diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index c7a17f912..bf8c23d3e 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -129,6 +129,7 @@ through configurable limits. | Pattern substitution (TM-DOS-127) | `${x//*a*/b}` over a long value | Globs run as a size-capped linear-time regex; extglob search capped at 10,000 attempts | MITIGATED | | Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** | | Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `: limit () exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** | +| Syntax diagnostic amplification (TM-DOS-136) | A multiline unexpected token and long `bash -c` ARG0 multiply diagnostic storage before stderr capture | Unexpected-token messages and complete syntax reports are capped at 1 KiB during construction; overflow stops formatting and appends `[diagnostic truncated]`. Short reports retain Bash text and syntax-error exit status | **MITIGATED** | | In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** | | Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution. Pending indexed compound-assignment keys, values, fields and containers also hold shared intermediate-memory leases, including repeated subscripts | **MITIGATED** | | `$( String { - let message = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") { - message.to_string() + // THREAT[TM-DOS-136]: stop before growth, including temporary formatting. + // The shell name (`-c` ARG0) is attacker-controlled and stamped on every + // report line: truncate it first so a long ARG0 cannot evict the + // structural payload (`syntax error ...`, token excerpt, marker) out of + // the byte budget. Short names print verbatim. + let shell: std::borrow::Cow<'_, str> = if who.chars().count() > MAX_SHELL_NAME_CHARS { + let head_len = MAX_SHELL_NAME_CHARS - SHELL_NAME_TAIL_CHARS; + let mut head_end = head_len; + while !who.is_char_boundary(head_end) { + head_end -= 1; + } + let mut tail_start = who.len(); + for _ in 0..SHELL_NAME_TAIL_CHARS { + if tail_start == 0 { + break; + } + tail_start -= 1; + while !who.is_char_boundary(tail_start) { + tail_start -= 1; + } + } + std::borrow::Cow::Owned(format!("{}...{}", &who[..head_end], &who[tail_start..])) } else { - format!("syntax error: {message}") + std::borrow::Cow::Borrowed(who) }; - if line == 0 { - return format!("{who}: {message}\n"); + let who = shell.as_ref(); + let mut out = Diagnostic::new(); + let _ = (|| -> fmt::Result { + let prefix = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") + { + "" + } else { + "syntax error: " + }; + if line == 0 { + return writeln!(out, "{who}: {prefix}{message}"); + } + let at = line.saturating_add(shift); + let mut lines = message.lines(); + let first = lines.next().unwrap_or_default(); + writeln!(out, "{who}: line {at}: {prefix}{first}")?; + if first.contains("near unexpected token") + && let Some(text) = source.lines().nth(line - 1) + { + writeln!(out, "{who}: line {at}: `{text}'")?; + } + for more in lines { + let further = more.strip_prefix("line ").and_then(|rest| { + let (n, text) = rest.split_once(": ")?; + Some((n.parse::().ok()?.saturating_add(shift), text)) + }); + match further { + Some((n, text)) => writeln!(out, "{who}: line {n}: {text}")?, + None => writeln!(out, "{who}: line {at}: {more}")?, + } + } + Ok(()) + })(); + out.finish() +} + +const MAX_DIAGNOSTIC_BYTES: usize = 1024; +/// Display width for the shell name (`-c` ARG0) inside one diagnostic. +/// Short names print verbatim; longer ones are cut here so a long ARG0 +/// cannot evict the structural payload out of the byte budget. The cut keeps +/// the head and the tail: the tail carries attribution suffixes such as +/// `: eval` (bash prints `