use super::CompiledRule;
use regex::Regex;
use std::sync::OnceLock;
pub fn scrub_secrets(input: &str, extra_patterns: &[CompiledRule], entropy: bool) -> String {
let mut scrubbed = input.to_string();
for (regex, replacement) in secret_patterns() {
scrubbed = regex.replace_all(&scrubbed, *replacement).to_string();
}
for rule in extra_patterns {
scrubbed = rule
.regex
.replace_all(&scrubbed, rule.replacement.as_str())
.to_string();
}
if entropy {
scrubbed = super::entropy::scrub_entropy_secrets(&scrubbed);
}
scrubbed
}
fn secret_patterns() -> &'static [(Regex, &'static str)] {
static PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
PATTERNS.get_or_init(|| {
vec![
(Regex::new(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----").unwrap(), "[REDACTED PRIVATE KEY]"),
(Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(?:\.[A-Za-z0-9\-_.+/=]+)?").unwrap(), "Bearer ***"),
(Regex::new(r"eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+").unwrap(), "[REDACTED JWT]"),
(Regex::new(r"github_pat_[A-Za-z0-9_]+").unwrap(), "github_pat_***"),
(Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").unwrap(), "glpat-***"),
(Regex::new(r"xox[baprs]-[A-Za-z0-9-]+").unwrap(), "xoxb-***"),
(Regex::new(r"AKIA[0-9A-Z]{16,}").unwrap(), "AKIA***"),
(Regex::new(r"AIza[0-9A-Za-z\-_]{35,}").unwrap(), "AIza***"),
(Regex::new(r"[sr]k_live_[0-9a-zA-Z]{16,}").unwrap(), "***_live_***"),
(Regex::new(r"SG\.[A-Za-z0-9_\-]{16,32}\.[A-Za-z0-9_\-]{16,64}").unwrap(), "SG.***"),
(Regex::new(r"\bSK[0-9a-fA-F]{32}\b").unwrap(), "SK***"),
(Regex::new(r"npm_[A-Za-z0-9]{36,}").unwrap(), "npm_***"),
(Regex::new(r"pypi-[A-Za-z0-9_\-]{16,}").unwrap(), "pypi-***"),
(Regex::new(r"hf_[A-Za-z0-9]{20,}").unwrap(), "hf_***"),
(Regex::new(r"https://hooks\.slack\.com/services/[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9]+").unwrap(), "https://hooks.slack.com/services/***"),
(Regex::new(r"\b[MNO][A-Za-z0-9_-]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b").unwrap(), "[REDACTED DISCORD TOKEN]"),
(Regex::new(r"\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b").unwrap(), "[REDACTED TELEGRAM TOKEN]"),
(Regex::new(r"do[opr]_v1_[a-f0-9]{64}").unwrap(), "do_v1_***"),
(Regex::new(r"shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}").unwrap(), "shp_***"),
(Regex::new(r"(?i)AccountKey=[A-Za-z0-9+/]{80,}={0,2}").unwrap(), "AccountKey=***"),
(Regex::new(r"sk-ant-[0-9A-Za-z\-_]{10,}").unwrap(), "sk-ant-***"),
(Regex::new(r"sk-proj-[A-Za-z0-9_\-]{20,}").unwrap(), "sk-proj-***"),
(Regex::new(r"sk-[a-zA-Z0-9\-]{20,}").unwrap(), "sk-***"),
(Regex::new(r"ghp_[a-zA-Z0-9]+").unwrap(), "ghp_***"),
(Regex::new(r"(?i)\b(\w+)://([^:@/\s]+):([^@/\s]+)@").unwrap(), "${1}://${2}:***@"),
(Regex::new(r#"\b(PASSWORD|PASSWD|SECRET|TOKEN|API[-_]?KEY)\s*[=:]\s*["']?[^\s"']{6,}"#).unwrap(), "${1}=***"),
]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scrub_secrets() {
let input = concat!(
"Here is my key: sk-",
"proj-1234567890abcdef",
" and my token: ghp_",
"abcdefghijklmno"
);
let expected = "Here is my key: sk-*** and my token: ghp_***";
assert_eq!(scrub_secrets(input, &[], true), expected);
}
#[test]
fn test_scrub_extended_secret_formats() {
let input = concat!(
"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
"eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\n",
"GitHub fine-grained: github_pat_",
"abcdefghijklmnopqrstuvwxyz1234567890\n",
"Slack: xox",
"b-123456789012-123456789012-abcdefghijklmnopqrstuvwx\n",
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----\n"
);
let scrubbed = scrub_secrets(input, &[], true);
assert!(scrubbed.contains("Authorization: Bearer ***"));
assert!(scrubbed.contains("github_pat_***"));
assert!(scrubbed.contains("xoxb-***"));
assert!(scrubbed.contains("[REDACTED PRIVATE KEY]"));
assert!(!scrubbed.contains("signature"));
assert!(!scrubbed.contains("abcdefghijklmnopqrstuvwx"));
assert!(!scrubbed.contains("abc123"));
}
#[test]
fn test_scrub_custom_secret_pattern() {
use crate::config::RedactionRule;
let input = "custom token-12345";
let rules = super::super::compile_rules(&[RedactionRule {
pattern: "token-[0-9]+".to_string(),
replacement: "token-***".to_string(),
}]);
let scrubbed = scrub_secrets(input, &rules, true);
assert_eq!(scrubbed, "custom token-***");
}
#[test]
fn test_scrub_new_secret_formats() {
let cases = [
("AWS: AKIAIOSFODNN7EXAMPLE", "AKIAIOSFODNN7EXAMPLE"),
(
"Google: AIzaSyA1234567890abcdefghijklmnopqrstuvw",
"AIzaSyA1234567890abcdefghijklmnopqrstuvw",
),
(
concat!("Stripe: sk_live_", "0123456789abcdefABCDEF99"),
concat!("sk_live_", "0123456789abcdefABCDEF99"),
),
(
"Anthropic: sk-ant-api03-AbC123_def-456",
"sk-ant-api03-AbC123_def-456",
),
];
for (input, raw) in cases {
let scrubbed = scrub_secrets(input, &[], true);
assert!(
!scrubbed.contains(raw),
"raw secret leaked for input: {input} -> {scrubbed}"
);
}
}
#[test]
fn test_scrub_additional_provider_formats() {
let cases = [
("GitLab: ", concat!("glpat-", "abcdef1234567890ABCDEF")),
(
"SendGrid: ",
concat!(
"SG.",
"abcdefghij1234567890ab",
".",
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHI"
),
),
(
"Twilio: ",
concat!("SK", "0123456789abcdef0123456789abcdef"),
),
(
"npm: ",
concat!("npm_", "abcdefghijklmnopqrstuvwxyz0123456789"),
),
("PyPI: ", concat!("pypi-", "AgEIcHlwaS5vcmcCJD12345")),
("HF: ", concat!("hf_", "abcdefghijklmnopqrstuvwxyz")),
(
"OpenAI: ",
concat!("sk-proj-", "abc_DEF-123ghi_JKL456mno789"),
),
];
for (label, raw) in cases {
let input = format!("{label}{raw}");
let scrubbed = scrub_secrets(&input, &[], false);
assert!(
!scrubbed.contains(raw),
"raw secret leaked for {label} -> {scrubbed}"
);
}
}
#[test]
fn over_length_fixed_count_keys_do_not_leak_a_suffix() {
let cases = [
concat!("AIza", "0123456789abcdefghijklmnopqrstuvwxyzAB"),
concat!("npm_", "0123456789abcdefghijklmnopqrstuvwxyzABCD"),
concat!("AKIA", "0123456789ABCDEFGH"),
];
for raw in cases {
let scrubbed = scrub_secrets(raw, &[], false);
assert!(
scrubbed.ends_with("***") && !scrubbed.contains(&raw[raw.len() - 2..]),
"over-length key leaked a suffix: {raw} -> {scrubbed}"
);
}
}
#[test]
fn scrubs_more_provider_formats() {
let slack = format!(
"https://hooks.slack.com/services/T{}/B{}/{}",
"0".repeat(8),
"1".repeat(8),
"a".repeat(24)
);
let discord = format!("M{}.{}.{}", "a".repeat(23), "b".repeat(6), "c".repeat(27));
let telegram = format!("{}:{}", "1".repeat(9), "a".repeat(35));
let digitalocean = format!("dop_v1_{}", "a".repeat(64));
let shopify = format!("shpat_{}", "a".repeat(32));
let azure = format!("AccountKey={}==", "a".repeat(86));
for raw in [&slack, &discord, &telegram, &digitalocean, &shopify, &azure] {
let input = format!("token: {raw}");
let scrubbed = scrub_secrets(&input, &[], false);
assert!(
!scrubbed.contains(raw.as_str()),
"raw secret leaked: {input} -> {scrubbed}"
);
}
}
#[test]
fn sk_ant_key_is_not_double_masked() {
let input = concat!("sk-ant-", "api03-AbC123_def-456ghijklmno");
let scrubbed = scrub_secrets(input, &[], false);
assert!(scrubbed.contains("sk-ant-***"), "got: {scrubbed}");
assert!(!scrubbed.contains("sk-******"), "double-masked: {scrubbed}");
}
#[test]
fn test_scrub_bare_jwt() {
let jwt = concat!(
"eyJhbGciOiJIUzI1NiJ9.",
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.",
"dummysignature_part-0123"
);
let input = format!("session token = {jwt}");
let scrubbed = scrub_secrets(&input, &[], false);
assert!(scrubbed.contains("[REDACTED JWT]"), "got: {scrubbed}");
assert!(!scrubbed.contains("dummysignature"), "got: {scrubbed}");
}
#[test]
fn test_twilio_sid_does_not_eat_prose() {
let input = "The SKILL value and SK123 are fine";
assert_eq!(scrub_secrets(input, &[], false), input);
}
#[test]
fn test_scrub_connection_string_password() {
let input = "postgres://admin:s3cr3tP@ss@db.example.com:5432/app";
let scrubbed = scrub_secrets(input, &[], true);
assert!(
scrubbed.contains("postgres://admin:***@"),
"got: {scrubbed}"
);
assert!(!scrubbed.contains("s3cr3tP"));
}
#[test]
fn test_scrub_env_assignment() {
let input = "PASSWORD=hunter2supersecret\nAPI_KEY: abcdef123456ZZ";
let scrubbed = scrub_secrets(input, &[], true);
assert!(!scrubbed.contains("hunter2supersecret"), "got: {scrubbed}");
assert!(!scrubbed.contains("abcdef123456ZZ"), "got: {scrubbed}");
}
#[test]
fn test_env_assignment_ignores_short_prose() {
let input = "the secret: tip";
let scrubbed = scrub_secrets(input, &[], true);
assert_eq!(scrubbed, input);
}
#[test]
fn entropy_stage_can_be_disabled() {
let input = "db_password=0123456789abcdef0123456789abcdef";
assert_eq!(
scrub_secrets(input, &[], true),
"db_password=***",
"entropy on must redact"
);
assert_eq!(
scrub_secrets(input, &[], false),
input,
"entropy off must pass through"
);
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_scrub_secrets_does_not_panic(s in "[\\s\\S]{0,500}") {
let _ = scrub_secrets(&s, &[], true);
}
#[test]
fn prop_scrub_secrets_idempotent(s in "[\\s\\S]{0,500}") {
let once = scrub_secrets(&s, &[], true);
let twice = scrub_secrets(&once, &[], true);
prop_assert_eq!(once, twice);
}
}
}