1use std::collections::HashSet;
23
24use once_cell::sync::Lazy;
25use regex::Regex;
26
27pub const RULES_VERSION: u32 = 4;
33
34const MIN_MNEMONIC_RUN: usize = 12;
36
37static BIP39_WORDS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
39 bip39::Language::English
40 .word_list()
41 .iter()
42 .copied()
43 .collect()
44});
45
46static WORD: Lazy<Regex> = Lazy::new(|| Regex::new(r"[A-Za-z]{3,8}").unwrap());
48
49static 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
58static AUTH_HEADER: Lazy<Regex> = Lazy::new(|| {
64 Regex::new(r#"(?i)(authorization"?\s*[:=]\s*"?)((?:[A-Za-z]+\s+)?[^"\s,}]+)"#).unwrap()
65});
66
67static BEARER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)\bbearer\s+([^"\s,}]+)"#).unwrap());
69
70static 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
78static 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
112const 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
128static SENSITIVE_KV: Lazy<Regex> = Lazy::new(|| {
134 Regex::new(r#"(?i)("?)([A-Za-z][A-Za-z0-9_]*)("?\s*[:=]\s*"?)([^"\s,}]+)"#).unwrap()
135});
136
137static 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
144fn 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
159static BECH32: Lazy<Regex> =
161 Lazy::new(|| Regex::new(r"\b(t?xch1)([0-9a-z]{8})[0-9a-z]{4,}\b").unwrap());
162
163static 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
168pub fn line(input: &str) -> String {
170 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 let stage = SENSITIVE_KV
188 .replace_all(&stage, |caps: ®ex::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
202pub fn text(input: &str) -> String {
204 input.lines().map(line).collect::<Vec<_>>().join("\n")
205}
206
207fn 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; let mut i = 0;
223 while i < words.len() {
224 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 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; assert!(!line(eleven).contains("[REDACTED:mnemonic]"));
274 }
275
276 #[test]
277 fn redacts_pem_and_tokens() {
278 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
438 fn redacts_basic_auth_with_standard_base64() {
439 let basic_b64 = "dXNlcjpwYXNz+w=="; 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 #[test]
458 fn redacts_bearer_with_standard_base64() {
459 let bearer_b64 = "abc+def/ghi=="; 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 #[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 #[test]
491 fn redacts_standalone_bearer_with_standard_base64() {
492 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}