feat: enhance safety filters for shell commands by normalizing ANSI-C quoting

This commit is contained in:
asepharyana
2026-07-13 04:10:08 +07:00
parent a080957c26
commit d09e440e7e
14 changed files with 383 additions and 85 deletions
+4 -1
View File
@@ -57,8 +57,11 @@ pub fn check_credential_read(cmd: &str) -> Result<()> {
let cmd_no_quotes: String = cmd_lower.chars()
.filter(|&c| c != '\'' && c != '"')
.collect();
// Also check against ANSI-C quoting normalization so that
// $'cat\u0020~/.ssh/id_rsa' does not bypass the filter.
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
for pattern in &patterns {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
anyhow::bail!("credential read blocked: '{}'", pattern);
}
}
+15 -4
View File
@@ -48,16 +48,27 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
let cmd_no_quotes: String = cmd_lower.chars()
.filter(|&c| c != '\'' && c != '"')
.collect();
// Normalize ANSI-C quoting ($'...') which can encode spaces and
// special characters as escape sequences (e.g. $'push\u0020--force'
// → "push --force"), bypassing the raw substring matching above.
// We decode \n, \t, \r, \\, \', \xNN, \uNNNN and \NNN escapes
// inside $'...' blocks, then substitute the decoded text.
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
for pattern in &patterns {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
anyhow::bail!("destructive git operation blocked: '{}'", pattern);
}
}
// Additional check: any `+` prefixed refspec in a `git push` is a
// force push, regardless of whether it immediately follows `push`
// (e.g. `git push origin +main`). Use the quote-stripped form so
// that `push or''igin +ma''in` also matches.
if cmd_no_quotes.contains("push") {
// (e.g. `git push origin +main`). Use the normalized form so
// that ANSI-C quoting bypasses ($'push\u0020+ma''in') are also caught.
let check_push = if cmd_normalized.contains("push") {
&cmd_normalized
} else {
&cmd_no_quotes
};
if check_push.contains("push") {
let push_end = cmd_no_quotes.find("push").map(|i| i + 4).unwrap_or(0);
let after_push = &cmd_no_quotes[push_end..];
if after_push.contains('+') {
+90
View File
@@ -1,3 +1,93 @@
//! Pre-execution safety filters applied to shell commands before they're spawned.
pub mod git;
/// Decode ANSI-C quoted strings ($'...') found in `input`, replacing
/// them with their unquoted, escape-decoded equivalents.
///
/// Supports: \n, \t, \r, \\, \', \xNN (hex), \uNNNN (unicode codepoint),
/// \NNN (octal). Non-hex/octal digits after \x or backslash are passed
/// through verbatim. Invalid or incomplete escapes emit the raw
/// characters for safety (better a missed block than a false negative).
///
/// Why: ANSI-C quoting ($'rm\u0020-rf\u0020/') lets an attacker encode
/// spaces and special characters as escape sequences, bypassing the
/// substring-based pattern matching in the shell filters.
pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' && chars.peek() == Some(&'\'') {
chars.next(); // consume '
let mut decoded = String::new();
loop {
match chars.next() {
None | Some('\'') => break,
Some('\\') => {
match chars.next() {
None => { decoded.push('\\'); break; }
Some('n') => decoded.push('\n'),
Some('t') => decoded.push('\t'),
Some('r') => decoded.push('\r'),
Some('\\') => decoded.push('\\'),
Some('\'') => decoded.push('\''),
Some('x' | 'X') => {
// \xHH — hex escape (2 hex digits)
let hex: String = chars.by_ref().take(2).take_while(|c| c.is_ascii_hexdigit()).collect();
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
decoded.push(byte as char);
}
} else {
decoded.push('\\');
decoded.push('x');
decoded.push_str(&hex);
}
}
Some('u') => {
// \uNNNN — unicode escape (4 hex digits)
let hex: String = chars.by_ref().take(4).take_while(|c| c.is_ascii_hexdigit()).collect();
if hex.len() == 4 {
if let Ok(code) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(code) {
decoded.push(c);
}
}
} else {
decoded.push('\\');
decoded.push('u');
decoded.push_str(&hex);
}
}
Some(d @ '0'..='7') => {
// \NNN — octal escape (up to 3 digits)
let mut oct = String::from(d);
for _ in 0..2 {
match chars.peek() {
Some(c) if c.is_ascii_digit() && *c >= '0' && *c <= '7' => {
oct.push(chars.next().unwrap());
}
_ => break,
}
}
if let Ok(code) = u32::from_str_radix(&oct, 8) {
decoded.push(char::from_u32(code).unwrap_or('?'));
}
}
Some(c) => {
decoded.push('\\');
decoded.push(c);
}
}
}
Some(c) => decoded.push(c),
}
}
out.push_str(&decoded);
} else {
out.push(ch);
}
}
out
}