use once_cell::sync::Lazy;
use regex::Regex;
static SENSITIVE_KV_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential|auth[_-]?token|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#
).unwrap()
});
static BEARER_TOKEN_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?i)Bearer\s+([a-zA-Z0-9_\-\.]{8,})"#).unwrap()
});
pub fn scrub_credentials(input: &str) -> String {
let after_bearer = BEARER_TOKEN_REGEX
.replace_all(input, |caps: ®ex::Captures| {
let val: &str = caps.get(1).map(|m: regex::Match| m.as_str()).unwrap_or("");
let prefix = if val.len() > 4 {
val.char_indices()
.nth(4)
.map(|(byte_idx, _)| &val[..byte_idx])
.unwrap_or(val)
} else {
""
};
format!("Bearer {}*[REDACTED]*", prefix)
});
SENSITIVE_KV_REGEX
.replace_all(&after_bearer, |caps: ®ex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val: &str = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map(|m: regex::Match| m.as_str())
.unwrap_or("");
let prefix = if val.len() > 4 {
val.char_indices()
.nth(4)
.map(|(byte_idx, _)| &val[..byte_idx])
.unwrap_or(val)
} else {
""
};
if full_match.contains(':') {
if full_match.contains('"') {
format!(r#""{}": "{}*[REDACTED]*""#, key, prefix)
} else {
format!("{}: {}*[REDACTED]*", key, prefix)
}
} else if full_match.contains('=') {
if full_match.contains('"') {
format!(r#"{}="{}*[REDACTED]*""#, key, prefix)
} else {
format!("{}={}*[REDACTED]*", key, prefix)
}
} else {
format!("{}: {}*[REDACTED]*", key, prefix)
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scrub_json_api_key() {
let input = r#"{"api_key": "sk-ant-api03-xxxxxxxxxxxxxx"}"#;
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
assert!(!result.contains("sk-ant-api03"));
assert!(result.contains("api_key"));
}
#[test]
fn test_scrub_env_token() {
let input = "TOKEN=ghp_abcdef1234567890";
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
assert!(!result.contains("ghp_abcdef"));
assert!(result.contains("TOKEN"));
}
#[test]
fn test_scrub_password_single_quotes() {
let input = "password='mysecretpassword123'";
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
assert!(!result.contains("mysecretpassword"));
}
#[test]
fn test_scrub_bearer_header() {
let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
assert!(!result.contains("eyJhbGci"));
}
#[test]
fn test_no_scrub_short_values() {
let input = r#"{"name": "alex"}"#;
let result = scrub_credentials(input);
assert_eq!(result, input);
}
#[test]
fn test_no_scrub_non_sensitive() {
let input = r#"{"title": "Hello World", "count": 42}"#;
let result = scrub_credentials(input);
assert_eq!(result, input);
}
#[test]
fn test_scrub_multiple_credentials() {
let input = r#"api_key=sk-1234567890 token=ghp_abcdefghij"#;
let result = scrub_credentials(input);
assert_eq!(result.matches("*[REDACTED]*").count(), 2);
}
#[test]
fn test_scrub_mixed_case() {
let input = "API_KEY=sk-ant-1234567890";
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
assert!(!result.contains("sk-ant-1234567890"));
}
#[test]
fn test_scrub_private_key() {
let input = "private_key=-----BEGIN RSA PRIVATE KEY-----MIIEow";
let result = scrub_credentials(input);
assert!(result.contains("*[REDACTED]*"));
}
#[test]
fn test_scrub_preserves_prefix() {
let input = r#"{"api_key": "sk-ant-1234567890abcdef"}"#;
let result = scrub_credentials(input);
assert!(result.contains("sk-a"));
}
}