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
10 changes: 6 additions & 4 deletions crates/bashkit/fuzz/fuzz_targets/glob_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ fuzz_target!(|data: &[u8]| {
// containing e.g. "Span {" is not a TM-INF-022 leak. Filtering at
// the fuzz-input layer keeps the harness's leak detector strict
// for real internals while avoiding false positives.
for pat in bashkit::testing::UNIVERSAL_BANNED {
if input.contains(pat) {
return;
}
// Checks the input as the shell will render it back, not just the
// raw bytes: NUL is dropped during word expansion, so `\0{ code:`
// and `</r\0ustc/` slip past a literal `contains` and reappear in
// stderr as ` { code:` and `/rustc/` (runs 218 and 219).
if bashkit::testing::input_echo_would_trip(input) {
return;
}

let rt = tokio::runtime::Builder::new_current_thread()
Expand Down
146 changes: 122 additions & 24 deletions crates/bashkit/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,37 +136,94 @@ pub fn assert_no_leak(result: &ExecResult, ctx: &str, tool_banned: &[&str]) {
/// error template before the banned-shape check; the byte-length cap
/// and the host-canary check still run on the unfiltered stderr so
/// flood and TM-INF-013 regressions are still caught.
///
/// The user-input slot can itself contain newlines, in which case a
/// one-line template renders across several lines — `glob_fuzz` run 219
/// produced `bash: { code:<(])\n: command not found` from a command name
/// ending in `\n`. So matching is span-based, not line-based: a `bash: `
/// or `ls: ` line that does not close on its own extends to the *first*
/// later line ending in a template suffix. Closing on the earliest
/// candidate keeps the span minimal, so a leak printed after a complete
/// diagnostic still survives; and a real leak cannot be swallowed
/// *inside* a span, because each shell diagnostic is formatted and
/// written as one string with nothing interleaved.
fn strip_real_shell_error_lines(stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.filter(|line| !is_real_shell_error_line(line))
.collect();
lines.join("\n")
let lines: Vec<&str> = stderr.lines().collect();
let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if is_real_shell_error_line(line) {
i += 1;
continue;
}
if starts_shell_error_template(line)
&& let Some(close) = lines[i + 1..]
.iter()
.position(|l| ends_shell_error_template(l))
{
i += close + 2;
continue;
}
kept.push(line);
i += 1;
}
kept.join("\n")
}

/// A stderr line that opens a real-shell diagnostic but does not close it,
/// so the user-input slot must run on into the following lines.
fn starts_shell_error_template(line: &str) -> bool {
line.starts_with("bash: ") || line.starts_with("ls: cannot access ")
}

/// A stderr line that closes a real-shell diagnostic opened earlier.
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.
///
/// 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.
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))
}

/// Trailing halves of the real-shell diagnostic templates that quote user
/// input. A line ending in one of these closes such a diagnostic.
const SHELL_ERROR_SUFFIXES: &[&str] = &[
": command not found",
": No such file or directory",
": Is a directory",
": Permission denied",
": cannot execute: required file not found",
": cannot execute binary file",
// Remaining errno templates that `redirect_error_reason` can emit
// for `bash: <redirect target>: <strerror>`.
": Not a directory",
": File exists",
": Operation not supported",
// Fixed bashkit redirection refusals. Like the errno templates they
// quote the user-supplied redirect target verbatim, so a target
// containing a banned shape is an echo, not an internal leak.
": filesystem redirection disabled",
": cannot overwrite existing file",
": filesystem is read-only",
];

/// Recognize stderr lines that bash, ls, or a uutils clap CLI produces
/// verbatim from user input. Conservative: each branch matches a fixed
/// real-shell error template that quotes input.
fn is_real_shell_error_line(line: &str) -> bool {
const SHELL_ERROR_SUFFIXES: &[&str] = &[
": command not found",
": No such file or directory",
": Is a directory",
": Permission denied",
": cannot execute: required file not found",
": cannot execute binary file",
// Remaining errno templates that `redirect_error_reason` can emit
// for `bash: <redirect target>: <strerror>`.
": Not a directory",
": File exists",
": Operation not supported",
// Fixed bashkit redirection refusals. Like the errno templates they
// quote the user-supplied redirect target verbatim, so a target
// containing a banned shape is an echo, not an internal leak.
": filesystem redirection disabled",
": cannot overwrite existing file",
": filesystem is read-only",
];
if let Some(rest) = line.strip_prefix("bash: ") {
if SHELL_ERROR_SUFFIXES.iter().any(|suf| rest.ends_with(suf)) {
return true;
Expand Down Expand Up @@ -369,6 +426,47 @@ mod tests {
assert!(stripped.contains("thread panicked"));
}

#[test]
fn strip_removes_command_not_found_split_across_lines() {
// Regression, glob_fuzz run 219 (crash-2ac9af1dcb0bb66e7849b347828a8236e12fdda3,
// bytes `\n \0{ code:<(])\n`). The command name itself contains a
// newline, so bash's one-line `bash: %s: command not found` template
// renders across two lines and the line-based matcher saw neither
// half as a template.
let s = "bash: { code:<(])\n: command not found\n";
assert_eq!(strip_real_shell_error_lines(s), "");
}

#[test]
fn strip_span_stops_at_the_first_closing_line() {
// The span must close on the earliest line ending in a template
// suffix, so a real leak printed afterwards still survives.
let s = "bash: foo: command not found\nFile { code: 1 }\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("File {"), "stripped: {stripped:?}");
}

#[test]
fn strip_keeps_unclosed_bash_prefix_span() {
// A `bash: ` line with no closing template anywhere after it is not
// a diagnostic — it must not swallow the rest of stderr.
let s = "bash: partial Span {\nTok::Ident\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("Span {"), "stripped: {stripped:?}");
assert!(stripped.contains("Tok::"), "stripped: {stripped:?}");
}

#[test]
fn input_echo_would_trip_sees_through_nul_stripping() {
// Both glob_fuzz crashes hinged on this: the raw bytes carry a NUL
// that hides the banned shape from a literal `contains` check, and
// the shell drops it during expansion.
assert!(input_echo_would_trip("\n \0{ code:<(])\n"));
assert!(input_echo_would_trip("</r\0ustc/"));
assert!(input_echo_would_trip("plain /rustc/ path"));
assert!(!input_echo_would_trip("ls -la /tmp/*.txt"));
}

#[test]
fn strip_removes_clap_unexpected_argument_block() {
// From a real glob_fuzz failure on main: input bytes contained
Expand Down
35 changes: 35 additions & 0 deletions crates/bashkit/tests/integration/glob_fuzz_scaffold_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,41 @@ async fn glob_fuzz_crash_nul_stripped_redirect_target() {
}
}

/// Regression: fuzz run 219 (`crash-2ac9af1dcb0bb66e7849b347828a8236e12fdda3`,
/// bytes `[10, 32, 0, 123, 32, 99, 111, 100, 101, 58, 60, 40, 93, 41, 10]`
/// = `\n \0{ code:<(])\n`).
///
/// Same NUL-stripping mechanism as run 218 — the raw bytes hide the banned
/// ` { code:` behind a NUL — but a second gap too: the resulting command name
/// ends in a newline, so bash's one-line `command not found` template renders
/// across two lines and the echo filter must match it as one span.
#[tokio::test]
async fn glob_fuzz_crash_nul_stripped_newline_command_name() {
let input = "\n \0{ code:<(])\n";
// The target now skips this input outright, since the shell would echo a
// banned shape back. Assert that, then assert the harness would survive it
// anyway — the pre-filter and the echo filter are independent defenses.
assert!(bashkit::testing::input_echo_would_trip(input));

let mut bash = fuzz_bash();
for script in [
format!("ls /tmp/{}", input),
format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac",
input
),
format!("if [[ \"hello.world\" == {} ]]; then echo y; fi", input),
] {
fuzz_exec(
&mut bash,
&script,
"glob_fuzz_crash_nul_stripped_newline_command_name",
&[],
)
.await;
}
}

/// The missing-input-redirect diagnostic must match real bash byte for byte:
/// `bash: <path>: No such file or directory`.
#[tokio::test]
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 @@ -492,8 +492,30 @@ other message survives verbatim, because a backend-specific reason tells an agen
*why* in a way the bare errno cannot: `filesystem is read-only` must not collapse
into `Permission denied`, and a custom `FileSystem` impl's own wording is the only
diagnostic its embedder gets. Those specific reasons are instead covered by adding
their fixed templates to the echo filter. Regression coverage:
`tests/integration/glob_fuzz_scaffold_tests.rs`.
their fixed templates to the echo filter.

Run 219 (`\n \0{ code:<(])\n`) exposed the mechanism behind both failures and a
second, independent gap:

- **The pre-filter checked the raw bytes, but the shell transforms them.** NUL is
dropped during word expansion, so `\0{ code:` and `</r\0ustc/` slip past a
literal `contains` and reappear in stderr as ` { code:` and `/rustc/`. Targets
now call `testing::input_echo_would_trip`, which checks the input as the shell
will render it back. Quote and backslash removal can synthesize shapes the same
way, which is why the detector must not depend on the pre-filter alone.
- **The echo filter was line-based, but the input slot can contain newlines.** A
command name ending in `\n` renders bash's one-line `command not found`
template across two lines, and neither half matched. Matching is now span-based:
an unclosed `bash: `/`ls: cannot access ` line extends to the *first* later line
ending in a template suffix. Closing on the earliest candidate keeps the span
minimal so a leak printed after a complete diagnostic still survives, and a leak
cannot be swallowed *inside* a span because each shell diagnostic is formatted
and written as one string with nothing interleaved.

Regression coverage: `tests/integration/glob_fuzz_scaffold_tests.rs` replays both
crash inputs through all three scripts the target builds, and the
`strip_*`/`input_echo_would_trip` unit tests in `src/testing.rs` pin both the
strip and keep directions.

**TM-INF-013**: The jq builtin previously called `std::env::set_var()` to expose
shell variables to jaq's `env` function. This also made host process env vars (API keys, tokens)
Expand Down