From 0006ca016967815cd36a22937161695c12519621 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 11:39:02 +0800 Subject: [PATCH 1/3] fix(sandbox): classify destructive commands from bash AST --- crates/astra-sandbox/src/bash_ast.rs | 226 +++++++++++++++++++++++++++ crates/astra-sandbox/src/command.rs | 47 +----- crates/astra-tools/src/shell_ops.rs | 36 ++--- 3 files changed, 247 insertions(+), 62 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index ceac2db6b8..79feb21089 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -59,15 +59,186 @@ fn collect_simple_commands(node: Node<'_>, source: &str, commands: &mut Vec Vec { + analyze_bash_risks_ast_inner(command, 0) +} + +fn analyze_bash_risks_ast_inner(command: &str, shell_depth: usize) -> Vec { let Some(tree) = parse_bash(command) else { return Vec::new(); }; let root = tree.root_node(); let mut ctx = RiskCtx::new(command); + + // Destructive tools must be identified from actual command invocations, + // never by scanning arbitrary source text. In particular, heredoc bodies + // and inline interpreter programs are data from Bash's point of view. + let mut commands = Vec::new(); + collect_simple_commands(root, command, &mut commands); + for words in commands { + if let Some(name) = destructive_command_name(&words) { + ctx.push(CommandRisk::DestructiveCommand(name.to_string())); + } + if let Some(script) = nested_shell_script(&words) { + // Quoted `sh -c` input is a new shell program, unlike heredoc + // input to Python/Node. Parse it as Bash so wrapping a real + // destructive command does not bypass executable detection. + if shell_depth >= 16 { + ctx.push(CommandRisk::RemoteCodeExecution); + } else { + for risk in analyze_bash_risks_ast_inner(script, shell_depth + 1) { + ctx.push(risk); + } + } + } + } + visit_node(root, &mut ctx); ctx.into_risks() } +fn nested_shell_script(words: &[String]) -> Option<&str> { + let index = effective_command_index(words)?; + let executable = command_basename(words.get(index)?); + if !matches!(executable.as_str(), "bash" | "sh" | "dash" | "zsh" | "ksh") { + return None; + } + + let mut argument_index = index + 1; + while let Some(raw) = words.get(argument_index) { + let argument = unquote_shell_word(raw); + if argument == "--" || !argument.starts_with('-') || argument == "-" { + return None; + } + if argument[1..].chars().any(|flag| flag == 'c') { + return words + .get(argument_index + 1) + .map(|script| unquote_shell_word(script)); + } + argument_index += 1; + } + None +} + +const DESTRUCTIVE_COMMANDS: &[&str] = &[ + "dd", + "mkswap", + "truncate", + "shred", + "wipefs", + "blkdiscard", + "fdisk", + "sfdisk", + "parted", + "cryptsetup", + "pvremove", + "vgremove", + "lvremove", + "zpool", + "zfs", + "shutdown", + "reboot", + "poweroff", + "halt", + "telinit", +]; + +fn destructive_command_name(words: &[String]) -> Option<&'static str> { + let index = effective_command_index(words)?; + let executable = command_basename(words.get(index)?); + if executable == "mkfs" || executable.starts_with("mkfs.") { + return Some("mkfs"); + } + DESTRUCTIVE_COMMANDS + .iter() + .copied() + .find(|candidate| executable.eq_ignore_ascii_case(candidate)) +} + +/// Resolve common transparent launchers without inspecting ordinary command +/// arguments. This preserves detection for `sudo dd`, `env X=1 wipefs`, etc., +/// while ensuring `python -c '... dd ...'` remains interpreter input. +fn effective_command_index(words: &[String]) -> Option { + let mut index = 0; + loop { + let executable = command_basename(words.get(index)?); + index += 1; + match executable.as_str() { + "command" | "builtin" | "exec" | "nohup" => { + index = skip_options(words, index, &[])?; + } + "env" => { + index = skip_options(words, index, &["-u", "--unset", "-C", "--chdir"])?; + while words.get(index).is_some_and(|word| is_assignment(word)) { + index += 1; + } + } + "sudo" | "doas" | "pkexec" => { + index = skip_options( + words, + index, + &[ + "-u", + "--user", + "-g", + "--group", + "-h", + "--host", + "-p", + "--prompt", + "-R", + "--chroot", + "-C", + "--close-from", + ], + )?; + } + _ => return Some(index - 1), + } + } +} + +fn skip_options(words: &[String], mut index: usize, options_with_value: &[&str]) -> Option { + while let Some(raw) = words.get(index) { + let argument = unquote_shell_word(raw); + if argument == "--" { + return (index + 1 < words.len()).then_some(index + 1); + } + if !argument.starts_with('-') || argument == "-" { + return Some(index); + } + let option = argument.split_once('=').map_or(argument, |(name, _)| name); + index += 1; + if options_with_value.contains(&option) && !argument.contains('=') { + index += 1; + } + } + None +} + +fn command_basename(raw: &str) -> String { + unquote_shell_word(raw) + .rsplit(['/', '\\']) + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +fn unquote_shell_word(raw: &str) -> &str { + raw.trim().trim_matches(|ch| matches!(ch, '\'' | '"')) +} + +fn is_assignment(raw: &str) -> bool { + let raw = unquote_shell_word(raw); + let Some((name, _)) = raw.split_once('=') else { + return false; + }; + let mut chars = name.chars(); + chars + .next() + .is_some_and(|first| first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + struct RiskCtx<'a> { src: &'a str, hits: Vec, @@ -369,6 +540,61 @@ mod tests { assert!(risks.contains(&CommandRisk::EnvManipulation)); } + #[test] + fn destructive_commands_are_classified_from_command_nodes() { + for executable in + DESTRUCTIVE_COMMANDS + .iter() + .copied() + .chain(["mkfs", "mkfs.ext4", "mkfs.xfs"]) + { + let command = format!("/usr/sbin/{executable} --example"); + assert!( + analyze_bash_risks_ast(&command) + .iter() + .any(|risk| matches!(risk, CommandRisk::DestructiveCommand(_))), + "configured destructive executable must be detected: {command}" + ); + } + + for command in [ + "command dd if=/dev/zero of=/dev/sda", + "builtin dd if=/dev/zero of=/dev/sda", + "exec dd if=/dev/zero of=/dev/sda", + "nohup dd if=/dev/zero of=/dev/sda", + "sudo wipefs -a /dev/sdb", + "doas wipefs -a /dev/sdb", + "pkexec wipefs -a /dev/sdb", + "env MODE=secure shred -u secrets.txt", + "bash -lc 'dd if=/dev/zero of=/dev/sda'", + "sudo sh -c 'wipefs -a /dev/sdb'", + ] { + assert!( + analyze_bash_risks_ast(command) + .iter() + .any(|risk| matches!(risk, CommandRisk::DestructiveCommand(_))), + "destructive command must be detected: {command}" + ); + } + } + + #[test] + fn destructive_words_in_data_are_not_commands() { + for command in [ + "echo dd", + "python3 -c 'dd = 1; print(dd)'", + "python3 <<'PY'\ndd = {'chart': 'bar'}\nprint(dd)\nPY", + "bash -c 'echo dd'", + ] { + assert!( + !analyze_bash_risks_ast(command) + .iter() + .any(|risk| matches!(risk, CommandRisk::DestructiveCommand(_))), + "data must not be classified as a destructive command: {command}" + ); + } + } + #[test] fn chmod_setuid_variants() { for cmd in [ diff --git a/crates/astra-sandbox/src/command.rs b/crates/astra-sandbox/src/command.rs index 7e8e9cb78b..92f6e721bf 100644 --- a/crates/astra-sandbox/src/command.rs +++ b/crates/astra-sandbox/src/command.rs @@ -230,8 +230,8 @@ fn analyze_command_risks_with_workspace( ) -> Vec { let mut risks = Vec::new(); - // 1) AST-level analysis (best-effort). This avoids many string-literal false positives. - // If parsing fails, we still fall back to the legacy heuristic scanner below. + // AST owns command-invocation risks, including destructive executables. The + // remaining checks below are limited to path and shell-feature diagnostics. let ast_risks = super::bash_ast::analyze_bash_risks_ast(command); let ast_parsed = super::bash_ast::parse_bash(command).is_some(); risks.extend(ast_risks); @@ -259,10 +259,6 @@ fn analyze_command_risks_with_workspace( push_unique(&mut risks, CommandRisk::CredentialAccess(path)); } - if let Some(cmd) = destructive_command(&lower) { - push_unique(&mut risks, CommandRisk::DestructiveCommand(cmd.to_string())); - } - if let Some(target) = workspace_out_write_target(command, workspace_root) { push_unique(&mut risks, CommandRisk::WorkspaceOutWrite(target)); } @@ -324,26 +320,6 @@ fn analyze_command_risks_with_workspace( risks } -const DESTRUCTIVE_COMMANDS: &[&str] = &[ - "dd", - "mkfs", - "mkfs.ext4", - "mkfs.xfs", - "truncate", - "shred", - "wipefs", - "blkdiscard", - "fdisk", - "sfdisk", - "parted", - "cryptsetup", - "pvremove", - "vgremove", - "lvremove", - "zpool", - "zfs", -]; - const CREDENTIAL_PATH_PREFIXES: &[&str] = &[ "/.ssh/", "~/.ssh/", @@ -369,18 +345,6 @@ const CREDENTIAL_PATH_PREFIXES: &[&str] = &[ const CREDENTIAL_FILE_NAMES: &[&str] = &["id_rsa", "id_ed25519", "id_ecdsa", "id_ed25519_sk"]; -fn destructive_command(lower: &str) -> Option<&'static str> { - for command in DESTRUCTIVE_COMMANDS { - if lower - .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_')) - .any(|token| token == *command) - { - return Some(*command); - } - } - None -} - fn credential_access_target(lower: &str) -> Option { for fragment in lower.split(|ch: char| ch.is_whitespace() || [';', '|', '&'].contains(&ch)) { let token = normalize_shell_token(fragment); @@ -2134,14 +2098,15 @@ mod tests { } #[test] - fn concrete_hazards_inside_inline_source_remain_visible() { + fn inline_source_is_not_reinterpreted_as_shell_commands() { assert!( analyze_command_risks("python3 -c \"open('/etc/shadow').read()\"") .contains(&CommandRisk::SensitivePathAccess("/etc/".into())) ); assert!( - analyze_command_risks("node -e 'require(\"child_process\").exec(\"dd\")'") - .contains(&CommandRisk::DestructiveCommand("dd".into())) + !analyze_command_risks("node -e 'require(\"child_process\").exec(\"dd\")'") + .iter() + .any(|risk| matches!(risk, CommandRisk::DestructiveCommand(_))) ); } } diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index 68cbb409e9..0099413055 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -566,8 +566,8 @@ struct SearchIgnoreRule { /// boundary because command parsing cannot enumerate arbitrary writers. /// /// Layering: -/// 1. Local substring/heuristic rules (destructive `rm`, pipe-to-shell, netcat, etc.). -/// 2. [`analyze_command_risks`] (tree-sitter + legacy): any reported risk **blocks** except +/// 1. Local rules for shell syntax with no ordinary executable (`rm -rf`, fork bombs, etc.). +/// 2. [`analyze_command_risks`] (tree-sitter command analysis): any reported risk **blocks** except /// [`CommandRisk::PathTraversal`] and [`CommandRisk::NetworkAccess`], which are allowed here /// so normal `cd ../..` and `curl`/`wget` workflows remain usable (network still subject to /// sandbox/permissions elsewhere). All other sandbox risks (e.g. [`CommandRisk::Eval`], @@ -740,25 +740,10 @@ pub fn validate_execute_bash_command_in_workspace( } validate_bash_background_task_contract(cmd)?; let lower = cmd.to_ascii_lowercase(); - let blocked_substrings = [ - "mkfs", - "mkswap", - " wipefs", - " dd if=", - " dd of=", - "shutdown", - "reboot", - "poweroff", - "halt", - "telinit", - "kill -9", - "pkill", - "killall", - " :(){", - ":(){ :", - "fork bomb", - ]; - for pat in blocked_substrings { + // Fork bombs are shell syntax rather than an executable invocation, so + // they remain an explicit syntax check. Executable risks are owned by the + // AST analyzer below and must not scan heredoc/interpreter source text. + for pat in [" :(){", ":(){ :"] { if lower.contains(pat) { return Err(format!( "Error: bash command matches a blocked destructive pattern ({pat:?})" @@ -5137,6 +5122,15 @@ printf 'probe.txt:1:needle\n' } } + #[test] + fn validate_execute_bash_allows_destructive_words_inside_heredoc_data() { + let command = "python3 <<'PY'\ndd = {'chart': 'bar'}\nprint(dd)\nPY"; + assert!( + validate_execute_bash_command(command).is_ok(), + "Python identifiers in heredoc data must not be treated as shell commands" + ); + } + #[test] fn validate_execute_bash_allows_typical_build_commands() { assert!(validate_execute_bash_command("cargo test -p foo --quiet").is_ok()); From 7db624c4c7d8843bee2c02b3da183f7a7b76e00b Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 13:07:27 +0800 Subject: [PATCH 2/3] fix(sandbox): parse nested shell options exactly --- crates/astra-sandbox/src/bash_ast.rs | 136 +++++++++++++++++++++++---- crates/astra-tools/src/shell_ops.rs | 2 + 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 79feb21089..755fb8ebb8 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -78,17 +78,21 @@ fn analyze_bash_risks_ast_inner(command: &str, shell_depth: usize) -> Vec= 16 { - ctx.push(CommandRisk::RemoteCodeExecution); - } else { + match nested_shell_script(&words) { + NestedShellScript::Script(script) if shell_depth < 16 => { + // Quoted `sh -c` input is a new shell program, unlike heredoc + // input to Python/Node. Parse it as Bash so wrapping a real + // destructive command does not bypass executable detection. for risk in analyze_bash_risks_ast_inner(script, shell_depth + 1) { ctx.push(risk); } } + NestedShellScript::Script(_) | NestedShellScript::Ambiguous => { + // An unbounded nesting depth or an option sequence whose + // command-string boundary is unclear cannot be authorized. + ctx.push(CommandRisk::RemoteCodeExecution); + } + NestedShellScript::None => {} } } @@ -96,27 +100,100 @@ fn analyze_bash_risks_ast_inner(command: &str, shell_depth: usize) -> Vec Option<&str> { - let index = effective_command_index(words)?; - let executable = command_basename(words.get(index)?); +enum NestedShellScript<'a> { + None, + Script(&'a str), + Ambiguous, +} + +const SHELL_LONG_OPTIONS: &[&str] = &[ + "--debug", + "--debugger", + "--dump-po-strings", + "--dump-strings", + "--help", + "--login", + "--noediting", + "--noprofile", + "--norc", + "--posix", + "--pretty-print", + "--restricted", + "--verbose", + "--version", +]; +const SHELL_LONG_OPTIONS_WITH_VALUE: &[&str] = &["--init-file", "--rcfile"]; +const SHELL_SHORT_OPTIONS: &str = "abefhiklmnprstuvxBCEHPTDqVE"; + +fn nested_shell_script(words: &[String]) -> NestedShellScript<'_> { + let Some(index) = effective_command_index(words) else { + return NestedShellScript::None; + }; + let Some(executable) = words.get(index).map(|word| command_basename(word)) else { + return NestedShellScript::None; + }; if !matches!(executable.as_str(), "bash" | "sh" | "dash" | "zsh" | "ksh") { - return None; + return NestedShellScript::None; } let mut argument_index = index + 1; while let Some(raw) = words.get(argument_index) { let argument = unquote_shell_word(raw); - if argument == "--" || !argument.starts_with('-') || argument == "-" { - return None; + if argument == "--" + || argument == "-" + || (!argument.starts_with('-') && !argument.starts_with('+')) + { + return NestedShellScript::None; + } + + if argument.starts_with("--") { + let (option, inline_value) = argument + .split_once('=') + .map_or((argument, false), |(option, _)| (option, true)); + if SHELL_LONG_OPTIONS.contains(&option) && !inline_value { + argument_index += 1; + continue; + } + if SHELL_LONG_OPTIONS_WITH_VALUE.contains(&option) { + if !inline_value { + if words.get(argument_index + 1).is_none() { + return NestedShellScript::Ambiguous; + } + argument_index += 1; + } + argument_index += 1; + continue; + } + return NestedShellScript::Ambiguous; } - if argument[1..].chars().any(|flag| flag == 'c') { + + let Some(flags) = argument.get(1..) else { + return NestedShellScript::Ambiguous; + }; + if flags.is_empty() + || flags + .chars() + .any(|flag| !SHELL_SHORT_OPTIONS.contains(flag) && !matches!(flag, 'c' | 'o' | 'O')) + { + return NestedShellScript::Ambiguous; + } + if argument.starts_with('+') && flags.contains('c') { + return NestedShellScript::Ambiguous; + } + let option_name_count = flags.matches(['o', 'O']).count(); + if flags.contains('c') { return words - .get(argument_index + 1) - .map(|script| unquote_shell_word(script)); + .get(argument_index + 1 + option_name_count) + .map_or(NestedShellScript::Ambiguous, |script| { + NestedShellScript::Script(unquote_shell_word(script)) + }); } - argument_index += 1; + if words.len() < argument_index + 1 + option_name_count { + return NestedShellScript::Ambiguous; + } + argument_index += 1 + option_name_count; } - None + NestedShellScript::None } const DESTRUCTIVE_COMMANDS: &[&str] = &[ @@ -567,7 +644,13 @@ mod tests { "pkexec wipefs -a /dev/sdb", "env MODE=secure shred -u secrets.txt", "bash -lc 'dd if=/dev/zero of=/dev/sda'", + "bash -oc pipefail 'dd if=/dev/zero of=/dev/sda'", + "bash -oO pipefail extglob -c 'wipefs -a /dev/sdb'", "sudo sh -c 'wipefs -a /dev/sdb'", + "bash --norc -c 'dd if=/dev/zero of=/dev/sda'", + "bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", + "sudo bash --norc -c 'dd if=/dev/zero of=/dev/sda'", + "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", ] { assert!( analyze_bash_risks_ast(command) @@ -585,6 +668,9 @@ mod tests { "python3 -c 'dd = 1; print(dd)'", "python3 <<'PY'\ndd = {'chart': 'bar'}\nprint(dd)\nPY", "bash -c 'echo dd'", + "bash -oc pipefail 'echo dd'", + "bash --norc -c 'echo dd'", + "bash --rcfile /tmp/bashrc -c 'echo dd'", ] { assert!( !analyze_bash_risks_ast(command) @@ -595,6 +681,20 @@ mod tests { } } + #[test] + fn ambiguous_shell_options_fail_closed() { + for command in [ + "bash --unknown-option -c 'echo safe'", + "bash +c 'echo safe'", + "bash --rcfile", + ] { + assert!( + analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), + "ambiguous shell invocation must fail closed: {command}" + ); + } + } + #[test] fn chmod_setuid_variants() { for cmd in [ diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index 0099413055..8cbc21a2f1 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -5110,6 +5110,8 @@ printf 'probe.txt:1:needle\n' fn validate_execute_bash_blocks_top5_security_risks() { for command in [ "dd if=/dev/zero of=/dev/sda", + "bash --norc -c 'dd if=/dev/zero of=/dev/sda'", + "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", "cat ~/.ssh/id_rsa", "echo data > ../outside.txt", "eval \"echo hi\"", From df3bd3b1011a90adb089c48caaac3b8550dd5067 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 14:16:28 +0800 Subject: [PATCH 3/3] fix(sandbox): resolve dispatched executables --- crates/astra-sandbox/src/bash_ast.rs | 443 +++++++++++++++++++++++---- crates/astra-tools/src/shell_ops.rs | 4 + 2 files changed, 386 insertions(+), 61 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 755fb8ebb8..dc197847a3 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -52,6 +52,90 @@ fn collect_simple_commands(node: Node<'_>, source: &str, commands: &mut Vec Option<&str> { + match self { + Self::Literal(value) => Some(value), + Self::Dynamic => None, + } + } +} + +fn command_words(node: Node<'_>, source: &str) -> Option> { + if !matches!(node.kind(), "command" | "simple_command") || node.has_error() { + return None; + } + let mut words = Vec::new(); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + let word_node = match child.kind() { + "command_name" => child.named_child(0)?, + "word" | "number" | "string" | "raw_string" | "concatenation" => child, + "variable_assignment" + | "file_redirect" + | "heredoc_redirect" + | "herestring_redirect" => continue, + _ => { + words.push(CommandWord::Dynamic); + continue; + } + }; + words.push( + literal_command_word(word_node, source) + .map(CommandWord::Literal) + .unwrap_or(CommandWord::Dynamic), + ); + } + (!words.is_empty()).then_some(words) +} + +fn literal_command_word(node: Node<'_>, source: &str) -> Option { + let mut stack = vec![node]; + while let Some(candidate) = stack.pop() { + if matches!( + candidate.kind(), + "expansion" + | "simple_expansion" + | "command_substitution" + | "process_substitution" + | "arithmetic_expansion" + ) { + return None; + } + let mut cursor = candidate.walk(); + stack.extend(candidate.named_children(&mut cursor)); + } + + let raw = node.utf8_text(source.as_bytes()).ok()?.trim(); + if raw.is_empty() || raw.contains(['*', '?', '[', '`']) || raw.starts_with('~') { + return None; + } + let raw = raw + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .or_else(|| { + raw.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + }) + .unwrap_or(raw); + let mut value = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + value.push(chars.next()?); + } else { + value.push(ch); + } + } + Some(value) +} + /// AST-level bash risk analysis. /// /// This is intentionally conservative: it focuses on high-signal primitives @@ -69,31 +153,35 @@ fn analyze_bash_risks_ast_inner(command: &str, shell_depth: usize) -> Vec { - // Quoted `sh -c` input is a new shell program, unlike heredoc - // input to Python/Node. Parse it as Bash so wrapping a real - // destructive command does not bypass executable detection. - for risk in analyze_bash_risks_ast_inner(script, shell_depth + 1) { - ctx.push(risk); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if let Some(words) = command_words(node, command) { + match resolve_destructive_command(&words, shell_depth) { + DestructiveCommandResolution::Destructive(name) => { + ctx.push(CommandRisk::DestructiveCommand(name)); } + DestructiveCommandResolution::Ambiguous => { + ctx.push(CommandRisk::RemoteCodeExecution); + } + DestructiveCommandResolution::Safe => {} } - NestedShellScript::Script(_) | NestedShellScript::Ambiguous => { - // An unbounded nesting depth or an option sequence whose - // command-string boundary is unclear cannot be authorized. - ctx.push(CommandRisk::RemoteCodeExecution); + match nested_shell_script(&words) { + NestedShellScript::Script(script) if shell_depth < 16 => { + // Quoted `sh -c` input is a new shell program, unlike + // heredoc input to Python/Node. Parse it as Bash so a real + // destructive command cannot hide behind a shell wrapper. + for risk in analyze_bash_risks_ast_inner(script, shell_depth + 1) { + ctx.push(risk); + } + } + NestedShellScript::Script(_) | NestedShellScript::Ambiguous => { + ctx.push(CommandRisk::RemoteCodeExecution); + } + NestedShellScript::None => {} } - NestedShellScript::None => {} } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); } visit_node(root, &mut ctx); @@ -125,20 +213,28 @@ const SHELL_LONG_OPTIONS: &[&str] = &[ const SHELL_LONG_OPTIONS_WITH_VALUE: &[&str] = &["--init-file", "--rcfile"]; const SHELL_SHORT_OPTIONS: &str = "abefhiklmnprstuvxBCEHPTDqVE"; -fn nested_shell_script(words: &[String]) -> NestedShellScript<'_> { - let Some(index) = effective_command_index(words) else { - return NestedShellScript::None; +fn nested_shell_script(words: &[CommandWord]) -> NestedShellScript<'_> { + let index = match resolve_transparent_launcher(words, 0) { + Ok(Some(index)) => index, + Ok(None) => return NestedShellScript::None, + Err(()) => return NestedShellScript::Ambiguous, }; - let Some(executable) = words.get(index).map(|word| command_basename(word)) else { - return NestedShellScript::None; + let Some(executable) = words + .get(index) + .and_then(CommandWord::literal) + .map(command_basename) + else { + return NestedShellScript::Ambiguous; }; if !matches!(executable.as_str(), "bash" | "sh" | "dash" | "zsh" | "ksh") { return NestedShellScript::None; } let mut argument_index = index + 1; - while let Some(raw) = words.get(argument_index) { - let argument = unquote_shell_word(raw); + while let Some(word) = words.get(argument_index) { + let Some(argument) = word.literal() else { + return NestedShellScript::Ambiguous; + }; if argument == "--" || argument == "-" || (!argument.starts_with('-') && !argument.starts_with('+')) @@ -184,9 +280,8 @@ fn nested_shell_script(words: &[String]) -> NestedShellScript<'_> { if flags.contains('c') { return words .get(argument_index + 1 + option_name_count) - .map_or(NestedShellScript::Ambiguous, |script| { - NestedShellScript::Script(unquote_shell_word(script)) - }); + .and_then(CommandWord::literal) + .map_or(NestedShellScript::Ambiguous, NestedShellScript::Script); } if words.len() < argument_index + 1 + option_name_count { return NestedShellScript::Ambiguous; @@ -219,38 +314,94 @@ const DESTRUCTIVE_COMMANDS: &[&str] = &[ "telinit", ]; -fn destructive_command_name(words: &[String]) -> Option<&'static str> { - let index = effective_command_index(words)?; - let executable = command_basename(words.get(index)?); +enum DestructiveCommandResolution { + Safe, + Destructive(String), + Ambiguous, +} + +fn resolve_destructive_command( + words: &[CommandWord], + shell_depth: usize, +) -> DestructiveCommandResolution { + let index = match resolve_transparent_launcher(words, 0) { + Ok(Some(index)) => index, + Ok(None) => return DestructiveCommandResolution::Safe, + Err(()) => return DestructiveCommandResolution::Ambiguous, + }; + let Some(executable) = words.get(index).and_then(CommandWord::literal) else { + return DestructiveCommandResolution::Ambiguous; + }; + let executable = command_basename(executable); if executable == "mkfs" || executable.starts_with("mkfs.") { - return Some("mkfs"); + return DestructiveCommandResolution::Destructive("mkfs".to_string()); } - DESTRUCTIVE_COMMANDS + if let Some(name) = DESTRUCTIVE_COMMANDS .iter() .copied() .find(|candidate| executable.eq_ignore_ascii_case(candidate)) + { + return DestructiveCommandResolution::Destructive(name.to_string()); + } + match nested_shell_script(words) { + NestedShellScript::Script(_) if shell_depth >= 16 => { + return DestructiveCommandResolution::Ambiguous; + } + NestedShellScript::Script(script) => { + let nested_risks = analyze_bash_risks_ast_inner(script, shell_depth + 1); + if let Some(name) = nested_risks.into_iter().find_map(|risk| match risk { + CommandRisk::DestructiveCommand(name) => Some(name), + _ => None, + }) { + return DestructiveCommandResolution::Destructive(name); + } + } + NestedShellScript::Ambiguous => return DestructiveCommandResolution::Ambiguous, + NestedShellScript::None => {} + } + + match executable.as_str() { + "busybox" | "toybox" => resolve_multicall_applet(&words[index + 1..], shell_depth), + "xargs" => resolve_xargs_command(&words[index + 1..], shell_depth), + "find" => resolve_find_commands(&words[index + 1..], shell_depth), + _ => DestructiveCommandResolution::Safe, + } } -/// Resolve common transparent launchers without inspecting ordinary command -/// arguments. This preserves detection for `sudo dd`, `env X=1 wipefs`, etc., -/// while ensuring `python -c '... dd ...'` remains interpreter input. -fn effective_command_index(words: &[String]) -> Option { - let mut index = 0; +fn resolve_transparent_launcher( + words: &[CommandWord], + mut index: usize, +) -> Result, ()> { loop { - let executable = command_basename(words.get(index)?); + let Some(word) = words.get(index) else { + return Ok(None); + }; + let executable = command_basename(word.literal().ok_or(())?); index += 1; match executable.as_str() { "command" | "builtin" | "exec" | "nohup" => { - index = skip_options(words, index, &[])?; + let Some(next) = skip_literal_options(words, index, &[])? else { + return Ok(None); + }; + index = next; } "env" => { - index = skip_options(words, index, &["-u", "--unset", "-C", "--chdir"])?; - while words.get(index).is_some_and(|word| is_assignment(word)) { + let Some(next) = + skip_literal_options(words, index, &["-u", "--unset", "-C", "--chdir"])? + else { + return Ok(None); + }; + index = next; + while words + .get(index) + .and_then(CommandWord::literal) + .is_some_and(is_assignment) + { index += 1; } } "sudo" | "doas" | "pkexec" => { - index = skip_options( + let Some(next) = skip_literal_options( words, index, &[ @@ -267,45 +418,189 @@ fn effective_command_index(words: &[String]) -> Option { "-C", "--close-from", ], - )?; + )? + else { + return Ok(None); + }; + index = next; } - _ => return Some(index - 1), + _ => return Ok(Some(index - 1)), } } } -fn skip_options(words: &[String], mut index: usize, options_with_value: &[&str]) -> Option { - while let Some(raw) = words.get(index) { - let argument = unquote_shell_word(raw); +fn skip_literal_options( + words: &[CommandWord], + mut index: usize, + options_with_value: &[&str], +) -> Result, ()> { + while let Some(word) = words.get(index) { + let argument = word.literal().ok_or(())?; if argument == "--" { - return (index + 1 < words.len()).then_some(index + 1); + return Ok((index + 1 < words.len()).then_some(index + 1)); } if !argument.starts_with('-') || argument == "-" { - return Some(index); + return Ok(Some(index)); } let option = argument.split_once('=').map_or(argument, |(name, _)| name); index += 1; if options_with_value.contains(&option) && !argument.contains('=') { + if words.get(index).is_none() { + return Ok(None); + } index += 1; } } - None + Ok(None) +} + +fn resolve_multicall_applet( + words: &[CommandWord], + shell_depth: usize, +) -> DestructiveCommandResolution { + let Some(applet) = words.first().and_then(CommandWord::literal) else { + return if words.is_empty() { + DestructiveCommandResolution::Safe + } else { + DestructiveCommandResolution::Ambiguous + }; + }; + if applet.starts_with('-') { + return if matches!( + applet, + "--help" | "--list" | "--list-full" | "--install" | "--show" + ) { + DestructiveCommandResolution::Safe + } else { + DestructiveCommandResolution::Ambiguous + }; + } + resolve_destructive_command(words, shell_depth) +} + +fn resolve_xargs_command( + words: &[CommandWord], + shell_depth: usize, +) -> DestructiveCommandResolution { + const OPTIONS_WITH_VALUE: &[&str] = &[ + "-E", + "--eof", + "-I", + "--replace", + "-L", + "--max-lines", + "-n", + "--max-args", + "-P", + "--max-procs", + "-s", + "--max-chars", + "--process-slot-var", + "-a", + "--arg-file", + "-d", + "--delimiter", + ]; + const OPTIONS_WITH_ATTACHED_VALUE: &[&str] = &["-E", "-I", "-L", "-n", "-P", "-s", "-a", "-d"]; + const FLAGS: &[&str] = &[ + "-0", + "--null", + "-p", + "--interactive", + "-r", + "--no-run-if-empty", + "-t", + "--verbose", + "-x", + "--exit", + "--show-limits", + "--help", + "--version", + ]; + + let mut index = 0; + while let Some(word) = words.get(index) { + let Some(argument) = word.literal() else { + return DestructiveCommandResolution::Ambiguous; + }; + if argument == "--" { + index += 1; + break; + } + if !argument.starts_with('-') || argument == "-" { + break; + } + let option = argument.split_once('=').map_or(argument, |(name, _)| name); + if OPTIONS_WITH_VALUE.contains(&option) { + index += 1; + if !argument.contains('=') { + if words.get(index).is_none() { + return DestructiveCommandResolution::Ambiguous; + } + index += 1; + } + continue; + } + if OPTIONS_WITH_ATTACHED_VALUE + .iter() + .any(|prefix| argument.starts_with(prefix) && argument.len() > prefix.len()) + || FLAGS.contains(&argument) + || argument + .strip_prefix('-') + .is_some_and(|flags| flags.chars().all(|flag| "0prtx".contains(flag))) + { + index += 1; + continue; + } + return DestructiveCommandResolution::Ambiguous; + } + + if index == words.len() { + DestructiveCommandResolution::Safe + } else { + resolve_destructive_command(&words[index..], shell_depth) + } +} + +fn resolve_find_commands( + words: &[CommandWord], + shell_depth: usize, +) -> DestructiveCommandResolution { + let mut index = 0; + while index < words.len() { + let Some(argument) = words[index].literal() else { + index += 1; + continue; + }; + if !matches!(argument, "-exec" | "-execdir" | "-ok" | "-okdir") { + index += 1; + continue; + } + let command_start = index + 1; + let Some(command_end) = (command_start..words.len()).find(|candidate| { + words[*candidate] + .literal() + .is_some_and(|word| matches!(word, ";" | "+")) + }) else { + return DestructiveCommandResolution::Ambiguous; + }; + match resolve_destructive_command(&words[command_start..command_end], shell_depth) { + DestructiveCommandResolution::Safe => {} + result => return result, + } + index = command_end + 1; + } + DestructiveCommandResolution::Safe } fn command_basename(raw: &str) -> String { - unquote_shell_word(raw) - .rsplit(['/', '\\']) + raw.rsplit(['/', '\\']) .next() .unwrap_or_default() .to_ascii_lowercase() } -fn unquote_shell_word(raw: &str) -> &str { - raw.trim().trim_matches(|ch| matches!(ch, '\'' | '"')) -} - fn is_assignment(raw: &str) -> bool { - let raw = unquote_shell_word(raw); let Some((name, _)) = raw.split_once('=') else { return false; }; @@ -651,6 +946,13 @@ mod tests { "bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", "sudo bash --norc -c 'dd if=/dev/zero of=/dev/sda'", "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", + "busybox dd if=/dev/zero of=/dev/sda", + "toybox wipefs -a /dev/sdb", + "sudo busybox dd if=/dev/zero of=/dev/sda", + "printf '%s\\n' data | xargs -n 1 dd if=/dev/zero of=/dev/sda", + "find . -exec dd if=/dev/zero of=/dev/sda {} \\;", + "find . -execdir sh -c 'wipefs -a /dev/sdb' {} \\;", + "printf data | xargs sh -c 'dd if=/dev/zero of=/dev/sda'", ] { assert!( analyze_bash_risks_ast(command) @@ -671,6 +973,10 @@ mod tests { "bash -oc pipefail 'echo dd'", "bash --norc -c 'echo dd'", "bash --rcfile /tmp/bashrc -c 'echo dd'", + "busybox echo dd", + "printf '%s\\n' dd | xargs printf '%s\\n'", + "printf '%s\\n' dd | xargs", + "find . -name dd -print", ] { assert!( !analyze_bash_risks_ast(command) @@ -695,6 +1001,21 @@ mod tests { } } + #[test] + fn dynamic_dispatched_executables_fail_closed() { + for command in [ + "tool=dd; \"$tool\" if=/dev/zero of=/dev/sda", + "busybox \"$tool\" if=/dev/zero of=/dev/sda", + "printf data | xargs \"$tool\" if=/dev/zero of=/dev/sda", + "find . -exec \"$tool\" if=/dev/zero of=/dev/sda {} \\;", + ] { + assert!( + analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), + "dynamic executable position must fail closed: {command}" + ); + } + } + #[test] fn chmod_setuid_variants() { for cmd in [ diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index 8cbc21a2f1..bb580b32b6 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -5112,6 +5112,10 @@ printf 'probe.txt:1:needle\n' "dd if=/dev/zero of=/dev/sda", "bash --norc -c 'dd if=/dev/zero of=/dev/sda'", "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", + "busybox dd if=/dev/zero of=/dev/sda", + "printf data | xargs dd if=/dev/zero of=/dev/sda", + "find . -exec wipefs -a /dev/sdb {} \\;", + "tool=dd; \"$tool\" if=/dev/zero of=/dev/sda", "cat ~/.ssh/id_rsa", "echo data > ../outside.txt", "eval \"echo hi\"",