use std::collections::HashSet;
use once_cell::sync::Lazy;
use regex::Regex;
pub const RULES_VERSION: u32 = 4;
const MIN_MNEMONIC_RUN: usize = 12;
static BIP39_WORDS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
bip39::Language::English
.word_list()
.iter()
.copied()
.collect()
});
static WORD: Lazy<Regex> = Lazy::new(|| Regex::new(r"[A-Za-z]{3,8}").unwrap());
static MNEMONIC_GAP: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^[\s\\n"',:#.)(0-9-]*$"#).unwrap());
static PEM_BLOCK: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?s)-----BEGIN[^-]*-----.*?-----END[^-]*-----").unwrap());
static AUTH_HEADER: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?i)(authorization"?\s*[:=]\s*"?)((?:[A-Za-z]+\s+)?[^"\s,}]+)"#).unwrap()
});
static BEARER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)\bbearer\s+([^"\s,}]+)"#).unwrap());
static TOKEN_KV: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?i)("?(?:token|api[_-]?key|apikey|secret|password|passphrase|pairing[_-]?code)"?\s*[:=]\s*"?)([^"\s,}]+)"#,
)
.unwrap()
});
static SAFE_KEY_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
[
"store_id",
"storeid",
"store",
"root",
"root_hash",
"roothash",
"coin_id",
"coinid",
"coin",
"puzzle_hash",
"owner_puzzle_hash",
"peer",
"peer_id",
"addr",
"address",
"ip",
"generation",
"capsule",
"resource_key",
"port",
"public_key",
"pubkey",
"verifying_key",
]
.into_iter()
.collect()
});
const SENSITIVE_EXACT: &[&str] = &[
"sk",
"xprv",
"wif",
"seed",
"mnemonic",
"private_key",
"secret_key",
"signing_key",
"beacon_key",
"privkey",
"secretkey",
];
static SENSITIVE_KV: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?i)("?)([A-Za-z][A-Za-z0-9_]*)("?\s*[:=]\s*"?)([^"\s,}]+)"#).unwrap()
});
static KEY_PHRASE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)\b((?:signing|private|secret|beacon)\s+key)\s+([A-Za-z0-9+/]{16,}={0,2})")
.unwrap()
});
fn is_sensitive_key(name: &str) -> bool {
let name = name.to_ascii_lowercase();
if SAFE_KEY_NAMES.contains(name.as_str()) {
return false;
}
SENSITIVE_EXACT.contains(&name.as_str())
|| name.ends_with("_key")
|| name.ends_with("_secret")
|| name.contains("seed")
|| name.contains("mnemonic")
|| name.contains("priv")
}
static BECH32: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(t?xch1)([0-9a-z]{8})[0-9a-z]{4,}\b").unwrap());
static WIN_USER: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?i)([A-Za-z]:\\Users\\)([^\\\s"]+)"#).unwrap());
static NIX_USER: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(/home/|/Users/)([^/\s"]+)"#).unwrap());
pub fn line(input: &str) -> String {
let stage = redact_mnemonics(input);
let stage = PEM_BLOCK.replace_all(&stage, "[REDACTED:pem]").into_owned();
let stage = AUTH_HEADER
.replace_all(&stage, "${1}[REDACTED:auth]")
.into_owned();
let stage = BEARER
.replace_all(&stage, "Bearer [REDACTED:auth]")
.into_owned();
let stage = TOKEN_KV
.replace_all(&stage, "${1}[REDACTED:token]")
.into_owned();
let stage = KEY_PHRASE
.replace_all(&stage, "${1} [REDACTED:key]")
.into_owned();
let stage = SENSITIVE_KV
.replace_all(&stage, |caps: ®ex::Captures| {
let value = &caps[4];
if is_sensitive_key(&caps[2]) && !value.starts_with("[REDACTED") {
format!("{}{}{}[REDACTED:key]", &caps[1], &caps[2], &caps[3])
} else {
caps[0].to_string()
}
})
.into_owned();
let stage = BECH32.replace_all(&stage, "${1}${2}…").into_owned();
let stage = WIN_USER.replace_all(&stage, r"${1}<user>").into_owned();
NIX_USER.replace_all(&stage, "${1}<user>").into_owned()
}
pub fn text(input: &str) -> String {
input.lines().map(line).collect::<Vec<_>>().join("\n")
}
fn redact_mnemonics(input: &str) -> String {
let words: Vec<_> = WORD
.find_iter(input)
.map(|m| {
(
m.start(),
m.end(),
BIP39_WORDS.contains(m.as_str().to_ascii_lowercase().as_str()),
)
})
.collect();
let mut out = String::new();
let mut cursor = 0; let mut i = 0;
while i < words.len() {
let start = i;
let mut end = i;
while end + 1 < words.len()
&& words[end].2
&& words[end + 1].2
&& MNEMONIC_GAP.is_match(&input[words[end].1..words[end + 1].0])
{
end += 1;
}
let run_len = if words[start].2 { end - start + 1 } else { 0 };
if run_len >= MIN_MNEMONIC_RUN {
out.push_str(&input[cursor..words[start].0]);
out.push_str("[REDACTED:mnemonic]");
cursor = words[end].1;
i = end + 1;
} else {
i += 1;
}
}
out.push_str(&input[cursor..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
const SEED12: &str =
"abandon ability able about above absent absorb abstract absurd abuse access accident";
#[test]
fn redacts_key_value_mnemonic() {
let got = line(&format!("mnemonic={SEED12}"));
assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
assert!(!got.contains("abandon"));
}
#[test]
fn redacts_comment_style_mnemonic() {
let got = line(&format!("# Mnemonic: {SEED12}"));
assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
assert!(!got.contains("abstract"));
}
#[test]
fn eleven_words_not_redacted() {
let eleven = SEED12.rsplit_once(' ').unwrap().0; assert!(!line(eleven).contains("[REDACTED:mnemonic]"));
}
#[test]
fn redacts_pem_and_tokens() {
let pem_out = line("key: -----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----");
assert!(pem_out.contains("[REDACTED:pem]"), "pem: {pem_out}");
assert!(!pem_out.contains("BEGIN"), "pem key leaked: {pem_out}");
assert!(!pem_out.contains("MIIB"), "pem key leaked: {pem_out}");
let token_out = line(r#"{"token":"abc123secret"}"#);
assert!(token_out.contains("[REDACTED:token]"), "token: {token_out}");
assert!(
!token_out.contains("abc123secret"),
"token leaked: {token_out}"
);
let auth_out = line("Authorization: Bearer zzz.yyy.xxx");
assert!(auth_out.contains("[REDACTED:auth]"), "auth: {auth_out}");
assert!(
!auth_out.contains("zzz.yyy.xxx"),
"Bearer token leaked: {auth_out}"
);
assert!(
!auth_out.contains("Bearer zzz"),
"Bearer token leaked: {auth_out}"
);
let opaque_out = line("Authorization: opaque_token_abc123");
assert!(
opaque_out.contains("[REDACTED:auth]"),
"opaque: {opaque_out}"
);
assert!(
!opaque_out.contains("opaque_token_abc123"),
"opaque token leaked: {opaque_out}"
);
let bearer_out = line("Bearer eyJ.payload.sig");
assert!(
bearer_out.contains("[REDACTED:auth]"),
"bearer: {bearer_out}"
);
assert!(
!bearer_out.contains("eyJ.payload.sig"),
"Bearer credential leaked: {bearer_out}"
);
}
#[test]
fn truncates_bech32_but_keeps_public_ids() {
let got = line("addr=xch1qqqqqqqqwwwwwwwweeeeeeee store=abc123def456 peer=203.0.113.7");
assert!(got.contains("xch1qqqqqqq…") || got.contains("…"), "{got}");
assert!(got.contains("abc123def456"), "store ids are KEPT: {got}");
assert!(got.contains("203.0.113.7"), "peer IPs are KEPT: {got}");
}
#[test]
fn scrubs_home_dir_username() {
assert!(line(r"path=C:\Users\alice\AppData").contains(r"C:\Users\<user>"));
assert!(line("path=/home/bob/logs").contains("/home/<user>"));
}
#[test]
fn redacts_named_key_and_seed_fields() {
for name in [
"private_key",
"secret_key",
"signing_key",
"beacon_key",
"sk",
"xprv",
"wif",
"seed",
"mnemonic",
] {
let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
let kv = line(&format!("{name}={secret}"));
assert!(kv.contains("[REDACTED:key]"), "kv {name}: {kv}");
assert!(!kv.contains(secret), "kv {name} leaked: {kv}");
assert!(kv.contains(name), "kv {name} name dropped: {kv}");
let json = line(&format!(r#"{{"{name}":"deadbeefdeadbeef01234567"}}"#));
assert!(json.contains("[REDACTED:key]"), "json {name}: {json}");
assert!(
!json.contains("deadbeefdeadbeef01234567"),
"json {name}: {json}"
);
}
}
#[test]
fn redacts_bare_signing_key_phrase() {
let got = line("loaded signing key 5f3a9c1b7e2d4088aa11bb22cc33dd44");
assert!(got.contains("signing key [REDACTED:key]"), "{got}");
assert!(!got.contains("5f3a9c1b7e2d4088"), "{got}");
}
#[test]
fn redacts_numbered_mnemonic() {
let numbered = "1. abandon 2. ability 3. able 4. about 5. above 6. absent \
7. absorb 8. abstract 9. absurd 10. abuse 11. access 12. accident";
let got = line(numbered);
assert!(got.contains("[REDACTED:mnemonic]"), "{got}");
assert!(
!got.contains("abandon") && !got.contains("accident"),
"{got}"
);
}
#[test]
fn keeps_public_ids_and_safe_named_fields() {
let ids = concat!(
"store_id=7d8f0a1b2c3d4e5f60718293a4b5c6d7 ",
"root_hash=aabbccddeeff00112233445566778899 ",
"coin_id=1122334455667788990011223344556677 ",
"puzzle_hash=ec7c30deadbeefcafe0011223344556677 ",
"resource_key=cafebabecafebabecafebabecafebabe ",
"public_key=abc123def456abc123def456abc123 ",
"peer=203.0.113.7 port=9257 generation=42"
);
let got = line(ids);
assert!(
!got.contains("[REDACTED"),
"public ids over-scrubbed: {got}"
);
for kept in [
"7d8f0a1b2c3d4e5f60718293a4b5c6d7",
"aabbccddeeff00112233445566778899",
"ec7c30deadbeefcafe0011223344556677",
"cafebabecafebabecafebabecafebabe",
"203.0.113.7",
"9257",
] {
assert!(got.contains(kept), "{kept} must be kept: {got}");
}
}
#[test]
fn key_rule_does_not_relabel_prior_redaction() {
let got = line(r#"{"api_key":"sekret","secret":"other"}"#);
assert!(got.contains("[REDACTED:token]"), "{got}");
assert!(!got.contains("[REDACTED:token][REDACTED"), "{got}");
assert!(!got.contains("sekret") && !got.contains("other"), "{got}");
}
#[test]
fn redacts_basic_auth_with_standard_base64() {
let basic_b64 = "dXNlcjpwYXNz+w=="; let got = line(&format!("Authorization: Basic {basic_b64}"));
assert!(
got.contains("[REDACTED:auth]"),
"Basic auth not redacted: {got}"
);
assert!(
!got.contains(basic_b64),
"Basic auth credential leaked: {got}"
);
assert!(
!got.contains("+w=="),
"Basic auth tail (+/= chars) leaked: {got}"
);
}
#[test]
fn redacts_bearer_with_standard_base64() {
let bearer_b64 = "abc+def/ghi=="; let got = line(&format!("Authorization: Bearer {bearer_b64}"));
assert!(
got.contains("[REDACTED:auth]"),
"Bearer auth not redacted: {got}"
);
assert!(!got.contains(bearer_b64), "Bearer credential leaked: {got}");
assert!(
!got.contains("+def/ghi=="),
"Bearer tail (+/= chars) leaked: {got}"
);
}
#[test]
fn redacts_bare_authorization_with_standard_base64() {
let bare_b64 = "dXNlcjpwYXNz+w==";
let got = line(&format!("Authorization: {bare_b64}"));
assert!(
got.contains("[REDACTED:auth]"),
"Bare auth not redacted: {got}"
);
assert!(
!got.contains(bare_b64),
"Bare auth credential leaked: {got}"
);
assert!(!got.contains("+w=="), "Bare auth tail leaked: {got}");
}
#[test]
fn redacts_standalone_bearer_with_standard_base64() {
let standalone_bearer = "Bearer abc+def/ghi==";
let got = line(standalone_bearer);
assert!(
got.contains("[REDACTED:auth]"),
"Standalone Bearer not redacted: {got}"
);
assert!(
!got.contains("abc+def/ghi=="),
"Standalone Bearer credential leaked: {got}"
);
assert!(
!got.contains("+def/ghi=="),
"Standalone Bearer tail (+/= chars) leaked: {got}"
);
}
}