Merge nucleic/brisk-yarn-dingo-50se into dev
This commit is contained in:
+14
-1
@@ -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).
|
||||
|
||||
@@ -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 <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]
|
||||
fn pipe_sigpipe_consumer_exits_early() {
|
||||
// `yes | head` — the consumer closes after N lines; the producer must get
|
||||
|
||||
Reference in New Issue
Block a user