1use std::collections::HashSet;
23
24use once_cell::sync::Lazy;
25use regex::Regex;
26
27pub const RULES_VERSION: u32 = 5;
38
39const MIN_MNEMONIC_RUN: usize = 12;
41
42static BIP39_WORDS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
44 bip39::Language::English
45 .word_list()
46 .iter()
47 .copied()
48 .collect()
49});
50
51static WORD: Lazy<Regex> = Lazy::new(|| Regex::new(r"[A-Za-z]{3,8}").unwrap());
53
54static 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
63static AUTH_HEADER: Lazy<Regex> = Lazy::new(|| {
69 Regex::new(r#"(?i)(authorization"?\s*[:=]\s*"?)((?:[A-Za-z]+\s+)?[^"\s,}]+)"#).unwrap()
70});
71
72static BEARER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)\bbearer\s+([^"\s,}]+)"#).unwrap());
74
75static 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
83static 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
117const 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
133static SENSITIVE_KV: Lazy<Regex> = Lazy::new(|| {
139 Regex::new(r#"(?i)("?)([A-Za-z][A-Za-z0-9_]*)("?\s*[:=]\s*"?)([^"\s,}]+)"#).unwrap()
140});
141
142static 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
153const CONDITIONAL_SENSITIVE: &[&str] = &["key", "keystore"];
158
159static 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
170static 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
182fn 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
199fn 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
210static BECH32: Lazy<Regex> =
212 Lazy::new(|| Regex::new(r"\b(t?xch1)([0-9a-z]{8})[0-9a-z]{4,}\b").unwrap());
213
214static 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
219pub fn line(input: &str) -> String {
221 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 let stage = SECRET_DEBUG_TUPLE
238 .replace_all(&stage, "${1}${2}[REDACTED:key]${3}")
239 .into_owned();
240 let stage = SENSITIVE_KV
244 .replace_all(&stage, |caps: ®ex::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
262pub fn text(input: &str) -> String {
264 input.lines().map(line).collect::<Vec<_>>().join("\n")
265}
266
267fn 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; let mut i = 0;
283 while i < words.len() {
284 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 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; assert!(!line(eleven).contains("[REDACTED:mnemonic]"));
334 }
335
336 #[test]
337 fn redacts_pem_and_tokens() {
338 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
498 fn redacts_basic_auth_with_standard_base64() {
499 let basic_b64 = "dXNlcjpwYXNz+w=="; 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 #[test]
518 fn redacts_bearer_with_standard_base64() {
519 let bearer_b64 = "abc+def/ghi=="; 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 #[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 #[test]
551 fn redacts_standalone_bearer_with_standard_base64() {
552 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 #[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 #[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 #[test]
607 fn gap1_redacts_bare_key_with_base64url_value() {
608 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 #[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 #[test]
641 fn gap2_redacts_base64url_key_phrase_full_tail() {
642 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 #[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 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 #[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 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 #[test]
714 fn keeps_safe_named_public_ids_after_v5() {
715 let secret_shaped = "cafebabecafebabecafebabecafebabecafebabe"; 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}