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