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 {
merge_and_redact(text, collect_spans(text, patterns()))
}
pub fn scrub_for_export(text: &str) -> RedactionResult {
let mut spans = collect_spans(text, patterns());
spans.extend(collect_pii_spans(text));
merge_and_redact(text, spans)
}
fn collect_spans(text: &str, patterns: &[SecretPattern]) -> Vec<(usize, usize, &'static str)> {
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));
}
}
spans
}
fn merge_and_redact(text: &str, mut spans: Vec<(usize, usize, &'static str)>) -> RedactionResult {
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,
}
}
fn collect_pii_spans(text: &str) -> Vec<(usize, usize, &'static str)> {
let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
for pat in pii_patterns() {
for m in pat.regex.find_iter(text) {
if pat.kind == "credit_card" {
let digits: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
if !(13..=19).contains(&digits.len()) || !luhn_valid(&digits) {
continue;
}
}
spans.push((m.start(), m.end(), pat.kind));
}
}
spans
}
fn luhn_valid(digits: &str) -> bool {
if digits.is_empty() {
return false;
}
let mut sum = 0u32;
let mut double = false;
for c in digits.chars().rev() {
let mut d = match c.to_digit(10) {
Some(d) => d,
None => return false,
};
if double {
d *= 2;
if d > 9 {
d -= 9;
}
}
sum += d;
double = !double;
}
sum % 10 == 0
}
fn pii_patterns() -> &'static [SecretPattern] {
static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
CELL.get_or_init(|| {
vec![
SecretPattern {
kind: "email",
regex: Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap(),
},
SecretPattern {
kind: "ssn",
regex: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
},
SecretPattern {
kind: "phone",
regex: Regex::new(
r"\b(?:\+?\d{1,3}[ .\-]?)?(?:\(\d{3}\)|\d{3})[ .\-]\d{3}[ .\-]\d{4}\b",
)
.unwrap(),
},
SecretPattern {
kind: "credit_card",
regex: Regex::new(r"\b\d(?:[ \-]?\d){12,18}\b").unwrap(),
},
]
})
}
struct SecretPattern {
kind: &'static str,
regex: Regex,
}
fn patterns() -> &'static [SecretPattern] {
static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
CELL.get_or_init(|| {
vec![
SecretPattern {
kind: "anthropic_oauth",
regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
},
SecretPattern {
kind: "openai_api_key",
regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
},
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(),
},
SecretPattern {
kind: "slack_token",
regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
},
SecretPattern {
kind: "aws_access_key",
regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
},
SecretPattern {
kind: "jwt",
regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
},
SecretPattern {
kind: "private_key_pem",
regex: Regex::new(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
)
.unwrap(),
},
SecretPattern {
kind: "google_api_key",
regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
},
SecretPattern {
kind: "url_credentials",
regex: Regex::new(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@]+:[^\s:/@]{4,}@").unwrap(),
},
SecretPattern {
kind: "generic_bearer",
regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
},
SecretPattern {
kind: "generic_api_key",
regex: Regex::new(r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#).unwrap(),
},
SecretPattern {
kind: "generic_token",
regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
},
SecretPattern {
kind: "generic_password",
regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
},
]
})
}
#[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 scrub_for_export_redacts_pii_and_credentials() {
let raw = "contact alice@example.com or 415-555-0142; ssn 123-45-6789; \
key sk-ant-AbCdEfGhIjKlMnOpQrStUvWx0123456789";
let r = scrub_for_export(raw);
let kinds: std::collections::BTreeSet<&str> = r.matches.iter().map(|m| m.kind).collect();
assert!(kinds.contains("email"), "{r:?}");
assert!(kinds.contains("phone"), "{r:?}");
assert!(kinds.contains("ssn"), "{r:?}");
assert!(kinds.contains("anthropic_oauth"), "{r:?}");
assert!(!r.text.contains("alice@example.com"));
assert!(!r.text.contains("123-45-6789"));
assert!(!r.text.contains("sk-ant-"));
}
#[test]
fn scrub_for_export_luhn_gates_credit_cards() {
let good = scrub_for_export("card 4242 4242 4242 4242 on file");
assert!(
good.matches.iter().any(|m| m.kind == "credit_card"),
"valid card must scrub: {good:?}"
);
let bad = scrub_for_export("trace 1234567890123456 step");
assert!(
!bad.matches.iter().any(|m| m.kind == "credit_card"),
"non-Luhn digit run must NOT scrub: {bad:?}"
);
}
#[test]
fn scrub_for_export_leaves_technical_text_alone() {
let raw = "build with cargo 1.79; commit a1b2c3d4; port 8787; ulid 01K8YMJ448514TP6CPQ";
let r = scrub_for_export(raw);
assert!(!r.was_redacted(), "false positive: {r:?}");
assert_eq!(r.text, raw);
}
#[test]
fn luhn_check() {
assert!(luhn_valid("4242424242424242"));
assert!(!luhn_valid("4242424242424241"));
assert!(!luhn_valid(""));
}
#[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]")
);
}
}