Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit/src/builtins/awk/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
201 changes: 201 additions & 0 deletions crates/bashkit/tests/integration/awk_field_depth_tests.rs
Original file line number Diff line number Diff line change
@@ -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<AtomicBool>,
) -> std::thread::JoinHandle<Vec<u8>> {
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);
}
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |
Expand Down
Loading