diff --git a/nash-observe/src/lib.rs b/nash-observe/src/lib.rs index 4610049..4fd8a5f 100644 --- a/nash-observe/src/lib.rs +++ b/nash-observe/src/lib.rs @@ -93,6 +93,11 @@ enum Event { pipeline_id: u64, from_index: u64, to_index: u64, + /// The commands either side of this link, as written — brush-core renders them from the + /// AST at spawn, so they name a compound stage correctly and arrive with the very first + /// link rather than waiting on the stages to exit. + from_text: String, + to_text: String, bytes: u64, truncated: bool, hash: String, @@ -311,7 +316,11 @@ fn redact(text: &str) -> String { } fn looks_secret(word: &str) -> bool { - let w = word.trim_matches(|c: char| c == '"' || c == '\'' || c == ','); + // `…` is trimmed alongside the quoting because brush-core appends it when it caps a pipeline + // stage label (docs/NASH.md §5.3). Glued onto the final token with no separator, it otherwise + // failed the charset test below and walked a truncated credential straight past the entropy + // branch — a secret that happened to straddle the cap left the guest in the clear. + let w = word.trim_matches(|c: char| c == '"' || c == '\'' || c == ',' || c == '…'); // Common token prefixes (GitHub PATs, Slack, Stripe, AWS, OpenAI/Anthropic, …). const PREFIXES: [&str; 10] = ["ghp_", "gho_", "ghs_", "github_pat_", "xoxb-", "xoxp-", "sk-", "AKIA", "ASIA", "AIza"]; @@ -523,6 +532,10 @@ impl brush_core::gate::Gate for RecordingGate { pipeline_id: ev.pipeline_id, from_index: ev.from_index as u64, to_index: ev.to_index as u64, + // Stage text is source, so it can carry a literal credential the same way argv + // can — every outbound string goes through the same masking (docs/NASH.md §5.4). + from_text: redact(&ev.from_text), + to_text: redact(&ev.to_text), bytes: ev.total_bytes, truncated: ev.truncated || ev.captured.len() > capped, // Hash over the captured prefix (the full stream isn't buffered). diff --git a/nash/tests/observe.rs b/nash/tests/observe.rs index aad4029..9be6457 100644 --- a/nash/tests/observe.rs +++ b/nash/tests/observe.rs @@ -554,6 +554,112 @@ fn nested_commands_inherit_their_enclosing_stage() { assert_eq!(cat["pipeline"]["index"], 1); } +/// Each link names the commands either side of it, rendered from their AST at spawn. This is what +/// makes `curl … | sh` legible; deriving it from exec events cannot, because a compound stage emits +/// one exec per command inside it and none of them says which owned the pipe. +#[test] +fn pipe_links_carry_their_stage_text() { + let sink = Sink::start(); + let status = run_nash(&sink, "printf 'a\\nb\\n' | grep -v a | cat > /dev/null"); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| evs.iter().filter(|e| e["kind"] == "pipe").count() >= 2); + let pipes: Vec<_> = events.iter().filter(|e| e["kind"] == "pipe").collect(); + let link0 = pipes.iter().find(|e| e["fromIndex"] == 0).expect("link 0->1"); + // Quoting is preserved as written, and a stage's redirections are part of the stage. + assert_eq!(link0["fromText"], "printf 'a\\nb\\n'"); + assert_eq!(link0["toText"], "grep -v a"); + let link1 = pipes.iter().find(|e| e["fromIndex"] == 1).expect("link 1->2"); + assert_eq!(link1["fromText"], "grep -v a"); + assert_eq!(link1["toText"], "cat > /dev/null"); +} + +/// The case exec-derived naming cannot get right: the stage is a compound command, so several exec +/// events share its slot and none of them is "the" stage. +#[test] +fn compound_stage_is_named_as_a_whole() { + let sink = Sink::start(); + let status = run_nash(&sink, "{ echo one; echo two; } | cat > /dev/null"); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| evs.iter().any(|e| e["kind"] == "pipe")); + let pipe = find(&events, "pipe", |_| true).expect("pipe event"); + let from = pipe["fromText"].as_str().unwrap(); + assert!(from.contains("echo one"), "names the whole stage, got {from:?}"); + assert!(from.contains("echo two"), "names the whole stage, got {from:?}"); + assert_eq!(pipe["toText"], "cat > /dev/null"); +} + +/// Stage text is source, so it can carry a literal credential exactly as argv can. It goes through +/// the same in-guest masking as every other outbound string (docs/NASH.md §5.4). +#[test] +fn stage_text_is_redacted() { + let sink = Sink::start(); + let status = run_nash( + &sink, + "echo 'Bearer ghp_abcdefghijklmnopqrstuvwxyz012345' | cat > /dev/null", + ); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| evs.iter().any(|e| e["kind"] == "pipe")); + let pipe = find(&events, "pipe", |_| true).expect("pipe event"); + let from = pipe["fromText"].as_str().unwrap(); + assert!(!from.contains("ghp_abcdefghij"), "credential must not survive: {from:?}"); + assert!(from.contains("redacted"), "and must be visibly masked: {from:?}"); +} + +/// A credential that straddles the stage-label cap must still be masked. The cap runs in brush-core +/// and appends `…` to the surviving text; glued onto the final token, that marker used to disqualify +/// it from the redactor's high-entropy branch and walk a truncated secret out of the guest. +#[test] +fn stage_text_redacts_a_secret_split_by_the_cap() { + let sink = Sink::start(); + // Land the key astride the 200-char cap with enough of it left to still look high-entropy: + // `echo -d -d ` is 164 chars, so 36 of the key's 40 survive and pick up the `…`. + let secret = "wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY01"; + let pad = "a".repeat(152); + // `PASTTHECAP` sits wholly beyond 200 chars, so its absence proves the cap actually fired — + // the `…` marker cannot prove it here, since redacting the straddling token consumes it. + let status = run_nash( + &sink, + &format!("echo -d {pad} -d {secret} PASTTHECAP | cat > /dev/null"), + ); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| evs.iter().any(|e| e["kind"] == "pipe")); + let pipe = find(&events, "pipe", |_| true).expect("pipe event"); + let from = pipe["fromText"].as_str().unwrap(); + assert!( + !from.contains("PASTTHECAP"), + "the label must actually have been capped for this to test anything: {from:?}" + ); + assert!( + !from.contains(&secret[..32]), + "a credential split by the cap must not survive: {from:?}" + ); + assert!(from.contains("redacted"), "and must be visibly masked: {from:?}"); +} + +/// A stage can be an entire loop body; the label is one line and bounded, because it rides on every +/// link of every pipeline. +#[test] +fn stage_text_is_single_line_and_capped() { + let sink = Sink::start(); + let long = "x".repeat(400); + let status = run_nash( + &sink, + &format!("while read l; do echo \"$l{long}\"; done < /etc/hostname | cat > /dev/null"), + ); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| evs.iter().any(|e| e["kind"] == "pipe")); + let pipe = find(&events, "pipe", |_| true).expect("pipe event"); + let from = pipe["fromText"].as_str().unwrap(); + assert!(!from.contains('\n'), "a label is one line: {from:?}"); + assert!(from.chars().count() <= 201, "capped, got {} chars", from.chars().count()); + assert!(from.ends_with('…'), "and marked as elided: {from:?}"); +} + #[test] fn pipe_sigpipe_consumer_exits_early() { // `yes | head` — the consumer closes after N lines; the producer must get