use std::sync::OnceLock;
use regex::Regex;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RedactedMatch {
pub kind: &'static str,
pub start: usize,
pub len: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct RedactionResult {
pub text: String,
pub matches: Vec<RedactedMatch>,
}
impl RedactionResult {
pub fn was_redacted(&self) -> bool {
!self.matches.is_empty()
}
pub fn summary(&self) -> String {
if self.matches.is_empty() {
return String::new();
}
let mut kinds: Vec<&'static str> = self.matches.iter().map(|m| m.kind).collect();
kinds.sort_unstable();
kinds.dedup();
format!(
"redacted {} secret{}: {}",
self.matches.len(),
if self.matches.len() == 1 { "" } else { "s" },
kinds.join(", ")
)
}
}
pub fn redact_secrets(text: &str) -> RedactionResult {
let patterns = patterns();
let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
for pat in patterns {
for m in pat.regex.find_iter(text) {
spans.push((m.start(), m.end(), pat.kind));
}
}
if spans.is_empty() {
return RedactionResult {
text: text.to_string(),
matches: Vec::new(),
};
}
spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
let mut cursor = 0usize;
for (start, end, kind) in spans {
if start < cursor {
continue;
}
accepted.push((start, end, kind));
cursor = end;
}
let mut redacted = String::with_capacity(text.len());
let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
let mut last = 0usize;
for (start, end, kind) in accepted {
redacted.push_str(&text[last..start]);
redacted.push_str(&format!("[REDACTED:{kind}]"));
matches.push(RedactedMatch {
kind,
start,
len: end - start,
});
last = end;
}
redacted.push_str(&text[last..]);
RedactionResult {
text: redacted,
matches,
}
}
struct SecretPattern {
kind: &'static str,
regex: Regex,
}
fn patterns() -> &'static [SecretPattern] {
static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
CELL.get_or_init(|| {
let mut out = Vec::new();
out.push(SecretPattern {
kind: "anthropic_oauth",
regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
});
out.push(SecretPattern {
kind: "openai_api_key",
regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
});
out.push(SecretPattern {
kind: "github_pat",
regex: Regex::new(
r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
)
.unwrap(),
});
out.push(SecretPattern {
kind: "slack_token",
regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
});
out.push(SecretPattern {
kind: "aws_access_key",
regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
});
out.push(SecretPattern {
kind: "jwt",
regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
});
out.push(SecretPattern {
kind: "private_key_pem",
regex: Regex::new(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
)
.unwrap(),
});
out.push(SecretPattern {
kind: "google_api_key",
regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
});
out.push(SecretPattern {
kind: "url_credentials",
regex: Regex::new(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@]+:[^\s:/@]{4,}@").unwrap(),
});
out.push(SecretPattern {
kind: "generic_bearer",
regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
});
out.push(SecretPattern {
kind: "generic_api_key",
regex: Regex::new(
r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#,
)
.unwrap(),
});
out.push(SecretPattern {
kind: "generic_token",
regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
});
out.push(SecretPattern {
kind: "generic_password",
regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
});
out
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_text_round_trips_untouched() {
let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
let r = redact_secrets(raw);
assert!(!r.was_redacted());
assert_eq!(r.text, raw);
assert!(r.summary().is_empty());
}
#[test]
fn anthropic_oauth_token_is_redacted() {
let raw = "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
let r = redact_secrets(raw);
assert!(r.was_redacted(), "{:?}", r);
assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
assert!(!r.text.contains("sk-ant-api03"));
assert_eq!(r.matches.len(), 1);
assert_eq!(r.matches[0].kind, "anthropic_oauth");
}
#[test]
fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
let raw =
"two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
let r = redact_secrets(raw);
let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&"openai_api_key"));
assert!(kinds.contains(&"anthropic_oauth"));
assert!(r.text.contains("[REDACTED:openai_api_key]"));
assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
}
#[test]
fn url_embedded_credentials_are_redacted() {
let raw = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.internal:5432/prod";
let r = redact_secrets(raw);
assert!(r.was_redacted(), "{:?}", r);
assert!(r.text.contains("[REDACTED:url_credentials]"));
assert!(!r.text.contains("S3cr3tP4ssw0rd"));
assert!(r.text.contains("db.internal:5432/prod"));
let redis = redact_secrets("redis://default:An0therSecret123@cache:6379");
assert!(redis.text.contains("[REDACTED:url_credentials]"));
assert!(!redis.text.contains("An0therSecret123"));
let clean = redact_secrets("see https://example.com/path?x=1 for docs");
assert!(!clean.was_redacted(), "{:?}", clean);
}
#[test]
fn github_pat_classic_and_fine_grained_redacted() {
let raw = concat!(
"classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
"fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
);
let r = redact_secrets(raw);
let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
assert_eq!(
kinds.iter().filter(|k| **k == "github_pat").count(),
2,
"two github_pat matches expected; got matches: {:?}",
r.matches
);
assert!(!r.text.contains("ghp_abcdef"));
assert!(!r.text.contains("github_pat_11AAA"));
}
#[test]
fn slack_aws_jwt_pem_google_all_redact() {
let raw = concat!(
"slack=xoxb-12345678-abcdefghijklmnop ",
"aws=AKIAIOSFODNN7EXAMPLE ",
"jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
"google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
"pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
);
let r = redact_secrets(raw);
let kinds: Vec<&'static str> = {
let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
k.sort_unstable();
k.dedup();
k
};
for expected in [
"aws_access_key",
"google_api_key",
"jwt",
"private_key_pem",
"slack_token",
] {
assert!(kinds.contains(&expected), "missing kind {expected}: {kinds:?}");
}
}
#[test]
fn generic_assignments_match_only_with_secret_looking_value() {
let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
let r_bad = redact_secrets(bad);
let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&"generic_api_key"));
assert!(kinds.contains(&"generic_token"));
assert!(kinds.contains(&"generic_password"));
let safe = "api_key = short token: 12345 password = a";
let r_safe = redact_secrets(safe);
assert!(
r_safe.matches.is_empty(),
"short values should not trip generic patterns: {r_safe:?}"
);
}
#[test]
fn bearer_token_in_curl_log_is_redacted() {
let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
let r = redact_secrets(raw);
assert!(r.was_redacted());
assert_eq!(r.matches[0].kind, "generic_bearer");
assert!(r.text.contains("[REDACTED:generic_bearer]"));
assert!(!r.text.contains("abc123def456ghi789"));
}
#[test]
fn overlapping_matches_keep_first_only() {
let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
let r = redact_secrets(raw);
assert_eq!(
r.matches.len(),
1,
"non-overlapping rule should pick one: {r:?}"
);
}
#[test]
fn summary_lists_unique_kinds() {
let raw = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
let r = redact_secrets(raw);
let summary = r.summary();
assert!(summary.contains("github_pat"));
assert!(summary.starts_with("redacted 2 secrets: github_pat"));
}
#[test]
fn match_offsets_point_into_original_text() {
let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
let r = redact_secrets(raw);
assert_eq!(r.matches.len(), 1);
let m = &r.matches[0];
let original_match = &raw[m.start..m.start + m.len];
assert!(original_match.starts_with("sk-ant-api03"));
}
#[test]
fn redaction_preserves_non_secret_surroundings() {
let raw =
"# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
let r = redact_secrets(raw);
assert!(r.text.starts_with("# Save to .env"));
assert!(r.text.ends_with("# Use it"));
assert!(r.text.contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]"));
}
}