use std::collections::HashSet;
use once_cell::sync::Lazy;
use regex::Regex;
pub const RULES_VERSION: u32 = 5;
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|identity|node|master|ed25519|bls|api)\s+key)\s+([A-Za-z0-9+/_-]{16,}={0,2})",
)
.unwrap()
});
const CONDITIONAL_SENSITIVE: &[&str] = &["key", "keystore"];
static VALUE_SECRET_SHAPE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[A-Za-z0-9+/_-]{20,}={0,2}$").unwrap());
fn value_looks_secret(value: &str) -> bool {
VALUE_SECRET_SHAPE.is_match(value)
}
static SECRET_DEBUG_TUPLE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)\b(priv(?:ate)?key|secretkey|signingkey|secretstring|seed|mnemonic|keypair|xprv|masterkey|ed25519secretkey|blssecretkey)\s*([(\[])[^)\]]*([)\]])",
)
.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")
|| marks_private_key(&name)
}
fn marks_private_key(name: &str) -> bool {
name == "priv"
|| name.starts_with("priv_")
|| name.contains("privkey")
|| name.contains("privatekey")
|| name.contains("xpriv")
}
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 = SECRET_DEBUG_TUPLE
.replace_all(&stage, "${1}${2}[REDACTED:key]${3}")
.into_owned();
let stage = SENSITIVE_KV
.replace_all(&stage, |caps: ®ex::Captures| {
let name = &caps[2];
let value = &caps[4];
let sensitive = is_sensitive_key(name)
|| (CONDITIONAL_SENSITIVE.contains(&name.to_ascii_lowercase().as_str())
&& value_looks_secret(value));
if sensitive && !value.starts_with("[REDACTED") {
format!("{}{}{}[REDACTED:key]", &caps[1], name, &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}"
);
}
#[test]
fn gap1_redacts_bare_key_and_keystore_with_secret_value() {
let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
for name in ["key", "keystore", "KEY", "Keystore"] {
let kv = line(&format!("{name}={secret}"));
assert!(!kv.contains(secret), "kv {name} leaked the secret: {kv}");
assert!(kv.contains("[REDACTED:key]"), "kv {name}: {kv}");
let json = line(&format!(r#"{{"{name}":"{secret}"}}"#));
assert!(
!json.contains(secret),
"json {name} leaked the secret: {json}"
);
assert!(json.contains("[REDACTED:key]"), "json {name}: {json}");
}
}
#[test]
fn gap1_keeps_bare_key_with_nonsecret_short_value() {
for benign in ["key=user_id", "key=42", "keystore=default", "key=name"] {
let got = line(benign);
assert!(
!got.contains("[REDACTED"),
"benign `{benign}` over-scrubbed: {got}"
);
}
}
#[test]
fn gap1_redacts_bare_key_with_base64url_value() {
let secret = "ZcjI14QiJ1Qety2clr-oDEkJyehiSBRoiYylEfi_JI";
let kv = line(&format!("key={secret}"));
assert!(!kv.contains(secret), "kv base64url secret leaked: {kv}");
assert!(kv.contains("[REDACTED:key]"), "kv not redacted: {kv}");
let json = line(&format!(r#"{{"key":"{secret}"}}"#));
assert!(
!json.contains(secret),
"json base64url secret leaked: {json}"
);
assert!(json.contains("[REDACTED:key]"), "json not redacted: {json}");
}
#[test]
fn gap2_redacts_extended_key_phrases() {
for kind in ["identity", "node", "master", "ed25519", "bls", "api"] {
let secret = "5f3a9c1b7e2d4088aa11bb22cc33dd44";
let got = line(&format!("loaded {kind} key {secret}"));
assert!(!got.contains(secret), "{kind} key leaked: {got}");
assert!(
got.contains(&format!("{kind} key [REDACTED:key]")),
"{kind}: {got}"
);
}
}
#[test]
fn gap2_redacts_base64url_key_phrase_full_tail() {
let secret = "ABCDEFGHIJKLMNOPqrstuvwx-yz012345_6789ABCD";
let tail_after_dash = "yz012345_6789ABCD";
let got = line(&format!("loaded identity key {secret}"));
assert!(
!got.contains(secret),
"identity key base64url secret leaked: {got}"
);
assert!(
!got.contains(tail_after_dash),
"identity key tail-leak (after dash): {got}"
);
assert!(
got.contains("identity key [REDACTED:key]"),
"identity key not redacted: {got}"
);
}
#[test]
fn gap3_redacts_positional_secret_debug_shapes() {
let cases = [
(
"PrivKey(0xabc123def456abc123def456abc1)",
"abc123def456abc123def456abc1",
),
("Seed([222, 173, 190, 239, 1, 2, 3, 4])", "222, 173, 190"),
(
r#"Mnemonic("abandon ability able about")"#,
"abandon ability",
),
(
"SigningKey(deadbeefdeadbeefdeadbeef)",
"deadbeefdeadbeefdeadbeef",
),
("Xprv(xprv9sdeadbeefcafe0011)", "xprv9sdeadbeefcafe0011"),
];
for (input, secret) in cases {
let got = line(input);
assert!(!got.contains(secret), "positional secret leaked: {got}");
assert!(got.contains("[REDACTED:key]"), "not redacted: {got}");
}
let benign = line("Coin([222, 173]) Peer(203.0.113.7)");
assert!(
!benign.contains("[REDACTED"),
"benign wrapper over-scrubbed: {benign}"
);
}
#[test]
fn gap4_keeps_privacy_and_private_beta_field_names() {
for kept in ["privacy=enabled", "private_beta=true", "privatebeta=on"] {
let got = line(kept);
assert!(!got.contains("[REDACTED"), "`{kept}` over-scrubbed: {got}");
}
let secret = "ZcjI14QiJ1Qety2clrKoDEkJyehiSBRoiYylEfiW3JI";
for name in ["priv", "privkey", "xpriv"] {
let got = line(&format!("{name}={secret}"));
assert!(!got.contains(secret), "{name} leaked: {got}");
assert!(got.contains("[REDACTED:key]"), "{name}: {got}");
}
}
#[test]
fn keeps_safe_named_public_ids_after_v5() {
let secret_shaped = "cafebabecafebabecafebabecafebabecafebabe"; for name in [
"storeId",
"store_id",
"rootHash",
"root_hash",
"coinId",
"coin_id",
"public_key",
"resource_key",
] {
let got = line(&format!("{name}={secret_shaped}"));
assert!(
!got.contains("[REDACTED"),
"safe id `{name}` over-scrubbed: {got}"
);
assert!(
got.contains(secret_shaped),
"safe id `{name}` value dropped: {got}"
);
}
}
}