use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone)]
pub struct SecretMatch {
pub secret_type: &'static str,
pub matched: String,
pub position: usize,
}
static SECRET_PATTERNS: LazyLock<Vec<(&str, Regex)>> = LazyLock::new(|| {
vec![
("AWS Access Key", Regex::new(r"AKIA[0-9A-Z]{16}").unwrap()),
(
"AWS Secret Key",
Regex::new(r"(?i)aws.?secret.?key[\s:=]+[A-Za-z0-9/+=]{40}").unwrap(),
),
(
"GitHub Token",
Regex::new(r"gh[pousr]_[A-Za-z0-9_]{36,}").unwrap(),
),
(
"GitHub PAT",
Regex::new(r"github_pat_[A-Za-z0-9_]{22,}").unwrap(),
),
(
"Anthropic API Key",
Regex::new(r"sk-ant-[A-Za-z0-9_-]{20,}").unwrap(),
),
(
"OpenAI API Key",
Regex::new(r"sk-[A-Za-z0-9_-]{20,}").unwrap(),
),
(
"Generic API Key",
Regex::new(r"(?i)(api[_-]?key|apikey)[\s:=]+[A-Za-z0-9_\-]{20,}").unwrap(),
),
(
"Private Key",
Regex::new(r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----").unwrap(),
),
(
"JWT Token",
Regex::new(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}").unwrap(),
),
("Password in URL", Regex::new(r"://[^:]+:[^@]+@").unwrap()),
(
"Slack Token",
Regex::new(r"xox[baprs]-[A-Za-z0-9-]{10,}").unwrap(),
),
(
"Generic Token",
Regex::new(r"(?i)(token|secret|password|passwd)[\s:=]+[A-Za-z0-9_\-!@#$%^&*]{8,}")
.unwrap(),
),
]
});
pub fn scan_secrets(text: &str) -> Vec<SecretMatch> {
let mut matches = Vec::new();
for (secret_type, regex) in SECRET_PATTERNS.iter() {
for m in regex.find_iter(text) {
let end = m.end();
if matches.iter().any(|existing: &SecretMatch| {
let existing_end = existing.position + existing.matched.len();
m.start() < existing_end && end > existing.position
}) {
continue;
}
matches.push(SecretMatch {
secret_type,
matched: m.as_str().to_string(),
position: m.start(),
});
}
}
matches
}
pub fn redact_secrets(text: &str) -> String {
let mut result = text.to_string();
let mut matches = scan_secrets(text);
matches.sort_by_key(|m| std::cmp::Reverse(m.position));
for m in &matches {
let replacement = format!("[REDACTED: {}]", m.secret_type);
let end = m.position + m.matched.len();
if end <= result.len() {
result.replace_range(m.position..end, &replacement);
}
}
result
}
pub fn contains_secrets(text: &str) -> bool {
SECRET_PATTERNS
.iter()
.any(|(_, regex)| regex.is_match(text))
}
pub fn scan_summary(text: &str) -> Vec<String> {
let matches = scan_secrets(text);
if matches.is_empty() {
return vec!["No secrets detected.".into()];
}
let mut by_type: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for m in &matches {
*by_type.entry(m.secret_type).or_default() += 1;
}
let mut summary: Vec<String> = vec![format!("Found {} potential secret(s):", matches.len())];
for (st, count) in &by_type {
summary.push(format!(" {} x {}", count, st));
}
summary.push("Consider using environment variables or a secrets manager.".into());
summary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_openai_key() {
let text = "export OPENAI_API_KEY=sk-proj12345678901234567890abcdef";
let matches = scan_secrets(text);
assert!(matches.iter().any(|m| m.secret_type == "OpenAI API Key"));
}
#[test]
fn test_redact_secrets() {
let text = "token: sk-ant-api03-abc123def456ghi789";
let redacted = redact_secrets(text);
assert!(redacted.contains("[REDACTED:"));
assert!(!redacted.contains("sk-ant-api03"));
}
#[test]
fn test_no_secrets() {
let text = "This is normal code without any secrets.";
assert!(!contains_secrets(text));
assert!(scan_secrets(text).is_empty());
}
#[test]
fn test_github_token() {
let text = "GITHUB_TOKEN=ghp_1234567890abcdefghijklmnopqrstuvwxyz123456";
let matches = scan_secrets(text);
assert!(matches.iter().any(|m| m.secret_type == "GitHub Token"));
}
#[test]
fn test_private_key() {
let text =
"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0B\n-----END PRIVATE KEY-----";
assert!(contains_secrets(text));
}
}