use std::sync::LazyLock;
use regex::Regex;
pub const REDACTED: &str = "[REDACTED]";
pub const CREDENTIAL_KEY_FRAGMENTS: [&str; 10] = [
"apikey",
"accesskey",
"secretkey",
"privatekey",
"secret",
"password",
"passwd",
"credential",
"authorization",
"cookie",
];
pub fn is_credential_key(key: &str) -> bool {
let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
normalized.ends_with("token")
|| CREDENTIAL_KEY_FRAGMENTS
.iter()
.any(|fragment| normalized.contains(fragment))
}
#[expect(
clippy::expect_used,
reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
)]
static SECRET_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
[
r"\bsk-[A-Za-z0-9_-]{16,}\b",
r"\bAKIA[0-9A-Z]{16}\b",
r"\bgh[pousr]_[A-Za-z0-9]{20,}\b",
r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}",
r#"(?i)\b(api[_-]?key|secret|password|passwd|token|access[_-]?key)\b\s*["']?\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{6,}"#,
]
.iter()
.map(|p| Regex::new(p).expect("valid secret regex"))
.collect()
});
#[expect(
clippy::expect_used,
reason = "Built-in constant regexes must compile; invalid syntax is a programming error"
)]
static URL_USERINFO: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s@?#]+@").expect("valid userinfo regex")
});
pub fn scrub_secrets(input: &str) -> String {
let mut out = URL_USERINFO
.replace_all(input, format!("${{1}}{REDACTED}@"))
.into_owned();
for re in SECRET_PATTERNS.iter() {
out = re.replace_all(&out, REDACTED).into_owned();
}
out
}
pub fn scrub_secrets_in_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => *s = scrub_secrets(s),
serde_json::Value::Array(items) => items.iter_mut().for_each(scrub_secrets_in_value),
serde_json::Value::Object(map) => map.values_mut().for_each(scrub_secrets_in_value),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn credential_keys_match_compound_names_but_not_token_counts() {
for key in [
"api_key",
"X-Api-Key",
"client_secret",
"Authorization",
"Set-Cookie",
"access_token",
"refreshToken",
"db_password",
"aws_credentials",
] {
assert!(is_credential_key(key), "{key}");
}
for key in ["max_tokens", "input_tokens", "url", "command", "author"] {
assert!(!is_credential_key(key), "{key}");
}
}
#[test]
fn scrubs_provider_keys() {
let scrubbed = scrub_secrets(
"sk-abcdef0123456789ABCDEF AKIAABCDEFGHIJKLMNOP ghp_abcdefghijklmnopqrstuvwxyz",
);
assert_eq!(scrubbed, "[REDACTED] [REDACTED] [REDACTED]");
}
#[test]
fn scrubs_bearer_header_in_command() {
let scrubbed =
scrub_secrets("curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' x");
assert!(!scrubbed.contains("eyJhbGci"), "{scrubbed}");
assert!(scrubbed.contains(REDACTED));
}
#[test]
fn scrubs_url_userinfo() {
assert_eq!(
scrub_secrets("git clone https://user:hunter2@github.com/o/r.git"),
"git clone https://[REDACTED]@github.com/o/r.git"
);
assert_eq!(
scrub_secrets("postgres://app:pw@db:5432/x and https://tok123@example.com"),
"postgres://[REDACTED]@db:5432/x and https://[REDACTED]@example.com"
);
}
#[test]
fn leaves_ordinary_text_alone() {
for text in [
"https://example.com/path?q=a@b",
"mail me at dev@example.com",
"ls -la /tmp",
"max_tokens: 5",
] {
assert_eq!(scrub_secrets(text), text);
}
}
#[test]
fn scrubs_nested_string_leaves() {
let mut value = json!({
"cmd": ["sh", "-c", "export K=sk-abcdef0123456789ABCDEF"],
"nested": { "list": [{ "url": "https://u:p@h/x" }] },
"n": 3,
});
scrub_secrets_in_value(&mut value);
assert_eq!(
value,
json!({
"cmd": ["sh", "-c", "export K=[REDACTED]"],
"nested": { "list": [{ "url": "https://[REDACTED]@h/x" }] },
"n": 3,
})
);
}
}