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).
32pub const RULES_VERSION: u32 = 4;
33
34/// The minimum consecutive BIP39 words that constitute a redactable mnemonic run (SPEC §8.2).
35const MIN_MNEMONIC_RUN: usize = 12;
36
37/// The authoritative English BIP39 wordlist as a fast lookup set (reused from the `bip39` crate).
38static BIP39_WORDS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
39    bip39::Language::English
40        .word_list()
41        .iter()
42        .copied()
43        .collect()
44});
45
46/// A BIP39 word token (3–8 lowercase letters) and its position in the input.
47static WORD: Lazy<Regex> = Lazy::new(|| Regex::new(r"[A-Za-z]{3,8}").unwrap());
48
49/// Chars allowed BETWEEN two mnemonic words: whitespace, and the punctuation/escapes a serialized
50/// seed can carry (`\n` escape, quotes, commas, colons, `#`, `-`, and the digits/`.`/`)` of a
51/// NUMBERED `1. abandon 2. ability …` layout). A gap of only these keeps a run contiguous, so a
52/// `\n`-joined, comment-embedded, or numbered seed is still caught as one run.
53static MNEMONIC_GAP: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^[\s\\n"',:#.)(0-9-]*$"#).unwrap());
54
55static PEM_BLOCK: Lazy<Regex> =
56    Lazy::new(|| Regex::new(r"(?s)-----BEGIN[^-]*-----.*?-----END[^-]*-----").unwrap());
57
58/// `Authorization: <v>` / `"authorization":"<v>"` — keep the key, redact the value.
59/// Handles `Authorization: <scheme> <token>` (e.g. Bearer, Basic, etc) and bare `Authorization: <opaque>`
60/// forms, consuming the optional scheme + full credential value together so all base64 chars (+/=//) are
61/// included. The value class `[^"\s,}]+` stops at quote/space/comma/brace to correctly bound header values
62/// in both plain-text and JSON-embedded logs.
63static AUTH_HEADER: Lazy<Regex> = Lazy::new(|| {
64    Regex::new(r#"(?i)(authorization"?\s*[:=]\s*"?)((?:[A-Za-z]+\s+)?[^"\s,}]+)"#).unwrap()
65});
66
67/// `Bearer <token>` anywhere - widen to capture full standard-base64 tokens (+ / =).
68static BEARER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)\bbearer\s+([^"\s,}]+)"#).unwrap());
69
70/// `token`/`api_key`/`secret`/`password`/`passphrase`/`pairing_code` = / : `<v>` (JSON or kv).
71static TOKEN_KV: Lazy<Regex> = Lazy::new(|| {
72    Regex::new(
73        r#"(?i)("?(?:token|api[_-]?key|apikey|secret|password|passphrase|pairing[_-]?code)"?\s*[:=]\s*"?)([^"\s,}]+)"#,
74    )
75    .unwrap()
76});
77
78/// Field names that are HIGH-ENTROPY but PUBLIC and load-bearing for debugging, so their values are
79/// KEPT even though the name may end `_key` (SPEC §8.2 KEEP list). When in doubt a name is treated as
80/// sensitive (a missed key leaks custody; a false-scrub of one of these merely hampers debugging), so
81/// this list is the explicit allow-list that overrides the `_key`/`_secret` suffix rule.
82static SAFE_KEY_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
83    [
84        "store_id",
85        "storeid",
86        "store",
87        "root",
88        "root_hash",
89        "roothash",
90        "coin_id",
91        "coinid",
92        "coin",
93        "puzzle_hash",
94        "owner_puzzle_hash",
95        "peer",
96        "peer_id",
97        "addr",
98        "address",
99        "ip",
100        "generation",
101        "capsule",
102        "resource_key",
103        "port",
104        "public_key",
105        "pubkey",
106        "verifying_key",
107    ]
108    .into_iter()
109    .collect()
110});
111
112/// Field names whose VALUE is always a secret regardless of suffix (kv or JSON). The `_key`/`_secret`
113/// suffix and `seed`/`mnemonic`/`priv` substrings extend this in [`is_sensitive_key`].
114const SENSITIVE_EXACT: &[&str] = &[
115    "sk",
116    "xprv",
117    "wif",
118    "seed",
119    "mnemonic",
120    "private_key",
121    "secret_key",
122    "signing_key",
123    "beacon_key",
124    "privkey",
125    "secretkey",
126];
127
128/// Any `name = value` / `name: value` / JSON `"name":"value"` pair — the value is redacted ONLY when
129/// the NAME marks it secret ([`is_sensitive_key`]); every other pair is left untouched. This is the
130/// field-name-driven key rule that catches `private_key`/`signing_key`/`sk`/`xprv`/`wif`/`seed`/
131/// `beacon_key`/`*_key`/`*_secret` (incl. raw base64/hex values) WITHOUT blanket-scrubbing public
132/// high-entropy ids. Group 1 = optional open quote, 2 = name, 3 = separator, 4 = value.
133static SENSITIVE_KV: Lazy<Regex> = Lazy::new(|| {
134    Regex::new(r#"(?i)("?)([A-Za-z][A-Za-z0-9_]*)("?\s*[:=]\s*"?)([^"\s,}]+)"#).unwrap()
135});
136
137/// A bare prose reference `<kind> key <hex-or-base64>` (e.g. `loaded signing key <hex>`), which no
138/// kv rule would catch. Group 1 = the `<kind> key` phrase (kept), group 2 = the secret material.
139static KEY_PHRASE: Lazy<Regex> = Lazy::new(|| {
140    Regex::new(r"(?i)\b((?:signing|private|secret|beacon)\s+key)\s+([A-Za-z0-9+/]{16,}={0,2})")
141        .unwrap()
142});
143
144/// Is a field NAME one whose value must be redacted? Safe public ids ([`SAFE_KEY_NAMES`]) win first;
145/// then exact sensitive names, the `_key`/`_secret` suffix, and `seed`/`mnemonic`/`priv` substrings.
146fn is_sensitive_key(name: &str) -> bool {
147    let name = name.to_ascii_lowercase();
148    if SAFE_KEY_NAMES.contains(name.as_str()) {
149        return false;
150    }
151    SENSITIVE_EXACT.contains(&name.as_str())
152        || name.ends_with("_key")
153        || name.ends_with("_secret")
154        || name.contains("seed")
155        || name.contains("mnemonic")
156        || name.contains("priv")
157}
158
159/// A bech32 `xch1…`/`txch1…` address — truncate to the HRP + first 8 payload chars.
160static BECH32: Lazy<Regex> =
161    Lazy::new(|| Regex::new(r"\b(t?xch1)([0-9a-z]{8})[0-9a-z]{4,}\b").unwrap());
162
163/// Home-dir usernames in Windows / Linux / macOS paths.
164static WIN_USER: Lazy<Regex> =
165    Lazy::new(|| Regex::new(r#"(?i)([A-Za-z]:\\Users\\)([^\\\s"]+)"#).unwrap());
166static NIX_USER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(/home/|/Users/)([^/\s"]+)"#).unwrap());
167
168/// Redact one line of log text (SPEC §8.2). Idempotent enough for repeated application.
169pub fn line(input: &str) -> String {
170    // Mnemonic runs first, on the original words, before other rules perturb the text.
171    let stage = redact_mnemonics(input);
172    let stage = PEM_BLOCK.replace_all(&stage, "[REDACTED:pem]").into_owned();
173    let stage = AUTH_HEADER
174        .replace_all(&stage, "${1}[REDACTED:auth]")
175        .into_owned();
176    let stage = BEARER
177        .replace_all(&stage, "Bearer [REDACTED:auth]")
178        .into_owned();
179    let stage = TOKEN_KV
180        .replace_all(&stage, "${1}[REDACTED:token]")
181        .into_owned();
182    let stage = KEY_PHRASE
183        .replace_all(&stage, "${1} [REDACTED:key]")
184        .into_owned();
185    // Field-name-driven key redaction: redact a value ONLY when its NAME is secret, and never
186    // re-touch an already-redacted value (so a prior token/auth rule keeps its `:token`/`:auth` kind).
187    let stage = SENSITIVE_KV
188        .replace_all(&stage, |caps: &regex::Captures| {
189            let value = &caps[4];
190            if is_sensitive_key(&caps[2]) && !value.starts_with("[REDACTED") {
191                format!("{}{}{}[REDACTED:key]", &caps[1], &caps[2], &caps[3])
192            } else {
193                caps[0].to_string()
194            }
195        })
196        .into_owned();
197    let stage = BECH32.replace_all(&stage, "${1}${2}…").into_owned();
198    let stage = WIN_USER.replace_all(&stage, r"${1}<user>").into_owned();
199    NIX_USER.replace_all(&stage, "${1}<user>").into_owned()
200}
201
202/// Redact every line of a multi-line string.
203pub fn text(input: &str) -> String {
204    input.lines().map(line).collect::<Vec<_>>().join("\n")
205}
206
207/// Find and replace maximal runs of ≥[`MIN_MNEMONIC_RUN`] consecutive BIP39 words (SPEC §8.2).
208fn redact_mnemonics(input: &str) -> String {
209    let words: Vec<_> = WORD
210        .find_iter(input)
211        .map(|m| {
212            (
213                m.start(),
214                m.end(),
215                BIP39_WORDS.contains(m.as_str().to_ascii_lowercase().as_str()),
216            )
217        })
218        .collect();
219
220    let mut out = String::new();
221    let mut cursor = 0; // byte index copied up to
222    let mut i = 0;
223    while i < words.len() {
224        // Extend a run of wordlist words whose gaps contain only separator chars.
225        let start = i;
226        let mut end = i;
227        while end + 1 < words.len()
228            && words[end].2
229            && words[end + 1].2
230            && MNEMONIC_GAP.is_match(&input[words[end].1..words[end + 1].0])
231        {
232            end += 1;
233        }
234        let run_len = if words[start].2 { end - start + 1 } else { 0 };
235        if run_len >= MIN_MNEMONIC_RUN {
236            out.push_str(&input[cursor..words[start].0]);
237            out.push_str("[REDACTED:mnemonic]");
238            cursor = words[end].1;
239            i = end + 1;
240        } else {
241            i += 1;
242        }
243    }
244    out.push_str(&input[cursor..]);
245    out
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    const SEED12: &str =
253        "abandon ability able about above absent absorb abstract absurd abuse access accident";
254
255    #[test]
256    fn redacts_key_value_mnemonic() {
257        let got = line(&format!("mnemonic={SEED12}"));
258        assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
259        assert!(!got.contains("abandon"));
260    }
261
262    #[test]
263    fn redacts_comment_style_mnemonic() {
264        // The `.test-credentials` leak shape: a seed on a `#` comment line, not key=value.
265        let got = line(&format!("# Mnemonic: {SEED12}"));
266        assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
267        assert!(!got.contains("abstract"));
268    }
269
270    #[test]
271    fn eleven_words_not_redacted() {
272        let eleven = SEED12.rsplit_once(' ').unwrap().0; // drop the 12th word
273        assert!(!line(eleven).contains("[REDACTED:mnemonic]"));
274    }
275
276    #[test]
277    fn redacts_pem_and_tokens() {
278        // PEM block redaction
279        let pem_out = line("key: -----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----");
280        assert!(pem_out.contains("[REDACTED:pem]"), "pem: {pem_out}");
281        assert!(!pem_out.contains("BEGIN"), "pem key leaked: {pem_out}");
282        assert!(!pem_out.contains("MIIB"), "pem key leaked: {pem_out}");
283
284        // JSON token field redaction
285        let token_out = line(r#"{"token":"abc123secret"}"#);
286        assert!(token_out.contains("[REDACTED:token]"), "token: {token_out}");
287        assert!(
288            !token_out.contains("abc123secret"),
289            "token leaked: {token_out}"
290        );
291
292        // Authorization header with Bearer token (the main security leak case)
293        let auth_out = line("Authorization: Bearer zzz.yyy.xxx");
294        assert!(auth_out.contains("[REDACTED:auth]"), "auth: {auth_out}");
295        assert!(
296            !auth_out.contains("zzz.yyy.xxx"),
297            "Bearer token leaked: {auth_out}"
298        );
299        assert!(
300            !auth_out.contains("Bearer zzz"),
301            "Bearer token leaked: {auth_out}"
302        );
303
304        // Bare Authorization header (non-Bearer form)
305        let opaque_out = line("Authorization: opaque_token_abc123");
306        assert!(
307            opaque_out.contains("[REDACTED:auth]"),
308            "opaque: {opaque_out}"
309        );
310        assert!(
311            !opaque_out.contains("opaque_token_abc123"),
312            "opaque token leaked: {opaque_out}"
313        );
314
315        // Standalone Bearer without Authorization prefix
316        let bearer_out = line("Bearer eyJ.payload.sig");
317        assert!(
318            bearer_out.contains("[REDACTED:auth]"),
319            "bearer: {bearer_out}"
320        );
321        assert!(
322            !bearer_out.contains("eyJ.payload.sig"),
323            "Bearer credential leaked: {bearer_out}"
324        );
325    }
326
327    #[test]
328    fn truncates_bech32_but_keeps_public_ids() {
329        let got = line("addr=xch1qqqqqqqqwwwwwwwweeeeeeee store=abc123def456 peer=203.0.113.7");
330        assert!(got.contains("xch1qqqqqqq…") || got.contains("…"), "{got}");
331        assert!(got.contains("abc123def456"), "store ids are KEPT: {got}");
332        assert!(got.contains("203.0.113.7"), "peer IPs are KEPT: {got}");
333    }
334
335    #[test]
336    fn scrubs_home_dir_username() {
337        assert!(line(r"path=C:\Users\alice\AppData").contains(r"C:\Users\<user>"));
338        assert!(line("path=/home/bob/logs").contains("/home/<user>"));
339    }
340
341    // --- v2: field-name-driven private-key / seed redaction (SECURITY regressions, §2.2) ---
342
343    /// Each named-key field must have its value redacted, in kv AND JSON shapes, while the FIELD
344    /// NAME survives so the log stays diagnosable.
345    #[test]
346    fn redacts_named_key_and_seed_fields() {
347        for name in [
348            "private_key",
349            "secret_key",
350            "signing_key",
351            "beacon_key",
352            "sk",
353            "xprv",
354            "wif",
355            "seed",
356            "mnemonic",
357        ] {
358            let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
359            let kv = line(&format!("{name}={secret}"));
360            assert!(kv.contains("[REDACTED:key]"), "kv {name}: {kv}");
361            assert!(!kv.contains(secret), "kv {name} leaked: {kv}");
362            assert!(kv.contains(name), "kv {name} name dropped: {kv}");
363
364            let json = line(&format!(r#"{{"{name}":"deadbeefdeadbeef01234567"}}"#));
365            assert!(json.contains("[REDACTED:key]"), "json {name}: {json}");
366            assert!(
367                !json.contains("deadbeefdeadbeef01234567"),
368                "json {name}: {json}"
369            );
370        }
371    }
372
373    /// The DIG identity / beacon key logged as bare prose, not a kv pair.
374    #[test]
375    fn redacts_bare_signing_key_phrase() {
376        let got = line("loaded signing key 5f3a9c1b7e2d4088aa11bb22cc33dd44");
377        assert!(got.contains("signing key [REDACTED:key]"), "{got}");
378        assert!(!got.contains("5f3a9c1b7e2d4088"), "{got}");
379    }
380
381    /// A numbered `1. word 2. word …` seed layout is one redactable run.
382    #[test]
383    fn redacts_numbered_mnemonic() {
384        let numbered = "1. abandon 2. ability 3. able 4. about 5. above 6. absent \
385             7. absorb 8. abstract 9. absurd 10. abuse 11. access 12. accident";
386        let got = line(numbered);
387        assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
388        assert!(
389            !got.contains("abandon") && !got.contains("accident"),
390            "{got}"
391        );
392    }
393
394    /// The KEEP guard: public high-entropy ids must NEVER be scrubbed even though a `_key` suffix or
395    /// 32-byte hex would otherwise look secret — a false-scrub here breaks debugging (SPEC §8.2).
396    #[test]
397    fn keeps_public_ids_and_safe_named_fields() {
398        let ids = concat!(
399            "store_id=7d8f0a1b2c3d4e5f60718293a4b5c6d7 ",
400            "root_hash=aabbccddeeff00112233445566778899 ",
401            "coin_id=1122334455667788990011223344556677 ",
402            "puzzle_hash=ec7c30deadbeefcafe0011223344556677 ",
403            "resource_key=cafebabecafebabecafebabecafebabe ",
404            "public_key=abc123def456abc123def456abc123 ",
405            "peer=203.0.113.7 port=9257 generation=42"
406        );
407        let got = line(ids);
408        assert!(
409            !got.contains("[REDACTED"),
410            "public ids over-scrubbed: {got}"
411        );
412        for kept in [
413            "7d8f0a1b2c3d4e5f60718293a4b5c6d7",
414            "aabbccddeeff00112233445566778899",
415            "ec7c30deadbeefcafe0011223344556677",
416            "cafebabecafebabecafebabecafebabe",
417            "203.0.113.7",
418            "9257",
419        ] {
420            assert!(got.contains(kept), "{kept} must be kept: {got}");
421        }
422    }
423
424    /// A token/auth value keeps its precise `[REDACTED:token]`/`:auth` kind — the generic key rule
425    /// must not re-label an already-redacted value.
426    #[test]
427    fn key_rule_does_not_relabel_prior_redaction() {
428        let got = line(r#"{"api_key":"sekret","secret":"other"}"#);
429        assert!(got.contains("[REDACTED:token]"), "{got}");
430        assert!(!got.contains("[REDACTED:token][REDACTED"), "{got}");
431        assert!(!got.contains("sekret") && !got.contains("other"), "{got}");
432    }
433
434    /// REGRESSION TEST: Basic auth credentials (standard base64) must be fully redacted, including
435    /// the `+`, `/`, `=` chars that distinguish standard base64 from base64url. The prior regex
436    /// excluded these chars and leaked the tail of the base64 string.
437    #[test]
438    fn redacts_basic_auth_with_standard_base64() {
439        let basic_b64 = "dXNlcjpwYXNz+w=="; // standard base64 with `+` and `=`
440        let got = line(&format!("Authorization: Basic {basic_b64}"));
441        assert!(
442            got.contains("[REDACTED:auth]"),
443            "Basic auth not redacted: {got}"
444        );
445        assert!(
446            !got.contains(basic_b64),
447            "Basic auth credential leaked: {got}"
448        );
449        assert!(
450            !got.contains("+w=="),
451            "Basic auth tail (+/= chars) leaked: {got}"
452        );
453    }
454
455    /// REGRESSION TEST: Bearer tokens with standard base64 chars (`+`, `/`, `=`) must be fully
456    /// redacted. The prior regex excluded these chars, leaking the tail.
457    #[test]
458    fn redacts_bearer_with_standard_base64() {
459        let bearer_b64 = "abc+def/ghi=="; // standard base64 with `+`, `/`, `=`
460        let got = line(&format!("Authorization: Bearer {bearer_b64}"));
461        assert!(
462            got.contains("[REDACTED:auth]"),
463            "Bearer auth not redacted: {got}"
464        );
465        assert!(!got.contains(bearer_b64), "Bearer credential leaked: {got}");
466        assert!(
467            !got.contains("+def/ghi=="),
468            "Bearer tail (+/= chars) leaked: {got}"
469        );
470    }
471
472    /// REGRESSION TEST: Bare Authorization values (non-Bearer schemes) with standard base64 must be
473    /// fully redacted.
474    #[test]
475    fn redacts_bare_authorization_with_standard_base64() {
476        let bare_b64 = "dXNlcjpwYXNz+w==";
477        let got = line(&format!("Authorization: {bare_b64}"));
478        assert!(
479            got.contains("[REDACTED:auth]"),
480            "Bare auth not redacted: {got}"
481        );
482        assert!(
483            !got.contains(bare_b64),
484            "Bare auth credential leaked: {got}"
485        );
486        assert!(!got.contains("+w=="), "Bare auth tail leaked: {got}");
487    }
488    /// REGRESSION TEST: Standalone Bearer tokens (outside Authorization header) with standard
489    /// base64 must be fully redacted, including +/= chars.
490    #[test]
491    fn redacts_standalone_bearer_with_standard_base64() {
492        // Standalone Bearer without Authorization: prefix
493        let standalone_bearer = "Bearer abc+def/ghi==";
494        let got = line(standalone_bearer);
495        assert!(
496            got.contains("[REDACTED:auth]"),
497            "Standalone Bearer not redacted: {got}"
498        );
499        assert!(
500            !got.contains("abc+def/ghi=="),
501            "Standalone Bearer credential leaked: {got}"
502        );
503        assert!(
504            !got.contains("+def/ghi=="),
505            "Standalone Bearer tail (+/= chars) leaked: {got}"
506        );
507    }
508}