Merge nucleic/brisk-yarn-dingo-50se into dev

This commit is contained in:
2026-07-26 21:33:53 -07:00
parent 3a3489d276
commit 1d6b240dbb
2 changed files with 120 additions and 1 deletions
+14 -1
View File
@@ -93,6 +93,11 @@ enum Event {
pipeline_id: u64, pipeline_id: u64,
from_index: u64, from_index: u64,
to_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, bytes: u64,
truncated: bool, truncated: bool,
hash: String, hash: String,
@@ -311,7 +316,11 @@ fn redact(text: &str) -> String {
} }
fn looks_secret(word: &str) -> bool { 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, …). // Common token prefixes (GitHub PATs, Slack, Stripe, AWS, OpenAI/Anthropic, …).
const PREFIXES: [&str; 10] = const PREFIXES: [&str; 10] =
["ghp_", "gho_", "ghs_", "github_pat_", "xoxb-", "xoxp-", "sk-", "AKIA", "ASIA", "AIza"]; ["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, pipeline_id: ev.pipeline_id,
from_index: ev.from_index as u64, from_index: ev.from_index as u64,
to_index: ev.to_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, bytes: ev.total_bytes,
truncated: ev.truncated || ev.captured.len() > capped, truncated: ev.truncated || ev.captured.len() > capped,
// Hash over the captured prefix (the full stream isn't buffered). // Hash over the captured prefix (the full stream isn't buffered).
+106
View File
@@ -554,6 +554,112 @@ fn nested_commands_inherit_their_enclosing_stage() {
assert_eq!(cat["pipeline"]["index"], 1); 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 <pad> -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] #[test]
fn pipe_sigpipe_consumer_exits_early() { fn pipe_sigpipe_consumer_exits_early() {
// `yes | head` — the consumer closes after N lines; the producer must get // `yes | head` — the consumer closes after N lines; the producer must get