From c444fba758dfc03895c5b56099e43851bf325370 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 11:39:02 +0800 Subject: [PATCH 1/5] fix(sandbox): classify destructive commands from bash AST --- crates/astra-sandbox/src/bash_ast.rs | 240 ++++++++++++++++++++++++++- crates/astra-sandbox/src/command.rs | 47 +----- crates/astra-tools/src/shell_ops.rs | 36 ++-- 3 files changed, 259 insertions(+), 64 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 63c24c8005..1287360936 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -346,23 +346,180 @@ fn decode_double_quoted_content(raw: &str) -> Option { /// and avoids false positives from string literals. /// Returns detected risks. If the shell cannot be parsed, returns an empty vector (no substring fallback). pub fn analyze_bash_risks_ast(command: &str) -> 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); + let mut ctx = RiskCtx::with_shell_depth(command, shell_depth); + 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, + shell_depth: usize, hits: Vec, } impl<'a> RiskCtx<'a> { fn new(src: &'a str) -> Self { - Self { src, hits: vec![] } + Self::with_shell_depth(src, 0) + } + + fn with_shell_depth(src: &'a str, shell_depth: usize) -> Self { + Self { + src, + shell_depth, + hits: vec![], + } } fn push(&mut self, risk: CommandRisk) { @@ -505,6 +662,30 @@ fn analyze_command_invocation(node: Node<'_>, ctx: &mut RiskCtx<'_>) { }; let lower = name.to_ascii_lowercase(); + // 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. + if let Some(destructive) = destructive_command_name(std::slice::from_ref(&name)) { + ctx.push(CommandRisk::DestructiveCommand(destructive.to_string())); + } + if let Some(words) = parse_plain_command(node, ctx.src) { + if let Some(destructive) = destructive_command_name(&words) { + ctx.push(CommandRisk::DestructiveCommand(destructive.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 so wrappers cannot hide a real + // destructive command. + if ctx.shell_depth >= 16 { + ctx.push(CommandRisk::RemoteCodeExecution); + } else { + for risk in analyze_bash_risks_ast_inner(script, ctx.shell_depth + 1) { + ctx.push(risk); + } + } + } + } + // Privilege escalation: `su` only when invoking a login/root shell (`su -`), not bare `su`. if matches!(lower.as_str(), "sudo" | "doas") { ctx.push(CommandRisk::PrivilegeEscalation); @@ -785,6 +966,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 0a8549ecea..5d180a8a90 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); @@ -2142,14 +2106,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 09f19bfe1f..997a546ae3 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -560,8 +560,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`], @@ -734,25 +734,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:?})" @@ -6164,6 +6149,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 9b6475ab1400855b88b91a6730fc7498a3be6406 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 13:07:27 +0800 Subject: [PATCH 2/5] 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 1287360936..27a02303c6 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -360,27 +360,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)) + }); + } + if words.len() < argument_index + 1 + option_name_count { + return NestedShellScript::Ambiguous; } - argument_index += 1; + argument_index += 1 + option_name_count; } - None + NestedShellScript::None } const DESTRUCTIVE_COMMANDS: &[&str] = &[ @@ -672,17 +745,21 @@ fn analyze_command_invocation(node: Node<'_>, ctx: &mut RiskCtx<'_>) { if let Some(destructive) = destructive_command_name(&words) { ctx.push(CommandRisk::DestructiveCommand(destructive.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 so wrappers cannot hide a real - // destructive command. - if ctx.shell_depth >= 16 { - ctx.push(CommandRisk::RemoteCodeExecution); - } else { + match nested_shell_script(&words) { + NestedShellScript::Script(script) if ctx.shell_depth < 16 => { + // Quoted `sh -c` input is a new shell program, unlike heredoc + // input to Python/Node. Parse it so wrappers cannot hide a + // real destructive command. for risk in analyze_bash_risks_ast_inner(script, ctx.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 => {} } } @@ -993,7 +1070,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) @@ -1011,6 +1094,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) @@ -1021,6 +1107,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 997a546ae3..edc35765f6 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -6137,6 +6137,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 1f594219eab75dfecc5f6dbc75cf27dd5121af7f Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 14:16:28 +0800 Subject: [PATCH 3/5] fix(sandbox): resolve dispatched executables --- crates/astra-sandbox/src/bash_ast.rs | 386 +++++++++++++++++++++++---- crates/astra-tools/src/shell_ops.rs | 4 + 2 files changed, 335 insertions(+), 55 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 27a02303c6..0da2877bbb 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -212,6 +212,49 @@ fn parse_plain_command(node: Node<'_>, source: &str) -> Option> { (!words.is_empty()).then_some(words) } +#[derive(Debug)] +enum CommandWord { + Literal(String), + Dynamic, +} + +impl CommandWord { + fn literal(&self) -> Option<&str> { + match self { + Self::Literal(value) => Some(value), + Self::Dynamic => None, + } + } +} + +fn command_words(node: Node<'_>, source: &str) -> Option> { + if node.kind() != "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( + parse_plain_word(word_node, source) + .map(CommandWord::Literal) + .unwrap_or(CommandWord::Dynamic), + ); + } + (!words.is_empty()).then_some(words) +} + fn parse_plain_word(node: Node<'_>, source: &str) -> Option { match node.kind() { "word" | "number" => { @@ -270,7 +313,7 @@ fn decode_unquoted_shell_word(raw: &str) -> Option { while let Some(ch) = chars.next() { if ch == '\\' { value.push(chars.next()?); - } else if matches!(ch, '*' | '?' | '[' | ']') { + } else if matches!(ch, '*' | '?' | '[') { // Unquoted pathname/brace expansion means the source spelling is // not the argv Bash will execute. Escaped forms took the branch // above and are safe literal characters. @@ -385,20 +428,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('+')) @@ -444,9 +495,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; @@ -479,38 +529,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, &[ @@ -527,45 +633,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; }; @@ -730,20 +980,15 @@ fn analyze_redirection(node: Node<'_>, ctx: &mut RiskCtx<'_>) { } fn analyze_command_invocation(node: Node<'_>, ctx: &mut RiskCtx<'_>) { - let Some(name) = command_name(node, ctx) else { - return; - }; - let lower = name.to_ascii_lowercase(); - - // 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. - if let Some(destructive) = destructive_command_name(std::slice::from_ref(&name)) { - ctx.push(CommandRisk::DestructiveCommand(destructive.to_string())); - } - if let Some(words) = parse_plain_command(node, ctx.src) { - if let Some(destructive) = destructive_command_name(&words) { - ctx.push(CommandRisk::DestructiveCommand(destructive.to_string())); + if let Some(words) = command_words(node, ctx.src) { + match resolve_destructive_command(&words, ctx.shell_depth) { + DestructiveCommandResolution::Destructive(name) => { + ctx.push(CommandRisk::DestructiveCommand(name)); + } + DestructiveCommandResolution::Ambiguous => { + ctx.push(CommandRisk::RemoteCodeExecution); + } + DestructiveCommandResolution::Safe => {} } match nested_shell_script(&words) { NestedShellScript::Script(script) if ctx.shell_depth < 16 => { @@ -763,6 +1008,11 @@ fn analyze_command_invocation(node: Node<'_>, ctx: &mut RiskCtx<'_>) { } } + let Some(name) = command_name(node, ctx) else { + return; + }; + let lower = name.to_ascii_lowercase(); + // Privilege escalation: `su` only when invoking a login/root shell (`su -`), not bare `su`. if matches!(lower.as_str(), "sudo" | "doas") { ctx.push(CommandRisk::PrivilegeEscalation); @@ -1077,6 +1327,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) @@ -1097,6 +1354,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) @@ -1121,6 +1382,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 edc35765f6..ba9cb72fc3 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -6139,6 +6139,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\"", From a687143fdc40c6ccba5d2c517751c7f5e64dadb4 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 18:52:59 +0800 Subject: [PATCH 4/5] fix(sandbox): parse launcher option arity --- crates/astra-sandbox/src/bash_ast.rs | 245 ++++++++++++++++++++++++--- crates/astra-tools/src/shell_ops.rs | 5 + 2 files changed, 223 insertions(+), 27 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 0da2877bbb..6cf667e952 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -594,15 +594,69 @@ fn resolve_transparent_launcher( let executable = command_basename(word.literal().ok_or(())?); index += 1; match executable.as_str() { - "command" | "builtin" | "exec" | "nohup" => { - let Some(next) = skip_literal_options(words, index, &[])? else { + "command" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("pVv", "", &[], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "builtin" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("", "", &["--help"], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "exec" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("cl", "a", &["--help"], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "nohup" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("", "", &["--help", "--version"], &[], &[]), + )? + else { return Ok(None); }; index = next; } "env" => { - let Some(next) = - skip_literal_options(words, index, &["-u", "--unset", "-C", "--chdir"])? + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "i0v", + "uCPa", + &[ + "--ignore-environment", + "--null", + "--debug", + "--list-signal-handling", + "--help", + "--version", + ], + &["--unset", "--chdir", "--path", "--argv0"], + &["--block-signal", "--default-signal", "--ignore-signal"], + ), + )? else { return Ok(None); }; @@ -615,24 +669,74 @@ fn resolve_transparent_launcher( index += 1; } } - "sudo" | "doas" | "pkexec" => { - let Some(next) = skip_literal_options( + "sudo" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "ABbEHikKlNnPSseVv", + "CDghpRTUurt", + &[ + "--askpass", + "--background", + "--bell", + "--edit", + "--set-home", + "--help", + "--login", + "--remove-timestamp", + "--reset-timestamp", + "--list", + "--non-interactive", + "--preserve-groups", + "--stdin", + "--shell", + "--version", + "--validate", + ], + &[ + "--close-from", + "--chdir", + "--group", + "--host", + "--prompt", + "--chroot", + "--command-timeout", + "--other-user", + "--role", + "--type", + "--user", + ], + &["--preserve-env"], + ), + )? + else { + return Ok(None); + }; + index = next; + } + "doas" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("Lns", "aCu", &[], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "pkexec" => { + let Some(next) = skip_launcher_options( words, index, - &[ - "-u", - "--user", - "-g", - "--group", - "-h", - "--host", - "-p", - "--prompt", - "-R", - "--chroot", - "-C", - "--close-from", - ], + LauncherOptionGrammar::new( + "", + "", + &["--disable-internal-agent", "--keep-cwd", "--version"], + &["--user"], + &[], + ), )? else { return Ok(None); @@ -644,10 +748,37 @@ fn resolve_transparent_launcher( } } -fn skip_literal_options( +#[derive(Clone, Copy)] +struct LauncherOptionGrammar { + short_flags: &'static str, + short_options_with_value: &'static str, + long_flags: &'static [&'static str], + long_options_with_value: &'static [&'static str], + long_options_with_optional_value: &'static [&'static str], +} + +impl LauncherOptionGrammar { + const fn new( + short_flags: &'static str, + short_options_with_value: &'static str, + long_flags: &'static [&'static str], + long_options_with_value: &'static [&'static str], + long_options_with_optional_value: &'static [&'static str], + ) -> Self { + Self { + short_flags, + short_options_with_value, + long_flags, + long_options_with_value, + long_options_with_optional_value, + } + } +} + +fn skip_launcher_options( words: &[CommandWord], mut index: usize, - options_with_value: &[&str], + grammar: LauncherOptionGrammar, ) -> Result, ()> { while let Some(word) = words.get(index) { let argument = word.literal().ok_or(())?; @@ -657,14 +788,50 @@ fn skip_literal_options( if !argument.starts_with('-') || argument == "-" { 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); + + if argument.starts_with("--") { + let (option, inline_value) = argument + .split_once('=') + .map_or((argument, false), |(name, _)| (name, true)); + if grammar.long_flags.contains(&option) && !inline_value + || grammar.long_options_with_optional_value.contains(&option) + { + index += 1; + continue; + } + if !grammar.long_options_with_value.contains(&option) { + return Err(()); } index += 1; + if !inline_value { + if words.get(index).is_none() { + return Ok(None); + } + index += 1; + } + continue; } + + let mut flags = argument[1..].chars().peekable(); + if flags.peek().is_none() { + return Ok(Some(index)); + } + while let Some(flag) = flags.next() { + if grammar.short_flags.contains(flag) { + continue; + } + if !grammar.short_options_with_value.contains(flag) { + return Err(()); + } + if flags.peek().is_none() { + index += 1; + if words.get(index).is_none() { + return Ok(None); + } + } + break; + } + index += 1; } Ok(None) } @@ -1314,11 +1481,18 @@ mod tests { "command dd if=/dev/zero of=/dev/sda", "builtin dd if=/dev/zero of=/dev/sda", "exec dd if=/dev/zero of=/dev/sda", + "exec -a alias dd if=/dev/zero of=/dev/sda", + "exec -cla alias dd if=/dev/zero of=/dev/sda", + "exec -a \"$alias\" dd if=/dev/zero of=/dev/sda", "nohup dd if=/dev/zero of=/dev/sda", "sudo wipefs -a /dev/sdb", + "sudo -D /tmp dd if=/dev/zero of=/dev/sda", "doas wipefs -a /dev/sdb", + "doas -a passwd dd if=/dev/zero of=/dev/sda", "pkexec wipefs -a /dev/sdb", + "pkexec --user root dd if=/dev/zero of=/dev/sda", "env MODE=secure shred -u secrets.txt", + "env -u HOME dd if=/dev/zero of=/dev/sda", "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'", @@ -1354,6 +1528,9 @@ mod tests { "bash -oc pipefail 'echo dd'", "bash --norc -c 'echo dd'", "bash --rcfile /tmp/bashrc -c 'echo dd'", + "exec -a alias printf '%s\\n' dd", + "env -u HOME printf '%s\\n' dd", + "sudo -u root printf '%s\\n' dd", "busybox echo dd", "printf '%s\\n' dd | xargs printf '%s\\n'", "printf '%s\\n' dd | xargs", @@ -1397,6 +1574,20 @@ mod tests { } } + #[test] + fn ambiguous_launcher_options_fail_closed() { + for command in [ + "exec --future-option dd if=/dev/zero of=/dev/sda", + "sudo --future-option dd if=/dev/zero of=/dev/sda", + "env -S 'dd if=/dev/zero of=/dev/sda'", + ] { + assert!( + analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), + "launcher option with unproven arity 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 ba9cb72fc3..6ca14f88e8 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -6137,6 +6137,11 @@ printf 'probe.txt:1:needle\n' fn validate_execute_bash_blocks_top5_security_risks() { for command in [ "dd if=/dev/zero of=/dev/sda", + "exec -a alias dd if=/dev/zero of=/dev/sda", + "sudo -D /tmp dd if=/dev/zero of=/dev/sda", + "doas -a passwd dd if=/dev/zero of=/dev/sda", + "pkexec --user root dd if=/dev/zero of=/dev/sda", + "env -u HOME 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", From 7642c9a3d1b9411afc878b8b76bdf6424bad704d Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 22:05:30 +0800 Subject: [PATCH 5/5] fix(sandbox): resolve common command dispatchers --- crates/astra-sandbox/src/bash_ast.rs | 121 +++++++++++++++++++++++++++ crates/astra-tools/src/shell_ops.rs | 25 ++++++ 2 files changed, 146 insertions(+) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 6cf667e952..5a310ed9a8 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -743,6 +743,84 @@ fn resolve_transparent_launcher( }; index = next; } + "timeout" | "gtimeout" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "fpv", + "ks", + &[ + "--foreground", + "--preserve-status", + "--verbose", + "--help", + "--version", + ], + &["--kill-after", "--signal"], + &[], + ), + )? + else { + return Ok(None); + }; + let Some(next) = skip_launcher_operands(words, next, 1)? else { + return Ok(None); + }; + index = next; + } + "nice" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "", + "n", + &["--help", "--version"], + &["--adjustment"], + &[], + ) + .with_legacy_numeric_short_option(), + )? + else { + return Ok(None); + }; + index = next; + } + "ionice" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "thV", + "cnpPu", + &["--ignore", "--help", "--version"], + &["--class", "--classdata", "--pid", "--pgid", "--uid"], + &[], + ), + )? + else { + return Ok(None); + }; + index = next; + } + "setsid" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "cfwhV", + "", + &["--ctty", "--fork", "--wait", "--help", "--version"], + &[], + &[], + ), + )? + else { + return Ok(None); + }; + index = next; + } _ => return Ok(Some(index - 1)), } } @@ -755,6 +833,7 @@ struct LauncherOptionGrammar { long_flags: &'static [&'static str], long_options_with_value: &'static [&'static str], long_options_with_optional_value: &'static [&'static str], + legacy_numeric_short_option: bool, } impl LauncherOptionGrammar { @@ -771,8 +850,14 @@ impl LauncherOptionGrammar { long_flags, long_options_with_value, long_options_with_optional_value, + legacy_numeric_short_option: false, } } + + const fn with_legacy_numeric_short_option(mut self) -> Self { + self.legacy_numeric_short_option = true; + self + } } fn skip_launcher_options( @@ -812,6 +897,15 @@ fn skip_launcher_options( continue; } + if grammar.legacy_numeric_short_option + && argument.strip_prefix('-').is_some_and(|value| { + !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit()) + }) + { + index += 1; + continue; + } + let mut flags = argument[1..].chars().peekable(); if flags.peek().is_none() { return Ok(Some(index)); @@ -836,6 +930,18 @@ fn skip_launcher_options( Ok(None) } +fn skip_launcher_operands( + words: &[CommandWord], + index: usize, + count: usize, +) -> Result, ()> { + let command_index = index.checked_add(count).ok_or(())?; + if command_index > words.len() { + return Ok(None); + } + Ok((command_index < words.len()).then_some(command_index)) +} + fn resolve_multicall_applet( words: &[CommandWord], shell_depth: usize, @@ -1504,6 +1610,13 @@ mod tests { "busybox dd if=/dev/zero of=/dev/sda", "toybox wipefs -a /dev/sdb", "sudo busybox dd if=/dev/zero of=/dev/sda", + "timeout 5 dd if=/dev/zero of=/dev/sda", + "sudo timeout -s KILL 5 dd if=/dev/zero of=/dev/sda", + "nice -n 5 wipefs -a /dev/sdb", + "nice -5 dd if=/dev/zero of=/dev/sda", + "ionice -c 2 dd if=/dev/zero of=/dev/sda", + "setsid wipefs -a /dev/sdb", + "setsid env MODE=secure timeout 5 sudo 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' {} \\;", @@ -1531,6 +1644,10 @@ mod tests { "exec -a alias printf '%s\\n' dd", "env -u HOME printf '%s\\n' dd", "sudo -u root printf '%s\\n' dd", + "timeout 5 printf '%s\\n' dd", + "nice -n 5 printf '%s\\n' dd", + "ionice -c 2 printf '%s\\n' dd", + "setsid printf '%s\\n' dd", "busybox echo dd", "printf '%s\\n' dd | xargs printf '%s\\n'", "printf '%s\\n' dd | xargs", @@ -1580,6 +1697,10 @@ mod tests { "exec --future-option dd if=/dev/zero of=/dev/sda", "sudo --future-option dd if=/dev/zero of=/dev/sda", "env -S 'dd if=/dev/zero of=/dev/sda'", + "timeout --future-option 5 dd if=/dev/zero of=/dev/sda", + "nice --future-option dd if=/dev/zero of=/dev/sda", + "ionice --future-option dd if=/dev/zero of=/dev/sda", + "setsid --future-option dd if=/dev/zero of=/dev/sda", ] { assert!( analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index 6ca14f88e8..3910d2c5df 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -6145,6 +6145,12 @@ printf 'probe.txt:1:needle\n' "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", + "timeout 5 dd if=/dev/zero of=/dev/sda", + "sudo timeout -s KILL 5 dd if=/dev/zero of=/dev/sda", + "nice -n 5 wipefs -a /dev/sdb", + "ionice -c 2 dd if=/dev/zero of=/dev/sda", + "setsid wipefs -a /dev/sdb", + "setsid env MODE=secure timeout 5 sudo 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", @@ -6173,6 +6179,25 @@ printf 'probe.txt:1:needle\n' fn validate_execute_bash_allows_typical_build_commands() { assert!(validate_execute_bash_command("cargo test -p foo --quiet").is_ok()); assert!(validate_execute_bash_command("echo hello && ls").is_ok()); + assert!(validate_execute_bash_command("timeout 5 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("nice -n 5 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("ionice -c 2 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("setsid printf '%s\\n' dd").is_ok()); + } + + #[test] + fn validate_execute_bash_rejects_ambiguous_dispatcher_options() { + for command in [ + "timeout --future-option 5 dd if=/dev/zero of=/dev/sda", + "nice --future-option dd if=/dev/zero of=/dev/sda", + "ionice --future-option dd if=/dev/zero of=/dev/sda", + "setsid --future-option dd if=/dev/zero of=/dev/sda", + ] { + assert!( + validate_execute_bash_command(command).is_err(), + "unknown dispatcher option arity must fail closed: {command}" + ); + } } #[test]