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
121 changes: 101 additions & 20 deletions crates/bashkit/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -188,20 +191,39 @@ 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
/// the raw bytes is not enough: the shell drops NUL bytes during word
/// expansion, so `\0{ code:` reaches stderr as ` { code:` and `</r\0ustc/`
/// as `/rustc/`. That transformation is what defeated `glob_fuzz`'s
/// original pre-filter in runs 218 and 219.
///
/// The canary belongs in this list because the detector hands libFuzzer
/// the bytes to forge it: `assert_fuzz_invariants` compares output
/// against [`FUZZ_HOST_CANARY`], that comparison is instrumented, and in
/// 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 {
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
/// `</r\0ustc/` into `/rustc/` and `\0{ code:` into ` { code:`.
///
/// Comparing the raw bytes alone is what defeated `glob_fuzz`'s original
/// pre-filter in runs 218 and 219. The `contains('\0')` guard keeps the
/// allocation off the common path: almost no input has a NUL, and this
/// runs once per pattern per fuzz iteration.
fn carries_through_expansion(text: &str, needle: &str) -> bool {
text.contains(needle) || (text.contains('\0') && text.replace('\0', "").contains(needle))
}

/// Trailing halves of the real-shell diagnostic templates that quote user
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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";
Expand Down
76 changes: 76 additions & 0 deletions crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", &[]);
}
11 changes: 6 additions & 5 deletions crates/bashkit/tests/proptest_security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", &[]);
}));
}

Expand Down Expand Up @@ -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 {"],
);
Expand All @@ -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", &[]);
}));
}

Expand All @@ -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", &[]);
}));
}

Expand All @@ -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", &[]);
}));
}

Expand All @@ -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", &[]);
}));
}
}
Expand Down
26 changes: 24 additions & 2 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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&<canary>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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
Loading