feat: enhance safety filters for shell commands by normalizing ANSI-C quoting
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('+') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user