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