use std::sync::LazyLock;
use regex::Regex;
static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
let p = |re: &str| Regex::new(re).expect("static redaction regex must compile");
vec![
(
p(
r#"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"']{6,}["']?"#,
),
"${1}=[REDACTED]",
),
(
p(r#"(?i)\b(Bearer)\s+[A-Za-z0-9._\-]{8,}"#),
"${1} [REDACTED]",
),
(p(r#"\bsk-(?:ant-)?[A-Za-z0-9._\-]{12,}"#), "[REDACTED]"), (p(r#"\bAKIA[0-9A-Z]{16}\b"#), "[REDACTED]"), (p(r#"\bgh[pousr]_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"), (p(r#"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"#), "[REDACTED]"), (p(r#"\bAIza[0-9A-Za-z._\-]{20,}\b"#), "[REDACTED]"), (p(r#"\bgsk_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"), (p(r#"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b"#), "[REDACTED]"), (
p(r#"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"#),
"[REDACTED]",
),
(p(r#"-----BEGIN [A-Z ]*PRIVATE KEY-----"#), "[REDACTED]"),
(p(r#"(://[^/:@\s]+:)[^/@\s]{3,}@"#), "${1}[REDACTED]@"),
]
});
const MIN_CREDENTIAL_VALUE_LEN: usize = 6;
const BARE_CREDENTIAL_KEYS: &[&str] = &[
"key",
"authorization",
"auth",
"password",
"token",
"secret",
"apikey",
];
static CRED_KEY_TOKEN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)",
)
.expect("static redaction regex must compile")
});
fn key_names_credential(key: &str) -> bool {
CRED_KEY_TOKEN.is_match(key)
|| BARE_CREDENTIAL_KEYS
.iter()
.any(|bare| key.eq_ignore_ascii_case(bare))
}
pub fn redact_secrets(input: &str) -> String {
let mut out = std::borrow::Cow::Borrowed(input);
for (re, repl) in SECRET_PATTERNS.iter() {
if re.is_match(&out) {
out = std::borrow::Cow::Owned(re.replace_all(&out, *repl).into_owned());
}
}
out.into_owned()
}
pub fn redact_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
let red = redact_secrets(s);
if &red != s {
*s = red;
}
},
serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
serde_json::Value::Object(map) => {
for (key, val) in map.iter_mut() {
if key_names_credential(key)
&& let serde_json::Value::String(s) = val
&& s.len() >= MIN_CREDENTIAL_VALUE_LEN
{
*s = "[REDACTED]".to_string();
continue;
}
redact_json(val);
}
},
_ => {},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_named_secrets() {
assert_eq!(
redact_secrets("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
"OPENAI_API_KEY=[REDACTED]"
);
assert_eq!(
redact_secrets("export AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG'"),
"export AWS_SECRET_ACCESS_KEY=[REDACTED]"
);
assert_eq!(redact_secrets("password = hunter2"), "password=[REDACTED]");
assert_eq!(
redact_secrets("--api-key=sk-abcdefghijklmnop1234"),
"--api-key=[REDACTED]"
);
}
#[test]
fn redacts_known_prefixes_and_bearer() {
assert_eq!(
redact_secrets("Authorization: Bearer abcdef123456ghijkl"),
"Authorization: Bearer [REDACTED]"
);
assert_eq!(
redact_secrets("key sk-ant-api03-abcdefghijklmnop"),
"key [REDACTED]"
);
assert_eq!(
redact_secrets("AKIAIOSFODNN7EXAMPLE here"),
"[REDACTED] here"
);
assert_eq!(
redact_secrets("token ghp_0123456789abcdefghijABCDEFGHIJ012345"),
"token [REDACTED]"
);
}
#[test]
fn leaves_ordinary_text_untouched() {
for ok in [
"the quick brown fox",
"let token_count = 42;",
"commit a614aa9f deploys the fix",
"see src/providers/tool/exec.rs:855",
] {
assert_eq!(redact_secrets(ok), ok, "should not redact: {ok}");
}
}
#[test]
fn redacts_new_value_prefixes() {
assert_eq!(
redact_secrets("groq gsk_abcdefghijklmnopqrstuvwxyz0123"),
"groq [REDACTED]"
);
assert_eq!(
redact_secrets("stripe sk_live_abcdefghijklmnop1234"),
"stripe [REDACTED]"
);
assert_eq!(
redact_secrets(
"token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM"
),
"token [REDACTED]"
);
assert_eq!(
redact_secrets("-----BEGIN RSA PRIVATE KEY-----"),
"[REDACTED]"
);
assert_eq!(
redact_secrets("postgres://admin:s3cr3tpass@db.example.com/app"),
"postgres://admin:[REDACTED]@db.example.com/app"
);
}
#[test]
fn redact_json_scrubs_credential_keyed_values_regardless_of_shape() {
let mut v = serde_json::json!({
"api_key": "gsk_opaquevalue123456",
"authorization": "OpaqueBearerLikeValue",
"key": "id",
"name": "hello",
"nested": { "Secret-Token": "another-opaque-credential" },
});
redact_json(&mut v);
assert_eq!(v["api_key"], "[REDACTED]");
assert_eq!(v["authorization"], "[REDACTED]");
assert_eq!(v["key"], "id");
assert_eq!(v["name"], "hello");
assert_eq!(v["nested"]["Secret-Token"], "[REDACTED]");
}
#[test]
fn redact_json_scrubs_string_leaves() {
let mut v = serde_json::json!({
"text": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
"count": 7,
"nested": ["safe", "Bearer abcdef123456ghijkl"],
});
redact_json(&mut v);
assert_eq!(v["text"], "OPENAI_API_KEY=[REDACTED]");
assert_eq!(v["count"], 7);
assert_eq!(v["nested"][0], "safe");
assert_eq!(v["nested"][1], "Bearer [REDACTED]");
}
}