Skip to main content

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: &regex::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}