From 668a4005c82cd9ab6bf47fbba064b87d531b37be Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 09:28:11 +0000 Subject: [PATCH] fix(testing): render the diagnostic slot in the fuzz echo gate Nightly fuzz run 295 went red on `arithmetic_fuzz` with the 6-byte input `wZTok:`. Arithmetic diagnostics render `{diag_echo(expr.trim())}: {msg}`, so the expression's trailing colon lands next to the message separator's: bash: line 1: wZTok:: syntax error in expression (error token is ":") That spells the banned parser-token shape `Tok::` across the join while the script carries only `Tok:`. The tokenizer's `Tok` enum is never formatted and `Tok::` appears nowhere in the script, so this is bashkit echoing the expression back the way real bash does -- a false positive, not a TM-INF-022 leak. `input_echo_would_trip` matched the raw input, which cannot see a shape the template's own punctuation completes. It now renders the slot with the punctuation a diagnostic places around it (`DIAGNOSTIC_ECHO_PAD`) before matching, and matches the trimmed text because the slot holds `expr.trim()`. The same join completes the space-prefixed ` { code:` shape from the `bash: line N: ` prefix. Kept in the gate rather than widening `strip_real_shell_error_lines` to the arithmetic template on purpose: that template's `{msg}` half is bashkit's own text, the one slot where a genuine Debug leak would appear, so stripping the line would blind the detector exactly where it has to look. A skipped input hides nothing, because the skip condition is that the shape is attributable to the input. The crash input reproduces the CI panic byte-for-byte before the fix (same `testing.rs:155`, same raw stderr) and is gated after it. The pre-existing run 270/271/272 regressions and the negative tests that prove the gate did not become blanket all still pass. Claude-Session: https://claude.ai/code/session_01Wy7MBv7EWLPKBj4GqJeH5s --- .../fuzz/fuzz_targets/arithmetic_fuzz.rs | 6 +++ crates/bashkit/src/testing.rs | 49 ++++++++++++++++++- .../arithmetic_fuzz_scaffold_tests.rs | 43 ++++++++++++++++ knowledge/security/threat-model.md | 16 ++++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/fuzz/fuzz_targets/arithmetic_fuzz.rs b/crates/bashkit/fuzz/fuzz_targets/arithmetic_fuzz.rs index f24fdcf97..30f7eda3a 100644 --- a/crates/bashkit/fuzz/fuzz_targets/arithmetic_fuzz.rs +++ b/crates/bashkit/fuzz/fuzz_targets/arithmetic_fuzz.rs @@ -39,6 +39,12 @@ fuzz_target!(|data: &[u8]| { // `strip_real_shell_error_lines` recognizes, so filter at the input // layer, as `glob_fuzz` and `cpython_fuzz` do, and keep the leak // detector strict for genuine internals. + // + // The shape need not be in the input at all. The diagnostic renders + // `{expr}: {msg}`, so run 295's `wZTok:` gained the separator's colon + // and read `bash: line 1: wZTok:: syntax error in expression`, + // spelling `Tok::` across the join. `input_echo_would_trip` renders + // the slot before matching, so the gate sees those too. if bashkit::testing::input_echo_would_trip(input) { return; } diff --git a/crates/bashkit/src/testing.rs b/crates/bashkit/src/testing.rs index 3d30c1d3d..3a99946b2 100644 --- a/crates/bashkit/src/testing.rs +++ b/crates/bashkit/src/testing.rs @@ -250,12 +250,42 @@ fn ends_shell_error_template(line: &str) -> bool { /// run 272 libFuzzer recovered a 32-byte `PersAutoDict` entry for it and /// spliced the whole canary into an `arithmetic_fuzz` expression. pub fn input_echo_would_trip(input: &str) -> bool { + // Matching the raw bytes alone is not enough either: a diagnostic wraps + // the slot in its own punctuation, so a banned shape can be spelled + // across the join without appearing in the input at all. See + // `DIAGNOSTIC_ECHO_PAD`. + let (lead, trail) = DIAGNOSTIC_ECHO_PAD; + // Mirror `diag_echo(expr.trim())`: the slot holds the trimmed text, so + // trailing whitespace must not hide a join the render will still make. + let rendered = format!("{lead}{}{trail}", input.trim()); UNIVERSAL_BANNED .iter() .chain(std::iter::once(&FUZZ_HOST_CANARY)) - .any(|pat| carries_through_expansion(input, pat)) + .any(|pat| { + carries_through_expansion(input, pat) || carries_through_expansion(&rendered, pat) + }) } +/// Punctuation a real-shell diagnostic places immediately around an echoed +/// user-input slot, used to render the slot before matching it. +/// +/// `Interpreter::arith_diag` renders +/// `bash: line N: {diag_echo(expr.trim())}: {msg}`, so the expression slot +/// carries `: ` on its left from the line prefix and `: ` on its right as +/// the message separator. A banned shape can therefore form across either +/// join while the input itself is clean: nightly fuzz run 295 submitted +/// `wZTok:`, whose diagnostic reads `bash: line 1: wZTok:: syntax error in +/// expression (error token is ":")` and spells the parser-token shape +/// `Tok::` out of the input's trailing colon plus the separator's. The +/// tokenizer's `Tok` enum is never formatted, and `Tok::` appears nowhere +/// in the script, so this is an echo and not a TM-INF-022 leak. +/// +/// Padding both sides at once is the real rendering rather than a widening: +/// both characters come from the same template around the same slot. The +/// "error token" slot is rendered inside double quotes and no banned shape +/// contains `"`, so it needs no padding of its own. +const DIAGNOSTIC_ECHO_PAD: (&str, &str) = (": ", ": "); + /// True when `text` carries `needle` into a diagnostic — literally, or /// after the shell drops NUL bytes during word expansion, which turns /// `>=::TTA:::Tok::::` until it pre-filtered. Adding a builtin whose errors quote user text means choosing one of the two, never relaxing `UNIVERSAL_BANNED`. +A pre-filter that matches the raw input is not enough, because the banned shape +need not be in the input at all -- the diagnostic's own punctuation can finish +it. Arithmetic errors render `{diag_echo(expr.trim())}: {msg}`, so run 295's +`wZTok:` picked up the message separator's colon and the line read `bash: line +1: wZTok:: syntax error in expression (error token is ":")`, spelling `Tok::` +across the join while the script contained only `Tok:`. `input_echo_would_trip` +therefore renders the slot with the punctuation a diagnostic places around it +(`DIAGNOSTIC_ECHO_PAD`) before matching, and matches the trimmed text because +the slot holds `expr.trim()`. The same join completes the space-prefixed +` { code:` shape from the `bash: line N: ` prefix. Keeping this in the gate +rather than widening `strip_real_shell_error_lines` to the arithmetic template +is deliberate: that template's `{msg}` half is bashkit's own text, the one slot +where a genuine Debug leak would appear, so stripping the line would blind the +detector exactly where it has to look. A skipped input hides nothing, because +the skip condition is that the shape is attributable to the input. + 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