diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 9d694185f..60b7a2220 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -92,7 +92,7 @@ through configurable limits. | Command sub depth (TM-DOS-021) | `$($($($())))` nesting | Inherited depth/fuel from parent | MITIGATED | | Parser depth (TM-DOS-022) | `(((((...))))))` nesting | `max_ast_depth` + hard cap (100) | MITIGATED | | Arithmetic depth (TM-DOS-026) | `$(((((...))))))` | `MAX_ARITHMETIC_DEPTH` (50) | MITIGATED | -| Builtin parser depth (TM-DOS-027) | Deeply nested awk/jq | `MAX_AWK_PARSER_DEPTH` (100) + `MAX_JQ_JSON_DEPTH` (100) | MITIGATED | +| Builtin parser depth (TM-DOS-027) | Deeply nested awk/jq, including awk field-operand unary chains | Shared `MAX_AWK_PARSER_DEPTH` (100) across every recursive awk expression path + `MAX_JQ_JSON_DEPTH` (100) | MITIGATED | | Collect dirs recursion (TM-DOS-049) | Deep VFS tree | Mitigated by `max_path_depth` | MITIGATED | | Python read-only self-mount recursion (TM-DOS-118) | `bash.mount("/", bash.fs(), read_only=True)` hides the live filesystem identity behind a wrapper | Python validates the resolved filesystem identity before wrapping it | MITIGATED | | find traversal amplification (TM-DOS-121) | `find -L` over a symlink cycle, unbounded `-exec ... {} +` batches, or `*`-heavy `-name` patterns | Canonical-path loop detection with a 40-hop symlink cap, budget-charged directory listings, capped output, batches flushed every 4096 paths, linear-time glob matching | MITIGATED | diff --git a/crates/bashkit/src/builtins/awk/parser.rs b/crates/bashkit/src/builtins/awk/parser.rs index d3099e8f0..1f771eee5 100644 --- a/crates/bashkit/src/builtins/awk/parser.rs +++ b/crates/bashkit/src/builtins/awk/parser.rs @@ -1154,18 +1154,24 @@ impl<'a> Parser<'a> { Ok(Expr::IncDec(Box::new(lv), delta, true)) } Tok::Minus => { + let saved = self.enter()?; self.advance()?; let e = self.parse_field_operand()?; + self.leave(saved); Ok(Expr::Neg(Box::new(e))) } Tok::Plus => { + let saved = self.enter()?; self.advance()?; let e = self.parse_field_operand()?; + self.leave(saved); Ok(Expr::Plus(Box::new(e))) } Tok::Not => { + let saved = self.enter()?; self.advance()?; let e = self.parse_field_operand()?; + self.leave(saved); Ok(Expr::Not(Box::new(e))) } _ => self.parse_primary(), diff --git a/crates/bashkit/tests/integration/awk_field_depth_tests.rs b/crates/bashkit/tests/integration/awk_field_depth_tests.rs new file mode 100644 index 000000000..7b31a9ec8 --- /dev/null +++ b/crates/bashkit/tests/integration/awk_field_depth_tests.rs @@ -0,0 +1,201 @@ +//! THREAT[TM-DOS-027]: field-operand recursion must share AWK depth accounting. +//! Run potentially aborting programs only in a supervised child test process. + +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const CHILD: &str = "BASHKIT_AWK_FIELD_DEPTH_CHILD"; +const FINITE_PROGRAM: &str = "BEGIN { $0=\"a b\"; i=0; print $++i, $!0, $+2, $-0 }"; + +fn drain( + mut stream: impl Read + Send + 'static, + exceeded: Arc, +) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut chunk = [0; 4096]; + loop { + let n = stream.read(&mut chunk).expect("read child output"); + if n == 0 { + return output; + } + if output.len() + n > 65536 { + exceeded.store(true, Ordering::Relaxed); + } else { + output.extend_from_slice(&chunk[..n]); + } + } + }) +} + +#[test] +#[cfg(unix)] +fn awk_field_depth_bounded() { + // OS CPU/stack/core limits, Linux address-space cap, continuous output + // drains and a wall watchdog also contain a regression that aborts/hangs. + #[cfg(target_os = "linux")] + let limits = + "ulimit -t 10 && ulimit -s 8192 && ulimit -c 0 && ulimit -v 2097152 && exec \"$@\""; + #[cfg(not(target_os = "linux"))] + let limits = "ulimit -t 10 && ulimit -s 8192 && ulimit -c 0 && exec \"$@\""; + let mut command = Command::new("/bin/sh"); + command.args(["-c", limits, "awk-depth"]); + let mut child = command + .arg(std::env::current_exe().unwrap()) + .args([ + "--exact", + "awk_field_depth_tests::awk_field_depth_child", + "--nocapture", + ]) + .env(CHILD, "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bounded child"); + let exceeded = Arc::new(AtomicBool::new(false)); + let stdout = drain(child.stdout.take().unwrap(), exceeded.clone()); + let stderr = drain(child.stderr.take().unwrap(), exceeded.clone()); + let start = Instant::now(); + let mut stopped = false; + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if exceeded.load(Ordering::Relaxed) || start.elapsed() > Duration::from_secs(20) { + stopped = true; + child.kill().unwrap(); + break child.wait().unwrap(); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let stdout = stdout.join().unwrap(); + let stderr = stderr.join().unwrap(); + assert!( + !stopped && !exceeded.load(Ordering::Relaxed), + "child exceeded bounds" + ); + assert!( + status.success(), + "child status {status}: {}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + assert!( + String::from_utf8_lossy(&stdout).contains("1 passed"), + "child test did not run" + ); +} + +#[test] +fn awk_field_depth_child() { + if std::env::var_os(CHILD).is_none() { + return; + } + // Keep the production API on a typical 2 MiB embedding thread stack. + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let mut bash = bashkit::Bash::new(); + for token in ["!", "- ", "+ ", "! + - "] { + for prefix in ["$", "++$", "getline $", "$++$"] { + let program = + format!("BEGIN {{ print {prefix}{}1 }}", token.repeat(200)); + for file in [false, true] { + let command = if file { + bash.fs() + .write_file( + Path::new("/tmp/payload.awk"), + program.as_bytes(), + ) + .await + .unwrap(); + "awk -f /tmp/payload.awk".to_owned() + } else { + format!("awk '{program}'") + }; + let result = bash.exec(&command).await.unwrap(); + assert_eq!( + result.exit_code, 2, + "token={token}, prefix={prefix}, file={file}" + ); + assert!(result.stdout.is_empty()); + assert!( + result.stderr.contains("nesting too deep"), + "{}", + result.stderr + ); + assert!(result.stderr.len() <= 1024); + let recovered = + bash.exec("awk 'BEGIN { print 42 }'").await.unwrap(); + assert_eq!(recovered.exit_code, 0); + assert_eq!(recovered.stdout, "42\n"); + assert!(recovered.stderr.is_empty()); + } + } + } + // BEGIN block + print + expression + field consume four levels. + for (count, status) in [(95, 0), (96, 0), (97, 2)] { + for token in ["! ", "- ", "+ "] { + let program = format!( + "BEGIN {{ $0=\"one two\"; print ${}0 }}", + token.repeat(count) + ); + let result = bash.exec(&format!("awk '{program}'")).await.unwrap(); + assert_eq!( + result.exit_code, status, + "count={count}, token={token}: {}", + result.stderr + ); + if status == 0 { + assert!(result.stderr.is_empty()); + let expected = if token == "! " && count % 2 == 1 { + "one\n" + } else { + "one two\n" + }; + assert_eq!(result.stdout, expected); + } else { + assert!(result.stderr.contains("101 levels, max 100")); + } + } + } + // Malformed operands remain syntax errors; recovery works afterwards. + let bad = bash.exec("awk 'BEGIN { print $! }'").await.unwrap(); + assert_eq!(bad.exit_code, 1); + assert!(bad.stderr.contains("syntax error")); + let good = bash.exec(&format!("awk '{FINITE_PROGRAM}'")).await.unwrap(); + assert_eq!(good.exit_code, 0); + assert_eq!(good.stdout, "a a b a b\n"); + assert!(good.stderr.is_empty()); + }); + }) + .unwrap() + .join() + .unwrap(); +} + +#[tokio::test] +async fn finite_field_operands_match_gawk() { + let oracle = match Command::new("gawk").arg(FINITE_PROGRAM).output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => panic!("run gawk oracle: {error}"), + }; + assert!(oracle.status.success()); + + let mut bash = bashkit::Bash::new(); + let result = bash.exec(&format!("awk '{FINITE_PROGRAM}'")).await.unwrap(); + assert_eq!(result.exit_code, oracle.status.code().unwrap()); + assert_eq!(result.stdout.as_bytes(), oracle.stdout); + assert_eq!(result.stderr.as_bytes(), oracle.stderr); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index db84adb10..89179cac2 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -19,6 +19,7 @@ pub mod archive_bzip2_tests; pub mod arithmetic_fuzz_scaffold_tests; pub mod arithmetic_resource_tests; pub mod array_budget_security_tests; +pub mod awk_field_depth_tests; pub mod awk_fuzz_scaffold_tests; pub mod awk_newline_tests; pub mod awk_pattern_tests; diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 84263f78f..974b26667 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -260,7 +260,7 @@ on default 2 MiB test threads (TM-DOS-044). | TM-DOS-023 | Long computation | Complex awk/sed regex, including repeated evaluation of dynamic awk and `[[ =~ ]]` operands | Linear-time regex engine; runtime regex compilation cached per evaluator (64 entries / 1 MB retained pattern text, including invalid patterns); timeout (30s) | **MITIGATED** | | TM-DOS-024 | Parser hang | Malformed input | `parser_timeout` (5s) + `max_parser_operations` | **MITIGATED** | | TM-DOS-025 | Regex backtrack | `grep "a](*b)*c" file`; `grep -P '(a+)+$' file` | Default `regex` engine is linear-time; `grep -P`, grep BRE/ERE back-references (`\(a\)\1`) and `sed` fancy-regex paths capped by `FANCY_BACKTRACK_LIMIT` (1M steps), exceeding it yields "no match", not a hang | **MITIGATED** | -| TM-DOS-027 | Builtin parser recursion | Deeply nested awk/jq expressions | awk: `AWK_MAX_PARSER_DEPTH` (100 nested groupings/statements) plus 1,000 operators per expression tree (`builtins/awk/parser.rs`), so the evaluator's recursion is bounded too; jq: `MAX_JQ_JSON_DEPTH` (100) | **MITIGATED** | +| TM-DOS-027 | Builtin parser recursion | Deeply nested awk/jq expressions, including unary operator chains after an awk `$` field operator | awk: every recursive grouping, statement, unary expression, and field operand shares `AWK_MAX_PARSER_DEPTH` (100), while operator chains share a 1,000-node tree cap (`builtins/awk/parser.rs`), so parsing and evaluator recursion are bounded; jq: `MAX_JQ_JSON_DEPTH` (100). Regression: `awk_field_depth_bounded` supervises inline and VFS `awk -f` routes with CPU, stack, output, and wall limits, plus an address-space limit where supported | **MITIGATED** | | TM-DOS-028 | Diff algorithm DoS | `diff` on two large unrelated files | LCS matrix capped at 10M cells; falls back to simple line-by-line output | **MITIGATED** | | TM-DOS-029 | Arithmetic overflow/panic | `$(( 2 ** -1 ))`, `$(( 1 << 64 ))`, `i64::MIN / -1` | `wrapping_*` / saturating ops; `wrapping_neg` for `i64::MIN / -1` and unary negate; `<<`/`>>` clamp shift amount | **MITIGATED** | | TM-DOS-043 | Arithmetic side-effect overflow/panic | `((x+=1))` or `$((x++))` at the `i64` boundaries | Compound assignment and prefix/postfix increment/decrement use wrapping arithmetic in both evaluator paths | **MITIGATED** |