use regex::{Regex, RegexBuilder};
use std::borrow::Cow;
use std::sync::OnceLock;
const REDACTED: &str = "[REDACTED]";
const REGEX_SIZE_LIMIT: usize = 1 << 20;
static SECRET_PATTERNS: OnceLock<Vec<SecretPattern>> = OnceLock::new();
struct SecretPattern {
name: &'static str,
regex: Regex,
}
fn compile_pattern(name: &'static str, pattern: &str) -> Option<SecretPattern> {
match RegexBuilder::new(pattern)
.size_limit(REGEX_SIZE_LIMIT)
.build()
{
Ok(regex) => Some(SecretPattern { name, regex }),
Err(e) => {
eprintln!(
"[redact] WARNING: secret pattern '{}' failed to compile (skipping): {}",
name, e
);
None
}
}
}
fn get_patterns() -> &'static Vec<SecretPattern> {
SECRET_PATTERNS.get_or_init(|| {
let candidates: Vec<Option<SecretPattern>> = vec![
compile_pattern("api_key", r#"(?i)(api[_-]?key|apikey)\s*[=:]\s*["']?([a-zA-Z0-9_\-]{20,})["']?"#),
compile_pattern("bearer_token", r#"(?i)(bearer\s+)([a-zA-Z0-9_\-\.]{20,})"#),
compile_pattern("aws_access_key", r#"(?i)(AKIA[A-Z0-9]{16})"#),
compile_pattern("aws_secret_key", r#"(?i)(aws[_-]?secret[_-]?access[_-]?key)\s*[=:]\s*["']?([a-zA-Z0-9/+=]{40})["']?"#),
compile_pattern("github_token", r#"(ghp_[a-zA-Z0-9]{36})"#),
compile_pattern("github_fine_grained_token", r#"(github_pat_[a-zA-Z0-9_]{22,})"#),
compile_pattern("gitlab_token", r#"(glpat-[a-zA-Z0-9_\-]{20,})"#),
compile_pattern("openai_key", r#"(sk-[a-zA-Z0-9]{32,})"#),
compile_pattern("google_api_key", r#"(AIza[a-zA-Z0-9_\-]{35})"#),
compile_pattern("stripe_key", r#"(sk_live_[a-zA-Z0-9]{24,}|rk_live_[a-zA-Z0-9]{24,}|pk_live_[a-zA-Z0-9]{24,})"#),
compile_pattern("slack_token", r#"(xox[bpsar]-[a-zA-Z0-9\-]+)"#),
compile_pattern("password", r#"(?i)(password|passwd|pwd|secret)\s*[=:]\s*["']?([^\s"']{8,})["']?"#),
compile_pattern("private_key", r#"-----BEGIN\s+(?:[A-Z0-9]+\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:[A-Z0-9]+\s+)?PRIVATE\s+KEY-----"#),
compile_pattern("db_connection", r#"(?i)(mongodb|postgres|mysql|redis)://[^\s"'<>]+"#),
compile_pattern("jwt", r#"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*"#),
compile_pattern("jwt_partial", r#"eyJ[a-zA-Z0-9_/+\-]{30,}"#),
compile_pattern("env_token", r#"(?i)([A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)[A-Z_]*)\s*[=:]\s*["']?([^\s"']{16,})["']?"#),
compile_pattern("base64_secret", r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*["']?([A-Za-z0-9+/=_\-]{40,})["']?"#),
];
candidates.into_iter().flatten().collect()
})
}
pub fn redact_secrets(input: &str) -> Cow<'_, str> {
let mut result = Cow::Borrowed(input);
for pattern in get_patterns() {
if pattern.regex.is_match(&result) {
let replacement = format!("{}={}", pattern.name, REDACTED);
result = Cow::Owned(
pattern
.regex
.replace_all(&result, &replacement)
.into_owned(),
);
}
}
result
}
pub fn redact_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
let redacted = redact_secrets(s);
if redacted != *s {
*s = redacted.into_owned();
}
}
serde_json::Value::Array(arr) => {
for item in arr {
redact_json(item);
}
}
serde_json::Value::Object(obj) => {
let sensitive_keys: Vec<String> = obj
.keys()
.filter(|k| is_sensitive_key(k))
.cloned()
.collect();
for key in sensitive_keys {
if let Some(val) = obj.get_mut(&key) {
if val.is_string() {
*val = serde_json::Value::String(REDACTED.to_string());
}
}
}
for (_, val) in obj.iter_mut() {
redact_json(val);
}
}
_ => {}
}
}
fn is_sensitive_key(key: &str) -> bool {
let key_lower = key.to_lowercase();
let sensitive_patterns = [
"password",
"passwd",
"pwd",
"secret",
"token",
"api_key",
"apikey",
"auth",
"credential",
"private",
"key",
"bearer",
"jwt",
"session",
"cookie",
"authorization",
];
sensitive_patterns.iter().any(|p| key_lower.contains(p))
}
pub fn redact_path(path: &str) -> Cow<'_, str> {
let sensitive_files = [
".env",
"credentials",
"secrets",
".netrc",
".npmrc",
"id_rsa",
"id_ed25519",
];
for sensitive in &sensitive_files {
if path.contains(sensitive) {
return Cow::Owned(format!("[SENSITIVE_PATH:{}]", sensitive));
}
}
Cow::Borrowed(path)
}
#[cfg(test)]
pub fn safe_log(message: &str) -> String {
redact_secrets(message).into_owned()
}
#[cfg(test)]
#[path = "../../tests/unit/safety/redact/redact_test.rs"]
mod tests;