use aho_corasick::AhoCorasick;
const AC_PATTERNS: &[&str] = &[
"sk-ant-", "sk-", "AKIA", "\"type\": \"service_account\"", "DefaultEndpointsProtocol=", "ghp_", "ghs_", "xoxb-", "xoxp-", "xoxa-", "postgres://", "mysql://", "mongodb://", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----", "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN PRIVATE KEY-----", "-----BEGIN PGP PRIVATE KEY BLOCK-----", "\"type\":\"service_account\"", "\"type\" :\"service_account\"", "\"type\" : \"service_account\"", ];
const AC_KINDS: &[CredentialKind] = &[
CredentialKind::AnthropicKey, CredentialKind::OpenAiKey, CredentialKind::AwsAccessKey, CredentialKind::GcpServiceAccount, CredentialKind::AzureConnectionString, CredentialKind::GitHubPat, CredentialKind::GitHubAppToken, CredentialKind::SlackBotToken, CredentialKind::SlackUserToken, CredentialKind::SlackOAuthToken, CredentialKind::PostgresUrl, CredentialKind::MysqlUrl, CredentialKind::MongodbUrl, CredentialKind::RsaPrivateKey, CredentialKind::EcPrivateKey, CredentialKind::OpensshPrivateKey, CredentialKind::PrivateKey, CredentialKind::PgpPrivateKey, CredentialKind::GcpServiceAccount, CredentialKind::GcpServiceAccount, CredentialKind::GcpServiceAccount, ];
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CredentialKind {
AnthropicKey,
AwsAccessKey,
GcpServiceAccount,
OpenAiKey,
AzureConnectionString,
GitHubAppToken,
GitHubPat,
SlackBotToken,
SlackOAuthToken,
SlackUserToken,
MongodbUrl,
MysqlUrl,
PostgresUrl,
EcPrivateKey,
OpensshPrivateKey,
PgpPrivateKey,
PrivateKey,
RsaPrivateKey,
CreditCardLuhn,
EmailAddress,
SsnPattern,
GenericHighEntropy,
Custom,
}
impl CredentialKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::AnthropicKey => "AnthropicKey",
Self::AwsAccessKey => "AwsAccessKey",
Self::AzureConnectionString => "AzureConnectionString",
Self::CreditCardLuhn => "CreditCardLuhn",
Self::EcPrivateKey => "EcPrivateKey",
Self::EmailAddress => "EmailAddress",
Self::GcpServiceAccount => "GcpServiceAccount",
Self::GenericHighEntropy => "GenericHighEntropy",
Self::GitHubAppToken => "GitHubAppToken",
Self::GitHubPat => "GitHubPat",
Self::MongodbUrl => "MongodbUrl",
Self::MysqlUrl => "MysqlUrl",
Self::OpenAiKey => "OpenAiKey",
Self::OpensshPrivateKey => "OpensshPrivateKey",
Self::PgpPrivateKey => "PgpPrivateKey",
Self::PostgresUrl => "PostgresUrl",
Self::PrivateKey => "PrivateKey",
Self::RsaPrivateKey => "RsaPrivateKey",
Self::SlackBotToken => "SlackBotToken",
Self::SlackOAuthToken => "SlackOAuthToken",
Self::SlackUserToken => "SlackUserToken",
Self::SsnPattern => "SsnPattern",
Self::Custom => "Custom",
}
}
fn priority(&self) -> u8 {
match self {
Self::GenericHighEntropy => 0,
Self::EmailAddress => 1,
Self::AnthropicKey
| Self::AwsAccessKey
| Self::AzureConnectionString
| Self::CreditCardLuhn
| Self::EcPrivateKey
| Self::GcpServiceAccount
| Self::GitHubAppToken
| Self::GitHubPat
| Self::MongodbUrl
| Self::MysqlUrl
| Self::OpenAiKey
| Self::OpensshPrivateKey
| Self::PgpPrivateKey
| Self::PostgresUrl
| Self::PrivateKey
| Self::RsaPrivateKey
| Self::SlackBotToken
| Self::SlackOAuthToken
| Self::SlackUserToken
| Self::SsnPattern
| Self::Custom => 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CredentialFinding {
pub kind: CredentialKind,
pub offset: usize,
pub matched: String,
#[cfg_attr(feature = "serde", serde(skip))]
end: usize,
}
impl CredentialFinding {
fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
let label = format!("[REDACTED:{}]", kind.as_str());
Self {
kind,
offset,
matched: label,
end,
}
}
pub fn from_regex_match(offset: usize, end: usize) -> Self {
Self::new(CredentialKind::Custom, offset, end)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScanResult {
pub findings: Vec<CredentialFinding>,
}
impl ScanResult {
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
pub fn redact(&self, text: &str) -> String {
let merged = coalesce_findings(&self.findings);
let mut result = text.to_string();
for span in merged.iter().rev() {
if span.end <= result.len()
&& span.offset <= span.end
&& result.is_char_boundary(span.offset)
&& result.is_char_boundary(span.end)
{
result.replace_range(span.offset..span.end, &span.label);
}
}
result
}
}
#[derive(Debug, Clone, Default)]
pub struct ScannerConfig {
pub disabled: bool,
pub custom_patterns: Vec<String>,
}
pub struct CredentialScanner {
patterns: AhoCorasick,
kinds: Vec<CredentialKind>,
disabled: bool,
}
impl Default for CredentialScanner {
fn default() -> Self {
Self::new()
}
}
impl CredentialScanner {
pub fn new() -> Self {
Self::with_config(ScannerConfig::default())
}
pub fn with_config(config: ScannerConfig) -> Self {
let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
all_patterns.extend_from_slice(&custom_refs);
let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
let ac = AhoCorasick::builder()
.match_kind(aho_corasick::MatchKind::LeftmostFirst)
.ascii_case_insensitive(true)
.build(&all_patterns)
.expect("AC patterns are always valid");
Self {
patterns: ac,
kinds,
disabled: config.disabled,
}
}
pub fn scan(&self, text: &str) -> ScanResult {
if self.disabled {
return ScanResult { findings: Vec::new() };
}
let mut findings = Vec::new();
for mat in self.patterns.find_iter(text) {
let kind = self.kinds[mat.pattern()].clone();
let offset = mat.start();
let end = token_end(text, mat.end());
findings.push(CredentialFinding::new(kind, offset, end));
}
scan_digit_sequences(text, &mut findings);
scan_emails(text, &mut findings);
scan_high_entropy(text, &mut findings);
scan_azure_account_key(text, &mut findings);
findings.sort_by_key(|f| f.offset);
ScanResult { findings }
}
}
struct MergedSpan {
offset: usize,
end: usize,
label: String,
kind: CredentialKind,
}
fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
sorted.sort_by_key(|f| (f.offset, f.end));
let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
for f in sorted {
match merged.last_mut() {
Some(last) if f.offset < last.end => {
last.end = last.end.max(f.end);
if f.kind.priority() > last.kind.priority() {
last.label = f.matched.clone();
last.kind = f.kind.clone();
}
}
_ => merged.push(MergedSpan {
offset: f.offset,
end: f.end,
label: f.matched.clone(),
kind: f.kind.clone(),
}),
}
}
merged
}
fn scan_azure_account_key(text: &str, findings: &mut Vec<CredentialFinding>) {
const MARKER: &str = "AccountKey=";
let mut search_from = 0;
while let Some(rel) = text[search_from..].find(MARKER) {
let offset = search_from + rel;
let value_start = offset + MARKER.len();
let end = text[value_start..]
.find(|c: char| c.is_whitespace() || matches!(c, ';' | '"' | '\'' | ',' | ')' | ']' | '}'))
.map(|i| value_start + i)
.unwrap_or(text.len());
findings.push(CredentialFinding::new(
CredentialKind::AzureConnectionString,
offset,
end,
));
search_from = end.max(value_start);
}
}
fn token_end(text: &str, from: usize) -> usize {
text[from..]
.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
.map(|i| from + i)
.unwrap_or(text.len())
}
fn is_ssn(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 11
&& b[0..3].iter().all(u8::is_ascii_digit)
&& b[3] == b'-'
&& b[4..6].iter().all(u8::is_ascii_digit)
&& b[6] == b'-'
&& b[7..11].iter().all(u8::is_ascii_digit)
}
fn luhn_valid(digits: &str) -> bool {
if digits.len() < 13 || digits.len() > 19 {
return false;
}
let mut sum = 0u32;
let mut double = false;
for ch in digits.chars().rev() {
let Some(d) = ch.to_digit(10) else {
return false;
};
let val = if double {
let v = d * 2;
if v > 9 {
v - 9
} else {
v
}
} else {
d
};
sum += val;
double = !double;
}
sum % 10 == 0
}
fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
let mut digits = String::new();
let mut j = i;
let limit = (start + 24).min(bytes.len());
while j < limit {
match bytes[j] {
b if b.is_ascii_digit() => {
digits.push(b as char);
j += 1;
}
b' ' | b'-' if !digits.is_empty() => {
j += 1;
}
_ => break,
}
}
let end = j;
let segment = &text[start..end];
if is_ssn(segment) {
findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
} else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
}
i = end.max(i + 1);
}
}
fn shannon_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut freq = [0u32; 256];
for &b in s.as_bytes() {
freq[b as usize] += 1;
}
let len = s.len() as f64;
freq.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
const ENTROPY_BITS_GATE: f64 = 4.5;
const HEX_RUN_MIN_LEN: usize = 64;
const BASE64_RUN_MIN_LEN: usize = 64;
fn is_base64_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'-' | b'_')
}
fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
let mut offset = 0usize;
for token in text.split_whitespace() {
let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
let whitespace_end = token_offset + token.len();
let len = token.len();
if (20..=64).contains(&len) && shannon_entropy(token) > ENTROPY_BITS_GATE {
let end = token_end(text, token_offset);
findings.push(CredentialFinding::new(
CredentialKind::GenericHighEntropy,
token_offset,
end,
));
}
offset = whitespace_end;
}
scan_long_hex_runs(text, findings);
scan_long_base64_runs(text, findings);
}
fn scan_long_hex_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_hexdigit() {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
i += 1;
}
if i - start >= HEX_RUN_MIN_LEN {
findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
}
}
}
fn scan_long_base64_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !is_base64_char(bytes[i]) {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && is_base64_char(bytes[i]) {
i += 1;
}
let run = &text[start..i];
if run.len() > BASE64_RUN_MIN_LEN && shannon_entropy(run) > ENTROPY_BITS_GATE {
findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
}
}
}
const MAX_EMAIL_LOCAL_LEN: usize = 64;
const MAX_EMAIL_DOMAIN_LEN: usize = 255;
fn bounded_token_end(text: &str, from: usize, max_len: usize) -> usize {
let mut end = from;
for (i, c) in text[from..].char_indices() {
if i >= max_len {
return from + i;
}
if c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}') {
return from + i;
}
end = from + i + c.len_utf8();
}
end
}
fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
let mut local_start = 0usize;
for (idx, c) in text.char_indices() {
if c == '@' {
if idx == local_start || idx - local_start > MAX_EMAIL_LOCAL_LEN {
continue;
}
let domain_start = idx + 1;
let domain_end = bounded_token_end(text, domain_start, MAX_EMAIL_DOMAIN_LEN);
let domain = &text[domain_start..domain_end];
if domain.contains('.') && domain.len() >= 3 {
findings.push(CredentialFinding::new(
CredentialKind::EmailAddress,
local_start,
domain_end,
));
}
continue;
}
if c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\'') {
local_start = idx + c.len_utf8();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_kind_as_str_round_trips() {
assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
}
#[test]
fn detects_anthropic_key() {
let scanner = CredentialScanner::new();
let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
}
#[test]
fn detects_openai_key_not_misclassified_as_anthropic() {
let scanner = CredentialScanner::new();
let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
}
#[test]
fn detects_aws_access_key() {
let scanner = CredentialScanner::new();
let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
}
#[test]
fn detects_gcp_service_account() {
let scanner = CredentialScanner::new();
let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GcpServiceAccount));
}
#[test]
fn detects_gcp_service_account_compact_json() {
let scanner = CredentialScanner::new();
let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GcpServiceAccount),
"compact GCP service-account JSON must be detected"
);
}
#[test]
fn detects_gcp_service_account_spaces_around_colon() {
let scanner = CredentialScanner::new();
let result = scanner.scan(r#"{ "type" : "service_account" }"#);
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GcpServiceAccount),
"spaced-colon GCP service-account JSON must be detected"
);
}
#[test]
fn detects_postgres_url_uppercase_scheme() {
let scanner = CredentialScanner::new();
let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
assert!(
result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
"upper-case POSTGRES:// scheme must be detected"
);
}
#[test]
fn detects_lowercase_pem_private_key_header() {
let scanner = CredentialScanner::new();
let result =
scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
assert!(
result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
"lower-case PEM header must be detected"
);
}
#[test]
fn detects_github_pat() {
let scanner = CredentialScanner::new();
let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
}
#[test]
fn detects_github_app_token() {
let scanner = CredentialScanner::new();
let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
}
#[test]
fn detects_slack_bot_token() {
let scanner = CredentialScanner::new();
let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
}
#[test]
fn detects_slack_user_token() {
let scanner = CredentialScanner::new();
let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
}
#[test]
fn detects_slack_oauth_token() {
let scanner = CredentialScanner::new();
let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::SlackOAuthToken));
}
#[test]
fn detects_azure_connection_string() {
let scanner = CredentialScanner::new();
let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::AzureConnectionString));
}
#[test]
fn redacts_azure_account_key_value_after_semicolons() {
let scanner = CredentialScanner::new();
let secret = "abc123DEF456ghi789JKL012mno345PQR678stu901VWX234yz==";
let input = format!(
"DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey={secret};EndpointSuffix=core.windows.net"
);
let redacted = scanner.scan(&input).redact(&input);
assert!(
!redacted.contains(secret),
"Azure AccountKey secret leaked past redaction: {redacted}"
);
assert!(
redacted.contains("[REDACTED:AzureConnectionString]"),
"expected an AzureConnectionString redaction label: {redacted}"
);
assert!(redacted.contains("EndpointSuffix=core.windows.net"));
}
#[test]
fn detects_postgres_url() {
let scanner = CredentialScanner::new();
let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
}
#[test]
fn detects_mysql_url() {
let scanner = CredentialScanner::new();
let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
}
#[test]
fn detects_mongodb_url() {
let scanner = CredentialScanner::new();
let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
}
#[test]
fn detects_rsa_private_key() {
let scanner = CredentialScanner::new();
let result =
scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
}
#[test]
fn detects_ec_private_key() {
let scanner = CredentialScanner::new();
let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
}
#[test]
fn detects_openssh_private_key() {
let scanner = CredentialScanner::new();
let result = scanner
.scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::OpensshPrivateKey));
}
#[test]
fn detects_generic_private_key() {
let scanner = CredentialScanner::new();
let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
}
#[test]
fn detects_pgp_private_key() {
let scanner = CredentialScanner::new();
let result =
scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
}
#[test]
fn detects_credit_card_luhn() {
let scanner = CredentialScanner::new();
let result = scanner.scan("card: 4532015112830366");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
}
#[test]
fn detects_credit_card_with_spaces() {
let scanner = CredentialScanner::new();
let result = scanner.scan("card: 4532 0151 1283 0366");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
}
#[test]
fn does_not_flag_invalid_luhn() {
let scanner = CredentialScanner::new();
let result = scanner.scan("num: 4532015112830367");
assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
}
#[test]
fn detects_ssn() {
let scanner = CredentialScanner::new();
let result = scanner.scan("SSN: 123-45-6789");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
}
#[test]
fn detects_email_address() {
let scanner = CredentialScanner::new();
let result = scanner.scan("contact: user@example.com for support");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
}
#[test]
fn detects_email_after_delimiter() {
let input = "mail to: <alice@example.org>";
let scanner = CredentialScanner::new();
let result = scanner.scan(input);
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::EmailAddress
&& input[f.offset..f.end].starts_with("alice@example.org"))
);
}
#[test]
fn email_scan_is_linear_on_pathological_at_run() {
let scanner = CredentialScanner::new();
let payload = "@".repeat(1_000_000);
let start = std::time::Instant::now();
let result = scanner.scan(&payload);
let elapsed = start.elapsed();
assert!(
!result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress),
"delimiter-free '@' run must not be flagged as an email",
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"email scan took {elapsed:?}; expected well under a second",
);
}
#[test]
fn email_scan_is_linear_on_alternating_at_run() {
let scanner = CredentialScanner::new();
let payload = "a@".repeat(500_000);
let start = std::time::Instant::now();
let _ = scanner.scan(&payload);
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"alternating '@' run must scan in linear time",
);
}
#[test]
fn detects_high_entropy_token() {
let scanner = CredentialScanner::new();
let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy));
}
#[test]
fn does_not_flag_short_token_as_high_entropy() {
let scanner = CredentialScanner::new();
let result = scanner.scan("word: hello");
assert!(!result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy));
}
#[test]
fn detects_64_char_lowercase_hex_secret() {
let scanner = CredentialScanner::new();
let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
assert_eq!(secret.len(), 64, "fixture must be exactly 64 hex chars");
let result = scanner.scan(&format!("token={secret}"));
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy),
"64-char hex secret must be flagged: {:?}",
result.findings
);
assert!(!scanner.scan(secret).is_clean());
}
#[test]
fn detects_base64_token_beyond_64_chars() {
let scanner = CredentialScanner::new();
let secret = "aGVsbG9Xb3JsZFRoaXNJc0FWZXJ5TG9uZ0Jhc2U2NFNlY3JldFRva2VuQmV5b25kU2l4dHlGb3VyQ2hhcnM5OQ";
assert!(secret.len() > 64, "fixture must exceed the old 64-char cap");
let result = scanner.scan(&format!("authorization: {secret}"));
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy),
">64-char base64 token must be flagged: {:?}",
result.findings
);
}
#[test]
fn branded_prefixes_still_flagged_after_rewrite() {
let scanner = CredentialScanner::new();
let result = scanner.scan("k=AKIAIOSFODNN7EXAMPLE p=ghp_0123456789abcdefghijklmnopqrstuvwxyz");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
}
#[test]
fn does_not_flag_benign_hex_ids_or_prose() {
let scanner = CredentialScanner::new();
let benign = [
"commit 0123456789abcdef0123456789abcdef01234567 fixed it",
"etag d41d8cd98f00b204e9800998ecf8427e cached",
"id 550e8400-e29b-41d4-a716-446655440000 ok",
"The quarterly report is ready for review by the team.",
"user id 42 logged in",
];
for text in &benign {
let result = scanner.scan(text);
assert!(
!result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy),
"benign text wrongly flagged: {:?} -> {:?}",
text,
result.findings
);
}
}
#[test]
fn redact_removes_long_hex_secret() {
let scanner = CredentialScanner::new();
let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
let text = format!(r#"{{"api_token":"{secret}"}}"#);
let result = scanner.scan(&text);
let redacted = result.redact(&text);
assert!(!redacted.contains(secret), "raw hex secret survived: {redacted}");
assert!(redacted.contains("[REDACTED:GenericHighEntropy]"));
}
#[test]
fn additive_passes_preserve_url_and_whole_blob_entropy_findings() {
let scanner = CredentialScanner::new();
let result = scanner.scan("MONGO_URI=mongodb://admin:pass@cluster0.mongodb.net/mydb");
assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
assert!(result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy && f.offset == 0));
}
#[test]
fn luhn_valid_visa_test_number() {
assert!(luhn_valid("4532015112830366"));
}
#[test]
fn luhn_valid_mastercard_test_number() {
assert!(luhn_valid("5425233430109903"));
}
#[test]
fn luhn_valid_amex_test_number() {
assert!(luhn_valid("371449635398431"));
}
#[test]
fn luhn_valid_discover_test_number() {
assert!(luhn_valid("6011111111111117"));
}
#[test]
fn luhn_invalid_altered_digit() {
assert!(!luhn_valid("4532015112830367"));
}
#[test]
fn luhn_rejects_too_short() {
assert!(!luhn_valid("123456789012"));
}
#[test]
fn luhn_rejects_too_long() {
assert!(!luhn_valid("45320151128303661234"));
}
#[test]
fn entropy_zero_for_empty() {
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn entropy_low_for_repeated_char() {
assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
}
#[test]
fn entropy_high_for_random_base64() {
assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
}
#[test]
fn entropy_moderate_for_english_text() {
let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
assert!(e > 3.0 && e < 5.0);
}
#[test]
fn redact_replaces_github_pat() {
let scanner = CredentialScanner::new();
let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert!(!redacted.contains("ghp_"));
assert!(redacted.contains("[REDACTED:GitHubPat]"));
}
#[test]
fn redact_is_deterministic() {
let scanner = CredentialScanner::new();
let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
let result = scanner.scan(text);
assert_eq!(result.redact(text), result.redact(text));
}
#[test]
fn redact_clean_text_unchanged() {
let scanner = CredentialScanner::new();
let text = "This is a normal sentence with no secrets.";
let result = scanner.scan(text);
assert!(result.is_clean());
assert_eq!(result.redact(text), text);
}
#[test]
fn redact_multiple_findings_in_one_pass() {
let scanner = CredentialScanner::new();
let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert!(!redacted.contains("ghp_"));
assert!(!redacted.contains("postgres://"));
assert!(redacted.contains("[REDACTED:GitHubPat]"));
assert!(redacted.contains("[REDACTED:PostgresUrl]"));
}
#[test]
fn is_clean_true_for_benign_text() {
let scanner = CredentialScanner::new();
assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
}
#[test]
fn redact_overlapping_findings_leaks_no_secret_fragment() {
let scanner = CredentialScanner::new();
let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
assert!(
!redacted.contains("stgresUrl"),
"mangled-splice secret fragment leaked: {redacted}"
);
assert!(redacted.contains("[REDACTED:"));
assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
let close = label.find(']').expect("redaction label must be closed");
let kind = &label["[REDACTED:".len()..close];
assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
}
}
#[test]
fn redact_overlap_at_multibyte_boundary_does_not_panic() {
let scanner = CredentialScanner::new();
let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
}
#[test]
fn redact_adjacent_overlapping_findings_merge_into_one_span() {
let scanner = CredentialScanner::new();
let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert!(!redacted.contains("ghp_"));
assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
assert!(
redacted.contains(" done"),
"trailing context must be preserved: {redacted}"
);
}
#[test]
fn coalesce_keeps_specific_kind_label_over_generic() {
let scanner = CredentialScanner::new();
let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
let result = scanner.scan(text);
assert!(
result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
"expected a GitHubPat finding: {:?}",
result.findings
);
assert!(
result
.findings
.iter()
.any(|f| f.kind == CredentialKind::GenericHighEntropy),
"expected a GenericHighEntropy finding: {:?}",
result.findings
);
let redacted = result.redact(text);
assert!(
redacted.contains("[REDACTED:GitHubPat]"),
"merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
);
assert!(
!redacted.contains("GenericHighEntropy"),
"generic backstop label must not win over a specific detector: {redacted}"
);
assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
}
#[test]
fn coalesce_keeps_db_url_label_over_embedded_email() {
let scanner = CredentialScanner::new();
let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
let result = scanner.scan(text);
let redacted = result.redact(text);
assert_eq!(
redacted, "[REDACTED:PostgresUrl]",
"db-url region must redact to the specific PostgresUrl label: {redacted}"
);
assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
}
#[test]
fn custom_kind_as_str_returns_custom() {
assert_eq!(CredentialKind::Custom.as_str(), "Custom");
}
#[test]
fn from_regex_match_creates_custom_finding() {
let finding = CredentialFinding::from_regex_match(5, 20);
assert_eq!(finding.kind, CredentialKind::Custom);
assert_eq!(finding.offset, 5);
assert_eq!(finding.matched, "[REDACTED:Custom]");
}
#[test]
fn false_positive_corpus_has_no_hard_credential_hits() {
let scanner = CredentialScanner::new();
let corpus = [
"The quick brown fox jumps over the lazy dog.",
"fn main() { println!(\"Hello, world!\"); }",
"SELECT * FROM users WHERE id = 42;",
"cargo build --release --features std",
"version = \"1.0.0\" edition = \"2021\"",
"2026-04-27T15:34:15.377+0800",
"error[E0382]: borrow of moved value: `x`",
];
for text in &corpus {
let result = scanner.scan(text);
let hard: Vec<_> = result
.findings
.iter()
.filter(|f| f.kind != CredentialKind::GenericHighEntropy)
.collect();
assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
}
}
#[test]
fn disabled_scanner_returns_empty_result() {
let config = ScannerConfig {
disabled: true,
..Default::default()
};
let scanner = CredentialScanner::with_config(config);
let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
assert!(result.is_clean(), "disabled scanner must return no findings");
}
#[test]
fn custom_pattern_detected_as_custom_kind() {
let config = ScannerConfig {
custom_patterns: vec!["INTERNAL_SECRET_".into()],
..Default::default()
};
let scanner = CredentialScanner::with_config(config);
let result = scanner.scan("token=INTERNAL_SECRET_hello");
let custom: Vec<_> = result
.findings
.iter()
.filter(|f| f.kind == CredentialKind::Custom)
.collect();
assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
assert!(custom[0].matched.contains("[REDACTED:Custom]"));
}
#[test]
fn custom_pattern_coexists_with_builtin() {
let config = ScannerConfig {
custom_patterns: vec!["MY_TOKEN_".into()],
..Default::default()
};
let scanner = CredentialScanner::with_config(config);
let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
let result = scanner.scan(text);
let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
assert!(kinds.contains(&&CredentialKind::GitHubPat));
assert!(kinds.contains(&&CredentialKind::Custom));
}
#[test]
fn default_config_matches_new() {
let default_scanner = CredentialScanner::new();
let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
let r1 = default_scanner.scan(text);
let r2 = config_scanner.scan(text);
assert_eq!(r1.findings.len(), r2.findings.len());
for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
assert_eq!(a.kind, b.kind);
assert_eq!(a.offset, b.offset);
}
}
}