dig_logging/redact.rs
1//! The redaction engine (SPEC §8.2) — the SECOND line of defense behind the never-log-at-source rule
2//! (SPEC §7). Applied to every line at BUNDLE time ([`bundle::build`](crate::bundle) re-redacts the
3//! on-disk JSONL as it is zipped) so a log bundle is safe to hand to a stranger. It is NOT applied at
4//! write time — the on-disk log files hold RAW lines, and `logs tail`/a manual copy therefore see
5//! un-redacted text. The primary defense is source-discipline (SPEC §7, the never-log list); bundle
6//! redaction is the guaranteed chokepoint for anything sent off-box. The rule set is VERSIONED
7//! ([`RULES_VERSION`]) and recorded in every bundle manifest, so a bundle's redaction guarantees are
8//! auditable after the fact.
9//!
10//! A false negative ships a secret, so the detectors err toward over-redaction — with ONE deliberate
11//! exception: key detection is FIELD-NAME-driven, never a blanket "32-byte hex = secret" heuristic,
12//! because storeIds, rootHashes, coinIds, puzzle hashes, and peer IPs are ALSO high-entropy hex/base64
13//! and are KEPT (they are public and load-bearing for debugging, SPEC §8.2). A field whose NAME marks
14//! it secret (`*_key`/`*_secret`/`sk`/`xprv`/`wif`/`seed`/`mnemonic`/…) has its value redacted; a
15//! field on the known-safe list ([`SAFE_KEY_NAMES`]) is always kept even when its name ends `_key`
16//! (e.g. `resource_key`). The mnemonic detector matches a run of ≥12 consecutive BIP39-wordlist words
17//! regardless of whether they sit in `key=value`, a bare `# Mnemonic:` comment line, a numbered
18//! `1. abandon 2. ability …` layout, or a `\n`-escaped multi-line value — the `.test-credentials`
19//! leak (2026-07-12) proved that comment-style seeds are the real hazard. Non-English BIP39 wordlists
20//! are an accepted residual (English-only), documented in SPEC §8.2.
21
22use std::collections::HashSet;
23
24use once_cell::sync::Lazy;
25use regex::Regex;
26
27/// The versioned redaction rule set. Bump on any rule change; recorded in the bundle manifest.
28///
29/// v2 added field-name-driven private-key/seed redaction ([`SENSITIVE_KV`], [`KEY_PHRASE`]) and
30/// numbered-mnemonic detection, over v1's PEM + token/auth + narrow mnemonic set.
31/// v3 → v4: fixed AUTH_HEADER and BEARER to redact full standard-base64 credentials (including +/= chars).
32/// v4 → v5 (defense-in-depth residuals): the generic `key`/`keystore` names are now scrubbed when
33/// their VALUE looks like raw secret material ([`CONDITIONAL_SENSITIVE`]); [`KEY_PHRASE`] covers
34/// `identity|node|master|ed25519|bls|api` prose forms; positional Debug shapes with no separator
35/// (`PrivKey(…)`/`Seed([…])`/`Mnemonic("…")`) are caught by [`SECRET_DEBUG_TUPLE`]; and the `priv`
36/// substring rule is tightened to private-key markers so `privacy`/`private-beta` are not over-scrubbed.
37/// v5 → v6 (#723): [`SECRET_DEBUG_TUPLE`] became SUFFIX-driven instead of a fixed whole-name list, so
38/// prefixed/aliased secret types (`ExtendedPrivKey(…)`, `MasterSecret(…)`, `BlsSk(…)`, `Ed25519Sk(…)`)
39/// are now caught; `Sk` is matched case-sensitively so `Task(…)`/`Disk(…)` stay unscathed.
40/// v6 → v7 (#730): [`SECRET_DEBUG_BRACE`] extends the same suffix-driven type-marker set to the
41/// named-field BRACE Debug shape (`SigningKey { scalar: <hex> }`), which evaded both the bracket-only
42/// tuple detector and the field-name kv rule when the field name was non-sensitive.
43pub const RULES_VERSION: u32 = 7;
44
45/// The minimum consecutive BIP39 words that constitute a redactable mnemonic run (SPEC §8.2).
46const MIN_MNEMONIC_RUN: usize = 12;
47
48/// The authoritative English BIP39 wordlist as a fast lookup set (reused from the `bip39` crate).
49static BIP39_WORDS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
50 bip39::Language::English
51 .word_list()
52 .iter()
53 .copied()
54 .collect()
55});
56
57/// A BIP39 word token (3–8 lowercase letters) and its position in the input.
58static WORD: Lazy<Regex> = Lazy::new(|| Regex::new(r"[A-Za-z]{3,8}").unwrap());
59
60/// Chars allowed BETWEEN two mnemonic words: whitespace, and the punctuation/escapes a serialized
61/// seed can carry (`\n` escape, quotes, commas, colons, `#`, `-`, and the digits/`.`/`)` of a
62/// NUMBERED `1. abandon 2. ability …` layout). A gap of only these keeps a run contiguous, so a
63/// `\n`-joined, comment-embedded, or numbered seed is still caught as one run.
64static MNEMONIC_GAP: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^[\s\\n"',:#.)(0-9-]*$"#).unwrap());
65
66static PEM_BLOCK: Lazy<Regex> =
67 Lazy::new(|| Regex::new(r"(?s)-----BEGIN[^-]*-----.*?-----END[^-]*-----").unwrap());
68
69/// `Authorization: <v>` / `"authorization":"<v>"` — keep the key, redact the value.
70/// Handles `Authorization: <scheme> <token>` (e.g. Bearer, Basic, etc) and bare `Authorization: <opaque>`
71/// forms, consuming the optional scheme + full credential value together so all base64 chars (+/=//) are
72/// included. The value class `[^"\s,}]+` stops at quote/space/comma/brace to correctly bound header values
73/// in both plain-text and JSON-embedded logs.
74static AUTH_HEADER: Lazy<Regex> = Lazy::new(|| {
75 Regex::new(r#"(?i)(authorization"?\s*[:=]\s*"?)((?:[A-Za-z]+\s+)?[^"\s,}]+)"#).unwrap()
76});
77
78/// `Bearer <token>` anywhere - widen to capture full standard-base64 tokens (+ / =).
79static BEARER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)\bbearer\s+([^"\s,}]+)"#).unwrap());
80
81/// `token`/`api_key`/`secret`/`password`/`passphrase`/`pairing_code` = / : `<v>` (JSON or kv).
82static TOKEN_KV: Lazy<Regex> = Lazy::new(|| {
83 Regex::new(
84 r#"(?i)("?(?:token|api[_-]?key|apikey|secret|password|passphrase|pairing[_-]?code)"?\s*[:=]\s*"?)([^"\s,}]+)"#,
85 )
86 .unwrap()
87});
88
89/// Field names that are HIGH-ENTROPY but PUBLIC and load-bearing for debugging, so their values are
90/// KEPT even though the name may end `_key` (SPEC §8.2 KEEP list). When in doubt a name is treated as
91/// sensitive (a missed key leaks custody; a false-scrub of one of these merely hampers debugging), so
92/// this list is the explicit allow-list that overrides the `_key`/`_secret` suffix rule.
93static SAFE_KEY_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
94 [
95 "store_id",
96 "storeid",
97 "store",
98 "root",
99 "root_hash",
100 "roothash",
101 "coin_id",
102 "coinid",
103 "coin",
104 "puzzle_hash",
105 "owner_puzzle_hash",
106 "peer",
107 "peer_id",
108 "addr",
109 "address",
110 "ip",
111 "generation",
112 "capsule",
113 "resource_key",
114 "port",
115 "public_key",
116 "pubkey",
117 "verifying_key",
118 ]
119 .into_iter()
120 .collect()
121});
122
123/// Field names whose VALUE is always a secret regardless of suffix (kv or JSON). The `_key`/`_secret`
124/// suffix and `seed`/`mnemonic`/`priv` substrings extend this in [`is_sensitive_key`].
125const SENSITIVE_EXACT: &[&str] = &[
126 "sk",
127 "xprv",
128 "wif",
129 "seed",
130 "mnemonic",
131 "private_key",
132 "secret_key",
133 "signing_key",
134 "beacon_key",
135 "privkey",
136 "secretkey",
137];
138
139/// Any `name = value` / `name: value` / JSON `"name":"value"` pair — the value is redacted ONLY when
140/// the NAME marks it secret ([`is_sensitive_key`]); every other pair is left untouched. This is the
141/// field-name-driven key rule that catches `private_key`/`signing_key`/`sk`/`xprv`/`wif`/`seed`/
142/// `beacon_key`/`*_key`/`*_secret` (incl. raw base64/hex values) WITHOUT blanket-scrubbing public
143/// high-entropy ids. Group 1 = optional open quote, 2 = name, 3 = separator, 4 = value.
144static SENSITIVE_KV: Lazy<Regex> = Lazy::new(|| {
145 Regex::new(r#"(?i)("?)([A-Za-z][A-Za-z0-9_]*)("?\s*[:=]\s*"?)([^"\s,}]+)"#).unwrap()
146});
147
148/// A bare prose reference `<kind> key <hex-or-base64url>` (e.g. `loaded signing key <hex>`, `node key
149/// <hex>`), which no kv rule would catch. The `<kind>` alternation covers every phrase a DIG service
150/// uses to log a key inline. Group 1 = the `<kind> key` phrase (kept), group 2 = the secret material
151/// (standard base64 + base64url alphabet).
152static KEY_PHRASE: Lazy<Regex> = Lazy::new(|| {
153 Regex::new(
154 r"(?i)\b((?:signing|private|secret|beacon|identity|node|master|ed25519|bls|api)\s+key)\s+([A-Za-z0-9+/_-]{16,}={0,2})",
155 )
156 .unwrap()
157});
158
159/// Names too GENERIC to blanket-scrub (a `key=user_id` map-debug line is not a secret), redacted
160/// ONLY when the VALUE itself looks like raw secret material ([`value_looks_secret`]). This closes the
161/// bare-`key`/`keystore` residual (neither ends `_key`, so [`is_sensitive_key`] misses both) without
162/// false-scrubbing short, obviously-non-secret values.
163const CONDITIONAL_SENSITIVE: &[&str] = &["key", "keystore"];
164
165/// A VALUE that looks like raw secret key material: a long hex string or a base64/base64url blob
166/// (≥ 20 chars, standard-base64 + base64url alphabets incl. `+`/`/`/`-`/`_` and optional `=` padding).
167/// Hex is a subset of this alphabet, so this single shape covers 32-hex-char keys and base64-encoded
168/// keys alike. Used to gate [`CONDITIONAL_SENSITIVE`] names; mnemonic runs are already redacted upstream.
169static VALUE_SECRET_SHAPE: Lazy<Regex> =
170 Lazy::new(|| Regex::new(r"^[A-Za-z0-9+/_-]{20,}={0,2}$").unwrap());
171
172fn value_looks_secret(value: &str) -> bool {
173 VALUE_SECRET_SHAPE.is_match(value)
174}
175
176/// Positional / Debug-struct shapes that carry secret material with NO `:`/`=` separator — e.g.
177/// `PrivKey(0xabc…)`, `Seed([1, 2, 3])`, `Mnemonic("abandon …")`, `ExtendedPrivKey(…)`,
178/// `MasterSecret(…)`, `BlsSk(…)` — matched by no kv rule.
179///
180/// The detector is SUFFIX-driven, not a fixed list of whole type names (#723): it matches any
181/// CamelCase type identifier that ENDS in a secret marker immediately before the bracket, so a
182/// prefix like `Extended`/`Master`/`Node`/`Bls`/`Ed25519` is absorbed by the leading `[A-Za-z0-9]*`.
183/// A full-word marker (`privkey`/`privatekey`/`secretkey`/`signingkey`/`secretstring`/`secret`/
184/// `seed`/`mnemonic`/`keypair`/`xprv`/`xpriv`/`masterkey`) is matched CASE-INSENSITIVELY, while the
185/// short `Sk` abbreviation is matched CASE-SENSITIVELY (capital `S`, lowercase `k`) so genuine
186/// secret-key types (`BlsSk`, `Ed25519Sk`) are caught while common words ending in lowercase `sk`
187/// (`Task`, `Disk`, `Mask`, `Ask`) are NOT false-scrubbed. The marker must sit immediately before
188/// the bracket, so `Skip(…)`/`Secretariat(…)` do not match (intervening chars break the suffix), and
189/// benign wrappers like `Coin(…)`/`Peer(…)` have no marker at all. The list is explicitly
190/// NON-EXHAUSTIVE (SPEC §8.2) — source-discipline (SPEC §7) is the primary defense; this is
191/// defense-in-depth for the common secret-type Debug shapes.
192///
193/// Group 1 = the type name (kept), 2 = the opening bracket, 3 = the closing bracket; the enclosed
194/// material is replaced. `[^)\]]*` keeps the match within a single bracket pair.
195static SECRET_DEBUG_TUPLE: Lazy<Regex> = Lazy::new(|| {
196 Regex::new(
197 r"\b([A-Za-z0-9]*(?:(?i:privatekey|privkey|secretkey|signingkey|secretstring|secret|seed|mnemonic|keypair|xprv|xpriv|masterkey)|Sk))\s*([(\[])[^)\]]*([)\]])",
198 )
199 .unwrap()
200});
201
202/// The BRACE-form counterpart of [`SECRET_DEBUG_TUPLE`] (#730): a `{:?}`-printed secret STRUCT whose
203/// FIELD name is itself non-sensitive — `SigningKey { scalar: <hex> }`, `SecretKey { inner: … }`,
204/// `MasterSecret { bytes: [ … ] }`. Such a line evades BOTH the bracket-only tuple detector AND the
205/// field-name-driven [`SENSITIVE_KV`] rule (the `scalar`/`inner`/`bytes` field name is not marked),
206/// so the raw material shipped unredacted. It reuses the EXACT SAME suffix-driven type-marker set as
207/// the tuple detector (so a leading qualifier like `Master`/`Extended`/`Bls`/`Ed25519` is absorbed
208/// and `Sk` is matched case-sensitively) — only the delimiter differs (`{ … }` instead of `( … )` /
209/// `[ … ]`). The whole brace body is redacted. `[^{}]*` keeps the match inside a single brace pair,
210/// so a nested inner secret struct is matched on its own scan. The type list is NON-EXHAUSTIVE by
211/// design (SPEC §8.2): a marker-LESS secret type (e.g. a bare `Signer { … }`) still relies on
212/// source-discipline (SPEC §7) — this is defense-in-depth, not the primary defense.
213///
214/// Group 1 = the type name (kept); the enclosed brace body is replaced.
215static SECRET_DEBUG_BRACE: Lazy<Regex> = Lazy::new(|| {
216 Regex::new(
217 r"\b([A-Za-z0-9]*(?:(?i:privatekey|privkey|secretkey|signingkey|secretstring|secret|seed|mnemonic|keypair|xprv|xpriv|masterkey)|Sk))\s*\{[^{}]*\}",
218 )
219 .unwrap()
220});
221
222/// Is a field NAME one whose value must be redacted? Safe public ids ([`SAFE_KEY_NAMES`]) win first;
223/// then exact sensitive names, the `_key`/`_secret` suffix, `seed`/`mnemonic` substrings, and the
224/// private-key markers ([`marks_private_key`]). Deliberately does NOT contain a bare `priv` substring
225/// check — that over-scrubbed `privacy`/`private-beta`; the private-key markers are matched precisely.
226fn is_sensitive_key(name: &str) -> bool {
227 let name = name.to_ascii_lowercase();
228 if SAFE_KEY_NAMES.contains(name.as_str()) {
229 return false;
230 }
231 SENSITIVE_EXACT.contains(&name.as_str())
232 || name.ends_with("_key")
233 || name.ends_with("_secret")
234 || name.contains("seed")
235 || name.contains("mnemonic")
236 || marks_private_key(&name)
237}
238
239/// Does a field name mark a PRIVATE key precisely (not the incidental `priv` substring of `privacy`
240/// or `private-beta`)? Matches `priv`, a `priv_` prefix, and the `privkey`/`privatekey`/`xpriv`
241/// spellings — the private-key names that lack a `_key`/`_secret` suffix.
242fn marks_private_key(name: &str) -> bool {
243 name == "priv"
244 || name.starts_with("priv_")
245 || name.contains("privkey")
246 || name.contains("privatekey")
247 || name.contains("xpriv")
248}
249
250/// A bech32 `xch1…`/`txch1…` address — truncate to the HRP + first 8 payload chars.
251static BECH32: Lazy<Regex> =
252 Lazy::new(|| Regex::new(r"\b(t?xch1)([0-9a-z]{8})[0-9a-z]{4,}\b").unwrap());
253
254/// Home-dir usernames in Windows / Linux / macOS paths.
255static WIN_USER: Lazy<Regex> =
256 Lazy::new(|| Regex::new(r#"(?i)([A-Za-z]:\\Users\\)([^\\\s"]+)"#).unwrap());
257static NIX_USER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(/home/|/Users/)([^/\s"]+)"#).unwrap());
258
259/// Redact one line of log text (SPEC §8.2). Idempotent enough for repeated application.
260pub fn line(input: &str) -> String {
261 // Mnemonic runs first, on the original words, before other rules perturb the text.
262 let stage = redact_mnemonics(input);
263 let stage = PEM_BLOCK.replace_all(&stage, "[REDACTED:pem]").into_owned();
264 let stage = AUTH_HEADER
265 .replace_all(&stage, "${1}[REDACTED:auth]")
266 .into_owned();
267 let stage = BEARER
268 .replace_all(&stage, "Bearer [REDACTED:auth]")
269 .into_owned();
270 let stage = TOKEN_KV
271 .replace_all(&stage, "${1}[REDACTED:token]")
272 .into_owned();
273 let stage = KEY_PHRASE
274 .replace_all(&stage, "${1} [REDACTED:key]")
275 .into_owned();
276 // Positional Debug shapes (`PrivKey(…)`/`Seed([…])`) carrying secret material with no separator.
277 let stage = SECRET_DEBUG_TUPLE
278 .replace_all(&stage, "${1}${2}[REDACTED:key]${3}")
279 .into_owned();
280 // The brace-form Debug shape (`SigningKey { scalar: … }`) whose field name is non-sensitive.
281 let stage = SECRET_DEBUG_BRACE
282 .replace_all(&stage, "${1} { [REDACTED:key] }")
283 .into_owned();
284 // Field-name-driven key redaction: redact a value when its NAME is secret, OR when a GENERIC
285 // name (`key`/`keystore`) has a secret-SHAPED value; never re-touch an already-redacted value (so
286 // a prior token/auth rule keeps its `:token`/`:auth` kind).
287 let stage = SENSITIVE_KV
288 .replace_all(&stage, |caps: ®ex::Captures| {
289 let name = &caps[2];
290 let value = &caps[4];
291 let sensitive = is_sensitive_key(name)
292 || (CONDITIONAL_SENSITIVE.contains(&name.to_ascii_lowercase().as_str())
293 && value_looks_secret(value));
294 if sensitive && !value.starts_with("[REDACTED") {
295 format!("{}{}{}[REDACTED:key]", &caps[1], name, &caps[3])
296 } else {
297 caps[0].to_string()
298 }
299 })
300 .into_owned();
301 let stage = BECH32.replace_all(&stage, "${1}${2}…").into_owned();
302 let stage = WIN_USER.replace_all(&stage, r"${1}<user>").into_owned();
303 NIX_USER.replace_all(&stage, "${1}<user>").into_owned()
304}
305
306/// Redact every line of a multi-line string.
307pub fn text(input: &str) -> String {
308 input.lines().map(line).collect::<Vec<_>>().join("\n")
309}
310
311/// Find and replace maximal runs of ≥[`MIN_MNEMONIC_RUN`] consecutive BIP39 words (SPEC §8.2).
312fn redact_mnemonics(input: &str) -> String {
313 let words: Vec<_> = WORD
314 .find_iter(input)
315 .map(|m| {
316 (
317 m.start(),
318 m.end(),
319 BIP39_WORDS.contains(m.as_str().to_ascii_lowercase().as_str()),
320 )
321 })
322 .collect();
323
324 let mut out = String::new();
325 let mut cursor = 0; // byte index copied up to
326 let mut i = 0;
327 while i < words.len() {
328 // Extend a run of wordlist words whose gaps contain only separator chars.
329 let start = i;
330 let mut end = i;
331 while end + 1 < words.len()
332 && words[end].2
333 && words[end + 1].2
334 && MNEMONIC_GAP.is_match(&input[words[end].1..words[end + 1].0])
335 {
336 end += 1;
337 }
338 let run_len = if words[start].2 { end - start + 1 } else { 0 };
339 if run_len >= MIN_MNEMONIC_RUN {
340 out.push_str(&input[cursor..words[start].0]);
341 out.push_str("[REDACTED:mnemonic]");
342 cursor = words[end].1;
343 i = end + 1;
344 } else {
345 i += 1;
346 }
347 }
348 out.push_str(&input[cursor..]);
349 out
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 const SEED12: &str =
357 "abandon ability able about above absent absorb abstract absurd abuse access accident";
358
359 #[test]
360 fn redacts_key_value_mnemonic() {
361 let got = line(&format!("mnemonic={SEED12}"));
362 assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
363 assert!(!got.contains("abandon"));
364 }
365
366 #[test]
367 fn redacts_comment_style_mnemonic() {
368 // The `.test-credentials` leak shape: a seed on a `#` comment line, not key=value.
369 let got = line(&format!("# Mnemonic: {SEED12}"));
370 assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
371 assert!(!got.contains("abstract"));
372 }
373
374 #[test]
375 fn eleven_words_not_redacted() {
376 let eleven = SEED12.rsplit_once(' ').unwrap().0; // drop the 12th word
377 assert!(!line(eleven).contains("[REDACTED:mnemonic]"));
378 }
379
380 #[test]
381 fn redacts_pem_and_tokens() {
382 // PEM block redaction
383 let pem_out = line("key: -----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----");
384 assert!(pem_out.contains("[REDACTED:pem]"), "pem: {pem_out}");
385 assert!(!pem_out.contains("BEGIN"), "pem key leaked: {pem_out}");
386 assert!(!pem_out.contains("MIIB"), "pem key leaked: {pem_out}");
387
388 // JSON token field redaction
389 let token_out = line(r#"{"token":"abc123secret"}"#);
390 assert!(token_out.contains("[REDACTED:token]"), "token: {token_out}");
391 assert!(
392 !token_out.contains("abc123secret"),
393 "token leaked: {token_out}"
394 );
395
396 // Authorization header with Bearer token (the main security leak case)
397 let auth_out = line("Authorization: Bearer zzz.yyy.xxx");
398 assert!(auth_out.contains("[REDACTED:auth]"), "auth: {auth_out}");
399 assert!(
400 !auth_out.contains("zzz.yyy.xxx"),
401 "Bearer token leaked: {auth_out}"
402 );
403 assert!(
404 !auth_out.contains("Bearer zzz"),
405 "Bearer token leaked: {auth_out}"
406 );
407
408 // Bare Authorization header (non-Bearer form)
409 let opaque_out = line("Authorization: opaque_token_abc123");
410 assert!(
411 opaque_out.contains("[REDACTED:auth]"),
412 "opaque: {opaque_out}"
413 );
414 assert!(
415 !opaque_out.contains("opaque_token_abc123"),
416 "opaque token leaked: {opaque_out}"
417 );
418
419 // Standalone Bearer without Authorization prefix
420 let bearer_out = line("Bearer eyJ.payload.sig");
421 assert!(
422 bearer_out.contains("[REDACTED:auth]"),
423 "bearer: {bearer_out}"
424 );
425 assert!(
426 !bearer_out.contains("eyJ.payload.sig"),
427 "Bearer credential leaked: {bearer_out}"
428 );
429 }
430
431 #[test]
432 fn truncates_bech32_but_keeps_public_ids() {
433 let got = line("addr=xch1qqqqqqqqwwwwwwwweeeeeeee store=abc123def456 peer=203.0.113.7");
434 assert!(got.contains("xch1qqqqqqq…") || got.contains("…"), "{got}");
435 assert!(got.contains("abc123def456"), "store ids are KEPT: {got}");
436 assert!(got.contains("203.0.113.7"), "peer IPs are KEPT: {got}");
437 }
438
439 #[test]
440 fn scrubs_home_dir_username() {
441 assert!(line(r"path=C:\Users\alice\AppData").contains(r"C:\Users\<user>"));
442 assert!(line("path=/home/bob/logs").contains("/home/<user>"));
443 }
444
445 // --- v2: field-name-driven private-key / seed redaction (SECURITY regressions, §2.2) ---
446
447 /// Each named-key field must have its value redacted, in kv AND JSON shapes, while the FIELD
448 /// NAME survives so the log stays diagnosable.
449 #[test]
450 fn redacts_named_key_and_seed_fields() {
451 for name in [
452 "private_key",
453 "secret_key",
454 "signing_key",
455 "beacon_key",
456 "sk",
457 "xprv",
458 "wif",
459 "seed",
460 "mnemonic",
461 ] {
462 let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
463 let kv = line(&format!("{name}={secret}"));
464 assert!(kv.contains("[REDACTED:key]"), "kv {name}: {kv}");
465 assert!(!kv.contains(secret), "kv {name} leaked: {kv}");
466 assert!(kv.contains(name), "kv {name} name dropped: {kv}");
467
468 let json = line(&format!(r#"{{"{name}":"deadbeefdeadbeef01234567"}}"#));
469 assert!(json.contains("[REDACTED:key]"), "json {name}: {json}");
470 assert!(
471 !json.contains("deadbeefdeadbeef01234567"),
472 "json {name}: {json}"
473 );
474 }
475 }
476
477 /// The DIG identity / beacon key logged as bare prose, not a kv pair.
478 #[test]
479 fn redacts_bare_signing_key_phrase() {
480 let got = line("loaded signing key 5f3a9c1b7e2d4088aa11bb22cc33dd44");
481 assert!(got.contains("signing key [REDACTED:key]"), "{got}");
482 assert!(!got.contains("5f3a9c1b7e2d4088"), "{got}");
483 }
484
485 /// A numbered `1. word 2. word …` seed layout is one redactable run.
486 #[test]
487 fn redacts_numbered_mnemonic() {
488 let numbered = "1. abandon 2. ability 3. able 4. about 5. above 6. absent \
489 7. absorb 8. abstract 9. absurd 10. abuse 11. access 12. accident";
490 let got = line(numbered);
491 assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
492 assert!(
493 !got.contains("abandon") && !got.contains("accident"),
494 "{got}"
495 );
496 }
497
498 /// The KEEP guard: public high-entropy ids must NEVER be scrubbed even though a `_key` suffix or
499 /// 32-byte hex would otherwise look secret — a false-scrub here breaks debugging (SPEC §8.2).
500 #[test]
501 fn keeps_public_ids_and_safe_named_fields() {
502 let ids = concat!(
503 "store_id=7d8f0a1b2c3d4e5f60718293a4b5c6d7 ",
504 "root_hash=aabbccddeeff00112233445566778899 ",
505 "coin_id=1122334455667788990011223344556677 ",
506 "puzzle_hash=ec7c30deadbeefcafe0011223344556677 ",
507 "resource_key=cafebabecafebabecafebabecafebabe ",
508 "public_key=abc123def456abc123def456abc123 ",
509 "peer=203.0.113.7 port=9257 generation=42"
510 );
511 let got = line(ids);
512 assert!(
513 !got.contains("[REDACTED"),
514 "public ids over-scrubbed: {got}"
515 );
516 for kept in [
517 "7d8f0a1b2c3d4e5f60718293a4b5c6d7",
518 "aabbccddeeff00112233445566778899",
519 "ec7c30deadbeefcafe0011223344556677",
520 "cafebabecafebabecafebabecafebabe",
521 "203.0.113.7",
522 "9257",
523 ] {
524 assert!(got.contains(kept), "{kept} must be kept: {got}");
525 }
526 }
527
528 /// A token/auth value keeps its precise `[REDACTED:token]`/`:auth` kind — the generic key rule
529 /// must not re-label an already-redacted value.
530 #[test]
531 fn key_rule_does_not_relabel_prior_redaction() {
532 let got = line(r#"{"api_key":"sekret","secret":"other"}"#);
533 assert!(got.contains("[REDACTED:token]"), "{got}");
534 assert!(!got.contains("[REDACTED:token][REDACTED"), "{got}");
535 assert!(!got.contains("sekret") && !got.contains("other"), "{got}");
536 }
537
538 /// REGRESSION TEST: Basic auth credentials (standard base64) must be fully redacted, including
539 /// the `+`, `/`, `=` chars that distinguish standard base64 from base64url. The prior regex
540 /// excluded these chars and leaked the tail of the base64 string.
541 #[test]
542 fn redacts_basic_auth_with_standard_base64() {
543 let basic_b64 = "dXNlcjpwYXNz+w=="; // standard base64 with `+` and `=`
544 let got = line(&format!("Authorization: Basic {basic_b64}"));
545 assert!(
546 got.contains("[REDACTED:auth]"),
547 "Basic auth not redacted: {got}"
548 );
549 assert!(
550 !got.contains(basic_b64),
551 "Basic auth credential leaked: {got}"
552 );
553 assert!(
554 !got.contains("+w=="),
555 "Basic auth tail (+/= chars) leaked: {got}"
556 );
557 }
558
559 /// REGRESSION TEST: Bearer tokens with standard base64 chars (`+`, `/`, `=`) must be fully
560 /// redacted. The prior regex excluded these chars, leaking the tail.
561 #[test]
562 fn redacts_bearer_with_standard_base64() {
563 let bearer_b64 = "abc+def/ghi=="; // standard base64 with `+`, `/`, `=`
564 let got = line(&format!("Authorization: Bearer {bearer_b64}"));
565 assert!(
566 got.contains("[REDACTED:auth]"),
567 "Bearer auth not redacted: {got}"
568 );
569 assert!(!got.contains(bearer_b64), "Bearer credential leaked: {got}");
570 assert!(
571 !got.contains("+def/ghi=="),
572 "Bearer tail (+/= chars) leaked: {got}"
573 );
574 }
575
576 /// REGRESSION TEST: Bare Authorization values (non-Bearer schemes) with standard base64 must be
577 /// fully redacted.
578 #[test]
579 fn redacts_bare_authorization_with_standard_base64() {
580 let bare_b64 = "dXNlcjpwYXNz+w==";
581 let got = line(&format!("Authorization: {bare_b64}"));
582 assert!(
583 got.contains("[REDACTED:auth]"),
584 "Bare auth not redacted: {got}"
585 );
586 assert!(
587 !got.contains(bare_b64),
588 "Bare auth credential leaked: {got}"
589 );
590 assert!(!got.contains("+w=="), "Bare auth tail leaked: {got}");
591 }
592 /// REGRESSION TEST: Standalone Bearer tokens (outside Authorization header) with standard
593 /// base64 must be fully redacted, including +/= chars.
594 #[test]
595 fn redacts_standalone_bearer_with_standard_base64() {
596 // Standalone Bearer without Authorization: prefix
597 let standalone_bearer = "Bearer abc+def/ghi==";
598 let got = line(standalone_bearer);
599 assert!(
600 got.contains("[REDACTED:auth]"),
601 "Standalone Bearer not redacted: {got}"
602 );
603 assert!(
604 !got.contains("abc+def/ghi=="),
605 "Standalone Bearer credential leaked: {got}"
606 );
607 assert!(
608 !got.contains("+def/ghi=="),
609 "Standalone Bearer tail (+/= chars) leaked: {got}"
610 );
611 }
612
613 // --- v5: defense-in-depth residuals (#714). Each asserts the SECRET VALUE is ABSENT. ---
614
615 /// GAP 1: the generic `key`/`keystore` field names — missed by the `_key` suffix rule — leak a
616 /// secret-shaped value. Now scrubbed (in kv AND JSON, for both names) when the VALUE looks secret.
617 #[test]
618 fn gap1_redacts_bare_key_and_keystore_with_secret_value() {
619 let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
620 for name in ["key", "keystore", "KEY", "Keystore"] {
621 let kv = line(&format!("{name}={secret}"));
622 assert!(!kv.contains(secret), "kv {name} leaked the secret: {kv}");
623 assert!(kv.contains("[REDACTED:key]"), "kv {name}: {kv}");
624
625 let json = line(&format!(r#"{{"{name}":"{secret}"}}"#));
626 assert!(
627 !json.contains(secret),
628 "json {name} leaked the secret: {json}"
629 );
630 assert!(json.contains("[REDACTED:key]"), "json {name}: {json}");
631 }
632 }
633
634 /// GAP 1 (over-scrub guard): a bare `key` with a short, obviously-non-secret value (a map-key
635 /// debug line) is KEPT — the value shape gates the scrub, so `key=user_id` survives.
636 #[test]
637 fn gap1_keeps_bare_key_with_nonsecret_short_value() {
638 for benign in ["key=user_id", "key=42", "keystore=default", "key=name"] {
639 let got = line(benign);
640 assert!(
641 !got.contains("[REDACTED"),
642 "benign `{benign}` over-scrubbed: {got}"
643 );
644 }
645 }
646
647 /// REGRESSION TEST (issue #714): a bare `key` with a base64url-encoded secret (containing `-`
648 /// and `_`) must be redacted. The prior VALUE_SECRET_SHAPE regex only matched standard base64
649 /// (+/), leaking base64url secrets with `-` or `_` characters.
650 #[test]
651 fn gap1_redacts_bare_key_with_base64url_value() {
652 // A 44-char base64url-encoded secret with - and _ (which wouldn't match the old regex)
653 let secret = "ZcjI14QiJ1Qety2clr-oDEkJyehiSBRoiYylEfi_JI";
654 let kv = line(&format!("key={secret}"));
655 assert!(!kv.contains(secret), "kv base64url secret leaked: {kv}");
656 assert!(kv.contains("[REDACTED:key]"), "kv not redacted: {kv}");
657
658 let json = line(&format!(r#"{{"key":"{secret}"}}"#));
659 assert!(
660 !json.contains(secret),
661 "json base64url secret leaked: {json}"
662 );
663 assert!(json.contains("[REDACTED:key]"), "json not redacted: {json}");
664 }
665
666 /// GAP 2: prose `<kind> key <hex>` for the extended kinds (`identity`/`node`/`master`/`ed25519`/
667 /// `bls`/`api`) leaked before — no kv separator, and KEY_PHRASE didn't list these kinds.
668 #[test]
669 fn gap2_redacts_extended_key_phrases() {
670 for kind in ["identity", "node", "master", "ed25519", "bls", "api"] {
671 let secret = "5f3a9c1b7e2d4088aa11bb22cc33dd44";
672 let got = line(&format!("loaded {kind} key {secret}"));
673 assert!(!got.contains(secret), "{kind} key leaked: {got}");
674 assert!(
675 got.contains(&format!("{kind} key [REDACTED:key]")),
676 "{kind}: {got}"
677 );
678 }
679 }
680
681 /// REGRESSION TEST (issue #714): bare key phrase `<kind> key <base64url>` with - and _
682 /// characters must be fully redacted (no tail leak). The prior KEY_PHRASE regex only matched
683 /// standard base64, leaking the tail after the first - or _ character.
684 #[test]
685 fn gap2_redacts_base64url_key_phrase_full_tail() {
686 // A 44-char base64url-encoded secret containing both - and _
687 let secret = "ABCDEFGHIJKLMNOPqrstuvwx-yz012345_6789ABCD";
688 let tail_after_dash = "yz012345_6789ABCD";
689
690 let got = line(&format!("loaded identity key {secret}"));
691 assert!(
692 !got.contains(secret),
693 "identity key base64url secret leaked: {got}"
694 );
695 assert!(
696 !got.contains(tail_after_dash),
697 "identity key tail-leak (after dash): {got}"
698 );
699 assert!(
700 got.contains("identity key [REDACTED:key]"),
701 "identity key not redacted: {got}"
702 );
703 }
704
705 /// GAP 3: positional / Debug-tuple shapes with NO `:`/`=` separator leaked before — no rule
706 /// matched `PrivKey(0x…)`, `Seed([…])`, `Mnemonic("…")`. Now caught by the type-name detector.
707 #[test]
708 fn gap3_redacts_positional_secret_debug_shapes() {
709 let cases = [
710 (
711 "PrivKey(0xabc123def456abc123def456abc1)",
712 "abc123def456abc123def456abc1",
713 ),
714 ("Seed([222, 173, 190, 239, 1, 2, 3, 4])", "222, 173, 190"),
715 (
716 r#"Mnemonic("abandon ability able about")"#,
717 "abandon ability",
718 ),
719 (
720 "SigningKey(deadbeefdeadbeefdeadbeef)",
721 "deadbeefdeadbeefdeadbeef",
722 ),
723 ("Xprv(xprv9sdeadbeefcafe0011)", "xprv9sdeadbeefcafe0011"),
724 ];
725 for (input, secret) in cases {
726 let got = line(input);
727 assert!(!got.contains(secret), "positional secret leaked: {got}");
728 assert!(got.contains("[REDACTED:key]"), "not redacted: {got}");
729 }
730 // A benign wrapper of the SAME shape must NOT be scrubbed (keyed on secret type names only).
731 let benign = line("Coin([222, 173]) Peer(203.0.113.7)");
732 assert!(
733 !benign.contains("[REDACTED"),
734 "benign wrapper over-scrubbed: {benign}"
735 );
736 }
737
738 /// GAP 4: names that merely CONTAIN `priv` but are not private keys (`privacy`, `private-beta`)
739 /// were over-scrubbed by the old bare-substring rule. They are now KEPT.
740 #[test]
741 fn gap4_keeps_privacy_and_private_beta_field_names() {
742 for kept in ["privacy=enabled", "private_beta=true", "privatebeta=on"] {
743 let got = line(kept);
744 assert!(!got.contains("[REDACTED"), "`{kept}` over-scrubbed: {got}");
745 }
746 // ...but genuine private-key markers WITHOUT a `_key` suffix are still caught.
747 let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
748 for name in ["priv", "privkey", "xpriv"] {
749 let got = line(&format!("{name}={secret}"));
750 assert!(!got.contains(secret), "{name} leaked: {got}");
751 assert!(got.contains("[REDACTED:key]"), "{name}: {got}");
752 }
753 }
754
755 // --- v6: SECRET_DEBUG_TUPLE was a NON-EXHAUSTIVE fixed list (#723). Each asserts the VALUE
756 // is ABSENT (not merely that a marker appeared), and benign look-alikes are KEPT. ---
757
758 /// REGRESSION (#723): positional secret Debug shapes the OLD fixed alternation MISSED — a
759 /// PREFIXED private-key type (`ExtendedPrivKey`), a `*Secret` type (`MasterSecret`), and the
760 /// short `*Sk` abbreviation (`BlsSk`) — must have their enclosed material redacted.
761 #[test]
762 fn redacts_prefixed_and_aliased_secret_debug_shapes() {
763 let cases = [
764 (
765 "ExtendedPrivKey(xprv9sdeadbeefcafe0011223344)",
766 "xprv9sdeadbeefcafe0011223344",
767 ),
768 (
769 "MasterSecret(deadbeefdeadbeefdeadbeef01)",
770 "deadbeefdeadbeefdeadbeef01",
771 ),
772 (
773 "BlsSk(5f3a9c1b7e2d4088aa11bb22)",
774 "5f3a9c1b7e2d4088aa11bb22",
775 ),
776 ("Ed25519Sk([222, 173, 190, 239])", "222, 173, 190"),
777 (
778 r#"NodeSecretKey("abandonabilityable")"#,
779 "abandonabilityable",
780 ),
781 ("KeyPair(0xcafebabecafebabecafe)", "cafebabecafebabecafe"),
782 ];
783 for (input, secret) in cases {
784 let got = line(input);
785 assert!(!got.contains(secret), "positional secret leaked: {got}");
786 assert!(got.contains("[REDACTED:key]"), "not redacted: {got}");
787 }
788 }
789
790 /// REGRESSION (#723 over-scrub guard): benign Debug shapes whose type names merely END in
791 /// lowercase `sk` (`Task`/`Disk`/`Mask`/`Ask`) or are unrelated (`Coin`/`Peer`) must be KEPT —
792 /// the `Sk` suffix is matched CASE-SENSITIVELY so common words are not false-scrubbed.
793 #[test]
794 fn keeps_benign_debug_shapes_ending_in_lowercase_sk() {
795 for benign in [
796 "Task([1, 2, 3])",
797 "Disk(203.0.113.7)",
798 "Mask(255)",
799 "Ask(bid, offer)",
800 "Coin([222, 173])",
801 "Peer(203.0.113.7)",
802 ] {
803 let got = line(benign);
804 assert!(
805 !got.contains("[REDACTED"),
806 "benign shape `{benign}` over-scrubbed: {got}"
807 );
808 }
809 }
810
811 // --- v7: the positional detector missed the named-field BRACE Debug shape (#730). A secret-
812 // marker STRUCT type with a NON-sensitive field name (`SigningKey { scalar: <hex> }`) evaded
813 // both SECRET_DEBUG_TUPLE (brackets only) and SENSITIVE_KV (field name not marked). Each asserts
814 // the enclosed VALUE is ABSENT; benign brace structs stay readable. ---
815
816 /// REGRESSION (#730): a secret-marker CamelCase struct printed in `{:?}` brace form, whose FIELD
817 /// name is NOT itself sensitive (`scalar`, `inner`, `bytes`), leaked the raw material — neither
818 /// the bracket-only tuple detector nor the field-name kv rule caught it. The brace body is now
819 /// redacted wholesale. Asserts the VALUE is ABSENT (a marker-only assert is insufficient, §2.2).
820 #[test]
821 fn redacts_named_field_brace_secret_debug_shapes() {
822 let secret64 = "5f3a9c1b7e2d4088aa11bb22cc33dd445f3a9c1b7e2d4088aa11bb22cc33dd44";
823 let cases = [
824 // A marker-suffixed type with a NON-sensitive field name — the exact #730 leak shape.
825 (
826 format!("SigningKey {{ scalar: {secret64} }}"),
827 secret64.to_string(),
828 ),
829 (
830 "SecretKey { inner: deadbeefdeadbeefdeadbeef }".to_string(),
831 "deadbeefdeadbeefdeadbeef".to_string(),
832 ),
833 (
834 "MasterSecret { bytes: [222, 173, 190, 239] }".to_string(),
835 "222, 173, 190".to_string(),
836 ),
837 (
838 "KeyPair { a: 0xcafebabecafe, b: 0xdeadbeef }".to_string(),
839 "cafebabecafe".to_string(),
840 ),
841 (
842 "Ed25519Sk { d: 5f3a9c1b7e2d4088 }".to_string(),
843 "5f3a9c1b7e2d4088".to_string(),
844 ),
845 ];
846 for (input, secret) in cases {
847 let got = line(&input);
848 assert!(!got.contains(&secret), "brace secret leaked: {got}");
849 assert!(got.contains("[REDACTED:key]"), "not redacted: {got}");
850 }
851 }
852
853 /// REGRESSION (#730 over-scrub guard): benign brace structs whose TYPE carries no secret marker
854 /// (`PublicKey`, `VerifyingKey`, `Coin`, `Peer`) — even with a `Key`-suffixed type or a
855 /// high-entropy field value — stay fully readable; the brace detector is TYPE-marker-driven.
856 #[test]
857 fn keeps_benign_brace_debug_shapes() {
858 for benign in [
859 "PublicKey { bytes: cafebabecafebabecafebabecafebabe }",
860 "VerifyingKey { point: abc123def456abc123def456 }",
861 "Coin { amount: 1000000000, puzzle_hash: ec7c30deadbeef }",
862 "Peer { addr: 203.0.113.7, port: 9257 }",
863 ] {
864 let got = line(benign);
865 assert!(
866 !got.contains("[REDACTED"),
867 "benign brace `{benign}` over-scrubbed: {got}"
868 );
869 }
870 }
871
872 /// KEEP guard (residuals edition): the SAFE_KEY_NAMES allowlist must survive the v5 changes —
873 /// `storeId`, `rootHash`, `coinId`, `public_key`, and `resource_key` values are NEVER scrubbed.
874 #[test]
875 fn keeps_safe_named_public_ids_after_v5() {
876 let secret_shaped = "cafebabecafebabecafebabecafebabecafebabe"; // looks high-entropy, but public
877 for name in [
878 "storeId",
879 "store_id",
880 "rootHash",
881 "root_hash",
882 "coinId",
883 "coin_id",
884 "public_key",
885 "resource_key",
886 ] {
887 let got = line(&format!("{name}={secret_shaped}"));
888 assert!(
889 !got.contains("[REDACTED"),
890 "safe id `{name}` over-scrubbed: {got}"
891 );
892 assert!(
893 got.contains(secret_shaped),
894 "safe id `{name}` value dropped: {got}"
895 );
896 }
897 }
898}