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