diff --git a/crates/bashkit/src/testing.rs b/crates/bashkit/src/testing.rs index d19167c3e..341ff890f 100644 --- a/crates/bashkit/src/testing.rs +++ b/crates/bashkit/src/testing.rs @@ -13,7 +13,10 @@ //! - **TM-INF-013** — no host environment variables leak through //! builtins; verified via the canary mechanism: `fuzz_init()` sets a //! magic env var on the host process, and `assert_fuzz_invariants` -//! asserts that magic value never appears in builtin stdout/stderr. +//! asserts that magic value never appears in builtin stdout/stderr +//! unless the executed script itself carries it (a coverage-guided +//! fuzzer can forge the canary into its own input — see +//! `assert_fuzz_invariants`). use crate::{Bash, ControlFlow, ExecResult}; @@ -107,7 +110,7 @@ pub async fn fuzz_exec(bash: &mut Bash, script: &str, ctx: &str, tool_banned: &[ control_flow: ControlFlow::None, ..Default::default() }); - assert_fuzz_invariants(&result, ctx, tool_banned); + assert_fuzz_invariants(&result, script, ctx, tool_banned); } /// Assert that stderr is short and contains no banned Debug-shape or @@ -188,8 +191,9 @@ fn ends_shell_error_template(line: &str) -> bool { SHELL_ERROR_SUFFIXES.iter().any(|suf| line.ends_with(suf)) } -/// True when the shell would echo `input` back into a diagnostic -/// containing a [`UNIVERSAL_BANNED`] shape. +/// True when the shell would echo `input` back into a diagnostic that +/// trips the leak detector — either a [`UNIVERSAL_BANNED`] shape or the +/// [`FUZZ_HOST_CANARY`]. /// /// Fuzz targets that inline raw bytes into scripts use this to skip such /// inputs, keeping the leak detector strict for real internals. Checking @@ -197,11 +201,29 @@ fn ends_shell_error_template(line: &str) -> bool { /// expansion, so `\0{ code:` reaches stderr as ` { code:` and ` bool { - let without_nul = input.replace('\0', ""); UNIVERSAL_BANNED .iter() - .any(|pat| input.contains(pat) || without_nul.contains(pat)) + .chain(std::iter::once(&FUZZ_HOST_CANARY)) + .any(|pat| carries_through_expansion(input, pat)) +} + +/// True when `text` carries `needle` into a diagnostic — literally, or +/// after the shell drops NUL bytes during word expansion, which turns +/// ` bool { + text.contains(needle) || (text.contains('\0') && text.replace('\0', "").contains(needle)) } /// Trailing halves of the real-shell diagnostic templates that quote user @@ -311,12 +333,21 @@ fn is_clap_error_chrome_line(line: &str) -> bool { /// Full fuzz-invariant check. Like [`assert_no_leak`] but tolerates /// real-shell-style error lines (which echo user input verbatim) and /// adds the host-canary check (TM-INF-013): the canary must not appear -/// in stdout or stderr. +/// in stdout or stderr unless `script` accounts for it. +/// +/// `script` is the script that produced `result`. The canary check is +/// attribution-based rather than absolute because the detector arms the +/// fuzzer against itself: the `contains(FUZZ_HOST_CANARY)` comparisons +/// below are instrumented, so libFuzzer can learn the canary and splice +/// it into its own input (run 272), and any builtin that echoes input +/// verbatim then reproduces it. A canary the script already carries is +/// that echo; a canary the script never mentions can only have come +/// from `std::env`, which is the regression this guards. /// /// Call this from cargo-fuzz targets and proptest cases — anywhere /// random input runs through a builtin. #[track_caller] -pub fn assert_fuzz_invariants(result: &ExecResult, ctx: &str, tool_banned: &[&str]) { +pub fn assert_fuzz_invariants(result: &ExecResult, script: &str, ctx: &str, tool_banned: &[&str]) { let stderr = &result.stderr; assert!( stderr.len() <= MAX_STDERR_BYTES, @@ -331,18 +362,21 @@ pub fn assert_fuzz_invariants(result: &ExecResult, ctx: &str, tool_banned: &[&st ---raw stderr---\n{stderr}\n---stripped---\n{stripped}\n---" ); } - assert!( - !result.stdout.contains(FUZZ_HOST_CANARY), - "[{ctx}] FUZZ canary leaked into stdout (TM-INF-013 regression — \ - a builtin is reading host env). stdout:\n---\n{}\n---", - truncate(&result.stdout, 512) - ); - assert!( - !result.stderr.contains(FUZZ_HOST_CANARY), - "[{ctx}] FUZZ canary leaked into stderr (TM-INF-013 regression — \ - a builtin is reading host env). stderr:\n---\n{}\n---", - truncate(&result.stderr, 512) - ); + // Only an occurrence the script cannot explain is a host-env read. + if !carries_through_expansion(script, FUZZ_HOST_CANARY) { + assert!( + !result.stdout.contains(FUZZ_HOST_CANARY), + "[{ctx}] FUZZ canary leaked into stdout (TM-INF-013 regression — \ + a builtin is reading host env). stdout:\n---\n{}\n---", + truncate(&result.stdout, 512) + ); + assert!( + !result.stderr.contains(FUZZ_HOST_CANARY), + "[{ctx}] FUZZ canary leaked into stderr (TM-INF-013 regression — \ + a builtin is reading host env). stderr:\n---\n{}\n---", + truncate(&result.stderr, 512) + ); + } } fn truncate(s: &str, max: usize) -> String { @@ -367,6 +401,53 @@ pub fn check_cpython_http_request(data: &[u8]) { mod tests { use super::*; + #[test] + fn canary_in_the_input_is_counted_as_an_echo() { + // Run 272: libFuzzer learned the canary from the detector's own + // `contains` and spliced it into the expression. The pre-filter must + // treat it like any other self-inflicted echo. + let input = format!("1+{FUZZ_HOST_CANARY}"); + assert!(input_echo_would_trip(&input)); + // NUL-split, as the shell will rejoin it during word expansion. + let (head, tail) = FUZZ_HOST_CANARY.split_at(10); + assert!(input_echo_would_trip(&format!("{head}\0{tail}"))); + } + + #[test] + fn canary_echoed_from_the_script_is_not_a_leak() { + let result = ExecResult { + stderr: format!("bash: {FUZZ_HOST_CANARY}: syntax error").into(), + ..Default::default() + }; + // The script put the canary there, so this is an echo, not a host read. + assert_fuzz_invariants( + &result, + &format!("echo $(({FUZZ_HOST_CANARY}))"), + "canary_echo", + &[], + ); + } + + #[test] + #[should_panic(expected = "FUZZ canary leaked into stderr")] + fn canary_absent_from_the_script_is_still_a_leak() { + let result = ExecResult { + stderr: format!("env: {FUZZ_HOST_CANARY}").into(), + ..Default::default() + }; + assert_fuzz_invariants(&result, "env", "canary_leak", &[]); + } + + #[test] + #[should_panic(expected = "FUZZ canary leaked into stdout")] + fn canary_absent_from_the_script_is_still_a_leak_on_stdout() { + let result = ExecResult { + stdout: FUZZ_HOST_CANARY.into(), + ..Default::default() + }; + assert_fuzz_invariants(&result, "env", "canary_leak_stdout", &[]); + } + #[test] fn strip_keeps_unrelated_lines() { let s = "warning: something\nthread panicked at lib.rs:1\n"; diff --git a/crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs b/crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs index 79943710c..b6819b56e 100644 --- a/crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs +++ b/crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs @@ -127,3 +127,79 @@ async fn same_shape_without_the_banned_text_is_still_checked() { assert!(!bashkit::testing::input_echo_would_trip(input)); fuzz_arith(input, "no_banned_text").await; } + +/// Run 272: libFuzzer spliced the leak detector's own host canary into the +/// expression, and the arithmetic diagnostic echoed it straight back. +/// +/// The canary bytes reach the fuzzer through the detector's own +/// `stderr.contains(FUZZ_HOST_CANARY)` comparison: libFuzzer recovered a +/// 32-byte `PersAutoDict` entry for it (`DE: +/// "BASHKIT_FUZZ_HOST_CANARY_47a83bc"`) and crossed the remaining bytes in +/// from the corpus. No builtin read host env -- the canary is in the output +/// only because the fuzzer put it in the script. Any target that echoes its +/// input verbatim can be driven into this, so the detector has to tell +/// "script explains it" from a real TM-INF-013 read. +#[tokio::test] +async fn run_272_canary_comes_from_the_input() { + // Rebuilt from the canary constant so the two cannot drift apart. + let input = format!( + "**HOST_CARY:ARY_47a83bcf_DO_NOT_LHi&{}EAKa", + bashkit::testing::FUZZ_HOST_CANARY + ); + assert_eq!(input.len(), 85, "crash input is 85 bytes: {input}"); + + // The target's gate admits it, so it really does reach exec: every byte + // is one `bashkit_fuzz::is_arithmetic_expression` accepts outside its + // grouping arms. (That crate is its own workspace, so mirror the set + // rather than depend on it.) + assert!( + input + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_ \t+-*/%<>=!&|^~?:,#".contains(&b)), + "input must pass the arithmetic gate: {input}" + ); + + // Pre-filter layer: the target skips the input before executing it. + assert!(bashkit::testing::input_echo_would_trip(&input)); + + // Echo layer: the canary does reach stderr, as a bounded verbatim echo. + let mut bash = fuzz_bash(); + let r = bash + .exec(&format!("echo $(({input}))")) + .await + .expect("exec"); + let err = r.stderr.to_string(); + assert!( + err.contains(bashkit::testing::FUZZ_HOST_CANARY), + "expected the echo to carry the canary: {err}" + ); + assert!(err.len() <= 1024, "stderr is {} bytes:\n{err}", err.len()); + + // Detector layer: the full invariant check tolerates it, because the + // script it ran accounts for every canary byte in the output. + fuzz_arith(&input, "run_272_canary_echo").await; +} + +/// The genuine TM-INF-013 regression is still caught: a canary in stderr that +/// the script cannot account for must still fail the invariant check. +#[test] +#[should_panic(expected = "FUZZ canary leaked into stderr")] +fn canary_not_explained_by_the_script_is_still_a_leak() { + let result = bashkit::ExecResult { + stderr: format!("env: {}\n", bashkit::testing::FUZZ_HOST_CANARY).into(), + ..Default::default() + }; + // The script never mentions the canary, so the only source is host env. + bashkit::testing::assert_fuzz_invariants(&result, "echo hi", "canary_real_leak", &[]); +} + +/// Same for stdout. +#[test] +#[should_panic(expected = "FUZZ canary leaked into stdout")] +fn canary_in_stdout_not_explained_by_the_script_is_still_a_leak() { + let result = bashkit::ExecResult { + stdout: bashkit::testing::FUZZ_HOST_CANARY.into(), + ..Default::default() + }; + bashkit::testing::assert_fuzz_invariants(&result, "echo hi", "canary_real_leak_stdout", &[]); +} diff --git a/crates/bashkit/tests/proptest_security.rs b/crates/bashkit/tests/proptest_security.rs index ff4a4f70f..e4981db3d 100644 --- a/crates/bashkit/tests/proptest_security.rs +++ b/crates/bashkit/tests/proptest_security.rs @@ -371,7 +371,7 @@ proptest! { let escaped = filter.replace('\'', "'\\''"); let script = format!("echo '{{}}' | jq '{}'", escaped); let result = bash.exec(&script).await.unwrap_or_default(); - assert_fuzz_invariants(&result, "jq_arbitrary_filter", &[]); + assert_fuzz_invariants(&result, &script, "jq_arbitrary_filter", &[]); })); } @@ -403,6 +403,7 @@ proptest! { let result = bash.exec(&script).await.unwrap_or_default(); assert_fuzz_invariants( &result, + &script, "yq_arbitrary_yaml_and_filter", &["serde_norway::", "Mapping {", "TaggedValue {"], ); @@ -429,7 +430,7 @@ proptest! { let escaped = program.replace('\'', "'\\''"); let script = format!("echo 'a b c' | awk '{}'", escaped); let result = bash.exec(&script).await.unwrap_or_default(); - assert_fuzz_invariants(&result, "awk_arbitrary_program", &[]); + assert_fuzz_invariants(&result, &script, "awk_arbitrary_program", &[]); })); } @@ -453,7 +454,7 @@ proptest! { let escaped = pattern.replace('\'', "'\\''"); let script = format!("echo 'hello world' | grep -E '{}'", escaped); let result = bash.exec(&script).await.unwrap_or_default(); - assert_fuzz_invariants(&result, "grep_arbitrary_regex", &[]); + assert_fuzz_invariants(&result, &script, "grep_arbitrary_regex", &[]); })); } @@ -477,7 +478,7 @@ proptest! { let escaped = expr.replace('\'', "'\\''"); let script = format!("echo 'hello' | sed '{}'", escaped); let result = bash.exec(&script).await.unwrap_or_default(); - assert_fuzz_invariants(&result, "sed_arbitrary_expr", &[]); + assert_fuzz_invariants(&result, &script, "sed_arbitrary_expr", &[]); })); } @@ -501,7 +502,7 @@ proptest! { let escaped = path.replace('\'', "'\\''"); let script = format!("echo '{{\"a\":1}}' | json get '{}'", escaped); let result = bash.exec(&script).await.unwrap_or_default(); - assert_fuzz_invariants(&result, "json_arbitrary_path", &[]); + assert_fuzz_invariants(&result, &script, "json_arbitrary_path", &[]); })); } } diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index ebd20efc6..bfbc80dc0 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -517,6 +517,24 @@ so `arithmetic_fuzz` went red in run 271 on `:A>>=::TTA:::Tok::::` until it pre-filtered. Adding a builtin whose errors quote user text means choosing one of the two, never relaxing `UNIVERSAL_BANNED`. +Run 272 showed the same false-positive class reaches the **host canary**, and +that the detector itself is what arms it. `assert_fuzz_invariants` compares +output against `FUZZ_HOST_CANARY` with `contains`, and that comparison is +instrumented: libFuzzer recovered the needle as a 32-byte `PersAutoDict` entry +(`DE: "BASHKIT_FUZZ_HOST_CANARY_47a83bc"`), crossed the remaining bytes in from +the corpus, and fed `arithmetic_fuzz` an expression carrying the whole canary +(`**HOST_CARY:ARY_47a83bcf_DO_NOT_LHi&EAKa`). The diagnostic echoed it +and the canary assertion fired with no builtin having touched host env. +Obfuscating the constant does not help — the reconstructed needle is still +compared — so the canary check is now **attribution-based**: a canary +occurrence is a TM-INF-013 leak only when the executed script cannot account +for it. `assert_fuzz_invariants` therefore takes the script, and +`input_echo_would_trip` counts the canary alongside `UNIVERSAL_BANNED` so +pre-filtering targets skip these inputs before executing them. Both layers are +kept: the pre-filter saves the execution, the attribution check covers every +target that inlines input, including those with no pre-filter. A canary in +output that the script does not contain still fails, unchanged. + Display of a **Bashkit** error is not a safe formatter either: `Error`'s own variants stringify as Rust enum shapes (`io error: `, `internal error: `) that no shell prints, and `internal error:` is in `UNIVERSAL_BANNED`. Builtins @@ -552,7 +570,10 @@ machinery (`bashkit::testing::assert_fuzz_invariants`): the magic value `BASHKIT_FUZZ_HOST_CANARY_47a83bcf_DO_NOT_LEAK` once per process. Every fuzz iteration asserts the canary appears in neither stdout nor stderr, a builtin reading `std::env::vars()` - instead of the sandboxed `ctx.env` trips this immediately. + instead of the sandboxed `ctx.env` trips this immediately. The + assertion is scoped by attribution: a canary the executed script + itself contains is the shell echoing input back, not a host read + (run 272, above), so only an unexplained occurrence fails. - **TM-INF-016 (host paths)**: `UNIVERSAL_BANNED` includes `/rustc/`, `/.cargo/registry/`, `target/{debug,release}/deps/`, `/.rustup/toolchains/` so leaked panic backtraces / build-artifact @@ -570,7 +591,8 @@ containing `/rustc/` lands in clap's quoted-argument chrome and trips the host-p check. These are not internal Debug leaks. To keep the detector strict on real internals while suppressing this false-positive class, `assert_fuzz_invariants` strips lines matching a recognized real-shell or clap error template before the banned-shape check; -the byte-length cap and host-canary check still run on the unfiltered stderr, so flood +the byte-length cap runs on the unfiltered stderr and the host-canary check runs on +whatever the script cannot account for, so flood and TM-INF-013 regressions are still caught. The strict per-builtin path (`assert_no_leak`) is unchanged, non-fuzz tests must not produce shell echoes at all.