1use aho_corasick::AhoCorasick;
8
9const AC_PATTERNS: &[&str] = &[
15 "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\"", ];
41
42const AC_KINDS: &[CredentialKind] = &[
44 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, ];
66
67#[derive(Debug, Clone, PartialEq, Eq)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub enum CredentialKind {
75 AnthropicKey,
78 AwsAccessKey,
80 GcpServiceAccount,
82 OpenAiKey,
84 AzureConnectionString,
87 GitHubAppToken,
90 GitHubPat,
92 SlackBotToken,
94 SlackOAuthToken,
96 SlackUserToken,
98 MongodbUrl,
101 MysqlUrl,
103 PostgresUrl,
105 EcPrivateKey,
108 OpensshPrivateKey,
110 PgpPrivateKey,
112 PrivateKey,
114 RsaPrivateKey,
116 CreditCardLuhn,
119 EmailAddress,
121 SsnPattern,
123 GenericHighEntropy,
128 Custom,
131}
132
133impl CredentialKind {
134 pub fn as_str(&self) -> &'static str {
136 match self {
137 Self::AnthropicKey => "AnthropicKey",
138 Self::AwsAccessKey => "AwsAccessKey",
139 Self::AzureConnectionString => "AzureConnectionString",
140 Self::CreditCardLuhn => "CreditCardLuhn",
141 Self::EcPrivateKey => "EcPrivateKey",
142 Self::EmailAddress => "EmailAddress",
143 Self::GcpServiceAccount => "GcpServiceAccount",
144 Self::GenericHighEntropy => "GenericHighEntropy",
145 Self::GitHubAppToken => "GitHubAppToken",
146 Self::GitHubPat => "GitHubPat",
147 Self::MongodbUrl => "MongodbUrl",
148 Self::MysqlUrl => "MysqlUrl",
149 Self::OpenAiKey => "OpenAiKey",
150 Self::OpensshPrivateKey => "OpensshPrivateKey",
151 Self::PgpPrivateKey => "PgpPrivateKey",
152 Self::PostgresUrl => "PostgresUrl",
153 Self::PrivateKey => "PrivateKey",
154 Self::RsaPrivateKey => "RsaPrivateKey",
155 Self::SlackBotToken => "SlackBotToken",
156 Self::SlackOAuthToken => "SlackOAuthToken",
157 Self::SlackUserToken => "SlackUserToken",
158 Self::SsnPattern => "SsnPattern",
159 Self::Custom => "Custom",
160 }
161 }
162
163 fn priority(&self) -> u8 {
174 match self {
175 Self::GenericHighEntropy => 0,
177 Self::EmailAddress => 1,
178 Self::AnthropicKey
181 | Self::AwsAccessKey
182 | Self::AzureConnectionString
183 | Self::CreditCardLuhn
184 | Self::EcPrivateKey
185 | Self::GcpServiceAccount
186 | Self::GitHubAppToken
187 | Self::GitHubPat
188 | Self::MongodbUrl
189 | Self::MysqlUrl
190 | Self::OpenAiKey
191 | Self::OpensshPrivateKey
192 | Self::PgpPrivateKey
193 | Self::PostgresUrl
194 | Self::PrivateKey
195 | Self::RsaPrivateKey
196 | Self::SlackBotToken
197 | Self::SlackOAuthToken
198 | Self::SlackUserToken
199 | Self::SsnPattern
200 | Self::Custom => 2,
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215pub struct CredentialFinding {
216 pub kind: CredentialKind,
218 pub offset: usize,
220 pub matched: String,
222 #[cfg_attr(feature = "serde", serde(skip))]
223 end: usize,
224}
225
226impl CredentialFinding {
227 fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
228 let label = format!("[REDACTED:{}]", kind.as_str());
229 Self {
230 kind,
231 offset,
232 matched: label,
233 end,
234 }
235 }
236
237 pub fn from_regex_match(offset: usize, end: usize) -> Self {
242 Self::new(CredentialKind::Custom, offset, end)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
249pub struct ScanResult {
250 pub findings: Vec<CredentialFinding>,
252}
253
254impl ScanResult {
255 pub fn is_clean(&self) -> bool {
257 self.findings.is_empty()
258 }
259
260 pub fn redact(&self, text: &str) -> String {
270 let merged = coalesce_findings(&self.findings);
271 let mut result = text.to_string();
272 for span in merged.iter().rev() {
274 if span.end <= result.len()
275 && span.offset <= span.end
276 && result.is_char_boundary(span.offset)
277 && result.is_char_boundary(span.end)
278 {
279 result.replace_range(span.offset..span.end, &span.label);
280 }
281 }
282 result
283 }
284}
285
286#[derive(Debug, Clone, Default)]
291pub struct ScannerConfig {
292 pub disabled: bool,
295 pub custom_patterns: Vec<String>,
299}
300
301pub struct CredentialScanner {
307 patterns: AhoCorasick,
308 kinds: Vec<CredentialKind>,
312 disabled: bool,
314}
315
316impl Default for CredentialScanner {
317 fn default() -> Self {
318 Self::new()
319 }
320}
321
322impl CredentialScanner {
323 pub fn new() -> Self {
330 Self::with_config(ScannerConfig::default())
331 }
332
333 pub fn with_config(config: ScannerConfig) -> Self {
339 let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
340 let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
342 all_patterns.extend_from_slice(&custom_refs);
343
344 let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
345 kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
346
347 let ac = AhoCorasick::builder()
348 .match_kind(aho_corasick::MatchKind::LeftmostFirst)
349 .ascii_case_insensitive(true)
354 .build(&all_patterns)
355 .expect("AC patterns are always valid");
356
357 Self {
358 patterns: ac,
359 kinds,
360 disabled: config.disabled,
361 }
362 }
363
364 pub fn scan(&self, text: &str) -> ScanResult {
375 if self.disabled {
376 return ScanResult { findings: Vec::new() };
377 }
378
379 let mut findings = Vec::new();
380
381 for mat in self.patterns.find_iter(text) {
384 let kind = self.kinds[mat.pattern()].clone();
385 let offset = mat.start();
386 let end = token_end(text, mat.end());
387 findings.push(CredentialFinding::new(kind, offset, end));
388 }
389
390 scan_digit_sequences(text, &mut findings);
392
393 scan_emails(text, &mut findings);
395
396 scan_high_entropy(text, &mut findings);
398
399 scan_azure_account_key(text, &mut findings);
402
403 findings.sort_by_key(|f| f.offset);
404 ScanResult { findings }
405 }
406}
407
408struct MergedSpan {
414 offset: usize,
415 end: usize,
416 label: String,
417 kind: CredentialKind,
421}
422
423fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
435 let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
436 sorted.sort_by_key(|f| (f.offset, f.end));
437
438 let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
439 for f in sorted {
440 match merged.last_mut() {
441 Some(last) if f.offset < last.end => {
444 last.end = last.end.max(f.end);
445 if f.kind.priority() > last.kind.priority() {
446 last.label = f.matched.clone();
447 last.kind = f.kind.clone();
448 }
449 }
450 _ => merged.push(MergedSpan {
451 offset: f.offset,
452 end: f.end,
453 label: f.matched.clone(),
454 kind: f.kind.clone(),
455 }),
456 }
457 }
458 merged
459}
460
461fn scan_azure_account_key(text: &str, findings: &mut Vec<CredentialFinding>) {
472 const MARKER: &str = "AccountKey=";
473 let mut search_from = 0;
474 while let Some(rel) = text[search_from..].find(MARKER) {
475 let offset = search_from + rel;
476 let value_start = offset + MARKER.len();
477 let end = text[value_start..]
480 .find(|c: char| c.is_whitespace() || matches!(c, ';' | '"' | '\'' | ',' | ')' | ']' | '}'))
481 .map(|i| value_start + i)
482 .unwrap_or(text.len());
483 findings.push(CredentialFinding::new(
484 CredentialKind::AzureConnectionString,
485 offset,
486 end,
487 ));
488 search_from = end.max(value_start);
490 }
491}
492
493fn token_end(text: &str, from: usize) -> usize {
496 text[from..]
497 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
498 .map(|i| from + i)
499 .unwrap_or(text.len())
500}
501
502fn is_ssn(s: &str) -> bool {
504 let b = s.as_bytes();
505 b.len() == 11
506 && b[0..3].iter().all(u8::is_ascii_digit)
507 && b[3] == b'-'
508 && b[4..6].iter().all(u8::is_ascii_digit)
509 && b[6] == b'-'
510 && b[7..11].iter().all(u8::is_ascii_digit)
511}
512
513fn luhn_valid(digits: &str) -> bool {
516 if digits.len() < 13 || digits.len() > 19 {
517 return false;
518 }
519 let mut sum = 0u32;
520 let mut double = false;
521 for ch in digits.chars().rev() {
522 let Some(d) = ch.to_digit(10) else {
523 return false;
524 };
525 let val = if double {
526 let v = d * 2;
527 if v > 9 {
528 v - 9
529 } else {
530 v
531 }
532 } else {
533 d
534 };
535 sum += val;
536 double = !double;
537 }
538 sum % 10 == 0
539}
540
541fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
543 let bytes = text.as_bytes();
544 let mut i = 0;
545 while i < bytes.len() {
546 if !bytes[i].is_ascii_digit() {
547 i += 1;
548 continue;
549 }
550
551 let start = i;
552 let mut digits = String::new();
553 let mut j = i;
554 let limit = (start + 24).min(bytes.len());
555
556 while j < limit {
557 match bytes[j] {
558 b if b.is_ascii_digit() => {
559 digits.push(b as char);
560 j += 1;
561 }
562 b' ' | b'-' if !digits.is_empty() => {
563 j += 1;
564 }
565 _ => break,
566 }
567 }
568
569 let end = j;
570 let segment = &text[start..end];
571
572 if is_ssn(segment) {
573 findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
574 } else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
575 findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
576 }
577 i = end.max(i + 1);
578 }
579}
580
581fn shannon_entropy(s: &str) -> f64 {
583 if s.is_empty() {
584 return 0.0;
585 }
586 let mut freq = [0u32; 256];
587 for &b in s.as_bytes() {
588 freq[b as usize] += 1;
589 }
590 let len = s.len() as f64;
591 freq.iter()
592 .filter(|&&c| c > 0)
593 .map(|&c| {
594 let p = c as f64 / len;
595 -p * p.log2()
596 })
597 .sum()
598}
599
600const ENTROPY_BITS_GATE: f64 = 4.5;
608
609const HEX_RUN_MIN_LEN: usize = 64;
620
621const BASE64_RUN_MIN_LEN: usize = 64;
630
631fn is_base64_char(b: u8) -> bool {
634 b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'-' | b'_')
635}
636
637fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
651 let mut offset = 0usize;
653 for token in text.split_whitespace() {
654 let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
655 let whitespace_end = token_offset + token.len();
656 let len = token.len();
657 if (20..=64).contains(&len) && shannon_entropy(token) > ENTROPY_BITS_GATE {
658 let end = token_end(text, token_offset);
664 findings.push(CredentialFinding::new(
665 CredentialKind::GenericHighEntropy,
666 token_offset,
667 end,
668 ));
669 }
670 offset = whitespace_end;
671 }
672
673 scan_long_hex_runs(text, findings);
675 scan_long_base64_runs(text, findings);
676}
677
678fn scan_long_hex_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
680 let bytes = text.as_bytes();
681 let mut i = 0;
682 while i < bytes.len() {
683 if !bytes[i].is_ascii_hexdigit() {
684 i += 1;
685 continue;
686 }
687 let start = i;
688 while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
689 i += 1;
690 }
691 if i - start >= HEX_RUN_MIN_LEN {
692 findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
693 }
694 }
695}
696
697fn scan_long_base64_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
700 let bytes = text.as_bytes();
701 let mut i = 0;
702 while i < bytes.len() {
703 if !is_base64_char(bytes[i]) {
704 i += 1;
705 continue;
706 }
707 let start = i;
708 while i < bytes.len() && is_base64_char(bytes[i]) {
709 i += 1;
710 }
711 let run = &text[start..i];
712 if run.len() > BASE64_RUN_MIN_LEN && shannon_entropy(run) > ENTROPY_BITS_GATE {
713 findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
714 }
715 }
716}
717
718const MAX_EMAIL_LOCAL_LEN: usize = 64;
722
723const MAX_EMAIL_DOMAIN_LEN: usize = 255;
727
728fn bounded_token_end(text: &str, from: usize, max_len: usize) -> usize {
732 let mut end = from;
733 for (i, c) in text[from..].char_indices() {
734 if i >= max_len {
735 return from + i;
736 }
737 if c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}') {
738 return from + i;
739 }
740 end = from + i + c.len_utf8();
741 }
742 end
743}
744
745fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
753 let mut local_start = 0usize;
757
758 for (idx, c) in text.char_indices() {
759 if c == '@' {
760 if idx == local_start || idx - local_start > MAX_EMAIL_LOCAL_LEN {
763 continue;
764 }
765
766 let domain_start = idx + 1;
767 let domain_end = bounded_token_end(text, domain_start, MAX_EMAIL_DOMAIN_LEN);
768 let domain = &text[domain_start..domain_end];
769
770 if domain.contains('.') && domain.len() >= 3 {
771 findings.push(CredentialFinding::new(
772 CredentialKind::EmailAddress,
773 local_start,
774 domain_end,
775 ));
776 }
777 continue;
778 }
779
780 if c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\'') {
781 local_start = idx + c.len_utf8();
782 }
783 }
784}
785
786#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
797 fn credential_kind_as_str_round_trips() {
798 assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
799 assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
800 assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
801 }
802
803 #[test]
806 fn detects_anthropic_key() {
807 let scanner = CredentialScanner::new();
808 let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
809 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
810 }
811
812 #[test]
813 fn detects_openai_key_not_misclassified_as_anthropic() {
814 let scanner = CredentialScanner::new();
815 let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
816 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
817 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
818 }
819
820 #[test]
821 fn detects_aws_access_key() {
822 let scanner = CredentialScanner::new();
823 let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
824 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
825 }
826
827 #[test]
828 fn detects_gcp_service_account() {
829 let scanner = CredentialScanner::new();
830 let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
831 assert!(result
832 .findings
833 .iter()
834 .any(|f| f.kind == CredentialKind::GcpServiceAccount));
835 }
836
837 #[test]
840 fn detects_gcp_service_account_compact_json() {
841 let scanner = CredentialScanner::new();
843 let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
844 assert!(
845 result
846 .findings
847 .iter()
848 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
849 "compact GCP service-account JSON must be detected"
850 );
851 }
852
853 #[test]
854 fn detects_gcp_service_account_spaces_around_colon() {
855 let scanner = CredentialScanner::new();
856 let result = scanner.scan(r#"{ "type" : "service_account" }"#);
857 assert!(
858 result
859 .findings
860 .iter()
861 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
862 "spaced-colon GCP service-account JSON must be detected"
863 );
864 }
865
866 #[test]
867 fn detects_postgres_url_uppercase_scheme() {
868 let scanner = CredentialScanner::new();
870 let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
871 assert!(
872 result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
873 "upper-case POSTGRES:// scheme must be detected"
874 );
875 }
876
877 #[test]
878 fn detects_lowercase_pem_private_key_header() {
879 let scanner = CredentialScanner::new();
880 let result =
881 scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
882 assert!(
883 result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
884 "lower-case PEM header must be detected"
885 );
886 }
887
888 #[test]
891 fn detects_github_pat() {
892 let scanner = CredentialScanner::new();
893 let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
894 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
895 }
896
897 #[test]
898 fn detects_github_app_token() {
899 let scanner = CredentialScanner::new();
900 let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
901 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
902 }
903
904 #[test]
905 fn detects_slack_bot_token() {
906 let scanner = CredentialScanner::new();
907 let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
908 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
909 }
910
911 #[test]
912 fn detects_slack_user_token() {
913 let scanner = CredentialScanner::new();
914 let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
915 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
916 }
917
918 #[test]
919 fn detects_slack_oauth_token() {
920 let scanner = CredentialScanner::new();
921 let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
922 assert!(result
923 .findings
924 .iter()
925 .any(|f| f.kind == CredentialKind::SlackOAuthToken));
926 }
927
928 #[test]
931 fn detects_azure_connection_string() {
932 let scanner = CredentialScanner::new();
933 let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
934 assert!(result
935 .findings
936 .iter()
937 .any(|f| f.kind == CredentialKind::AzureConnectionString));
938 }
939
940 #[test]
941 fn redacts_azure_account_key_value_after_semicolons() {
942 let scanner = CredentialScanner::new();
947 let secret = "abc123DEF456ghi789JKL012mno345PQR678stu901VWX234yz==";
948 let input = format!(
949 "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey={secret};EndpointSuffix=core.windows.net"
950 );
951 let redacted = scanner.scan(&input).redact(&input);
952 assert!(
953 !redacted.contains(secret),
954 "Azure AccountKey secret leaked past redaction: {redacted}"
955 );
956 assert!(
957 redacted.contains("[REDACTED:AzureConnectionString]"),
958 "expected an AzureConnectionString redaction label: {redacted}"
959 );
960 assert!(redacted.contains("EndpointSuffix=core.windows.net"));
962 }
963
964 #[test]
967 fn detects_postgres_url() {
968 let scanner = CredentialScanner::new();
969 let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
970 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
971 }
972
973 #[test]
974 fn detects_mysql_url() {
975 let scanner = CredentialScanner::new();
976 let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
977 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
978 }
979
980 #[test]
981 fn detects_mongodb_url() {
982 let scanner = CredentialScanner::new();
983 let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
984 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
985 }
986
987 #[test]
990 fn detects_rsa_private_key() {
991 let scanner = CredentialScanner::new();
992 let result =
993 scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
994 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
995 }
996
997 #[test]
998 fn detects_ec_private_key() {
999 let scanner = CredentialScanner::new();
1000 let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
1001 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
1002 }
1003
1004 #[test]
1005 fn detects_openssh_private_key() {
1006 let scanner = CredentialScanner::new();
1007 let result = scanner
1008 .scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
1009 assert!(result
1010 .findings
1011 .iter()
1012 .any(|f| f.kind == CredentialKind::OpensshPrivateKey));
1013 }
1014
1015 #[test]
1016 fn detects_generic_private_key() {
1017 let scanner = CredentialScanner::new();
1018 let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
1019 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
1020 }
1021
1022 #[test]
1023 fn detects_pgp_private_key() {
1024 let scanner = CredentialScanner::new();
1025 let result =
1026 scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
1027 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
1028 }
1029
1030 #[test]
1033 fn detects_credit_card_luhn() {
1034 let scanner = CredentialScanner::new();
1035 let result = scanner.scan("card: 4532015112830366");
1036 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1037 }
1038
1039 #[test]
1040 fn detects_credit_card_with_spaces() {
1041 let scanner = CredentialScanner::new();
1042 let result = scanner.scan("card: 4532 0151 1283 0366");
1043 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1044 }
1045
1046 #[test]
1047 fn does_not_flag_invalid_luhn() {
1048 let scanner = CredentialScanner::new();
1049 let result = scanner.scan("num: 4532015112830367");
1050 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1051 }
1052
1053 #[test]
1054 fn detects_ssn() {
1055 let scanner = CredentialScanner::new();
1056 let result = scanner.scan("SSN: 123-45-6789");
1057 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
1058 }
1059
1060 #[test]
1061 fn detects_email_address() {
1062 let scanner = CredentialScanner::new();
1063 let result = scanner.scan("contact: user@example.com for support");
1064 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
1065 }
1066
1067 #[test]
1068 fn detects_email_after_delimiter() {
1069 let input = "mail to: <alice@example.org>";
1072 let scanner = CredentialScanner::new();
1073 let result = scanner.scan(input);
1074 assert!(
1076 result
1077 .findings
1078 .iter()
1079 .any(|f| f.kind == CredentialKind::EmailAddress
1080 && input[f.offset..f.end].starts_with("alice@example.org"))
1081 );
1082 }
1083
1084 #[test]
1085 fn email_scan_is_linear_on_pathological_at_run() {
1086 let scanner = CredentialScanner::new();
1091 let payload = "@".repeat(1_000_000);
1092
1093 let start = std::time::Instant::now();
1094 let result = scanner.scan(&payload);
1095 let elapsed = start.elapsed();
1096
1097 assert!(
1098 !result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress),
1099 "delimiter-free '@' run must not be flagged as an email",
1100 );
1101 assert!(
1102 elapsed < std::time::Duration::from_secs(1),
1103 "email scan took {elapsed:?}; expected well under a second",
1104 );
1105 }
1106
1107 #[test]
1108 fn email_scan_is_linear_on_alternating_at_run() {
1109 let scanner = CredentialScanner::new();
1111 let payload = "a@".repeat(500_000);
1112
1113 let start = std::time::Instant::now();
1114 let _ = scanner.scan(&payload);
1115 assert!(
1116 start.elapsed() < std::time::Duration::from_secs(1),
1117 "alternating '@' run must scan in linear time",
1118 );
1119 }
1120
1121 #[test]
1124 fn detects_high_entropy_token() {
1125 let scanner = CredentialScanner::new();
1126 let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
1127 assert!(result
1128 .findings
1129 .iter()
1130 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1131 }
1132
1133 #[test]
1134 fn does_not_flag_short_token_as_high_entropy() {
1135 let scanner = CredentialScanner::new();
1136 let result = scanner.scan("word: hello");
1137 assert!(!result
1138 .findings
1139 .iter()
1140 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1141 }
1142
1143 #[test]
1149 fn detects_64_char_lowercase_hex_secret() {
1150 let scanner = CredentialScanner::new();
1151 let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1153 assert_eq!(secret.len(), 64, "fixture must be exactly 64 hex chars");
1154 let result = scanner.scan(&format!("token={secret}"));
1155 assert!(
1156 result
1157 .findings
1158 .iter()
1159 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1160 "64-char hex secret must be flagged: {:?}",
1161 result.findings
1162 );
1163 assert!(!scanner.scan(secret).is_clean());
1164 }
1165
1166 #[test]
1169 fn detects_base64_token_beyond_64_chars() {
1170 let scanner = CredentialScanner::new();
1171 let secret = "aGVsbG9Xb3JsZFRoaXNJc0FWZXJ5TG9uZ0Jhc2U2NFNlY3JldFRva2VuQmV5b25kU2l4dHlGb3VyQ2hhcnM5OQ";
1173 assert!(secret.len() > 64, "fixture must exceed the old 64-char cap");
1174 let result = scanner.scan(&format!("authorization: {secret}"));
1175 assert!(
1176 result
1177 .findings
1178 .iter()
1179 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1180 ">64-char base64 token must be flagged: {:?}",
1181 result.findings
1182 );
1183 }
1184
1185 #[test]
1188 fn branded_prefixes_still_flagged_after_rewrite() {
1189 let scanner = CredentialScanner::new();
1190 let result = scanner.scan("k=AKIAIOSFODNN7EXAMPLE p=ghp_0123456789abcdefghijklmnopqrstuvwxyz");
1191 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
1192 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
1193 }
1194
1195 #[test]
1199 fn does_not_flag_benign_hex_ids_or_prose() {
1200 let scanner = CredentialScanner::new();
1201 let benign = [
1202 "commit 0123456789abcdef0123456789abcdef01234567 fixed it",
1204 "etag d41d8cd98f00b204e9800998ecf8427e cached",
1206 "id 550e8400-e29b-41d4-a716-446655440000 ok",
1208 "The quarterly report is ready for review by the team.",
1210 "user id 42 logged in",
1211 ];
1212 for text in &benign {
1213 let result = scanner.scan(text);
1214 assert!(
1215 !result
1216 .findings
1217 .iter()
1218 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1219 "benign text wrongly flagged: {:?} -> {:?}",
1220 text,
1221 result.findings
1222 );
1223 }
1224 }
1225
1226 #[test]
1229 fn redact_removes_long_hex_secret() {
1230 let scanner = CredentialScanner::new();
1231 let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1232 let text = format!(r#"{{"api_token":"{secret}"}}"#);
1233 let result = scanner.scan(&text);
1234 let redacted = result.redact(&text);
1235 assert!(!redacted.contains(secret), "raw hex secret survived: {redacted}");
1236 assert!(redacted.contains("[REDACTED:GenericHighEntropy]"));
1237 }
1238
1239 #[test]
1244 fn additive_passes_preserve_url_and_whole_blob_entropy_findings() {
1245 let scanner = CredentialScanner::new();
1246 let result = scanner.scan("MONGO_URI=mongodb://admin:pass@cluster0.mongodb.net/mydb");
1247 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
1248 assert!(result
1249 .findings
1250 .iter()
1251 .any(|f| f.kind == CredentialKind::GenericHighEntropy && f.offset == 0));
1252 }
1253
1254 #[test]
1257 fn luhn_valid_visa_test_number() {
1258 assert!(luhn_valid("4532015112830366"));
1259 }
1260
1261 #[test]
1262 fn luhn_valid_mastercard_test_number() {
1263 assert!(luhn_valid("5425233430109903"));
1264 }
1265
1266 #[test]
1267 fn luhn_valid_amex_test_number() {
1268 assert!(luhn_valid("371449635398431"));
1269 }
1270
1271 #[test]
1272 fn luhn_valid_discover_test_number() {
1273 assert!(luhn_valid("6011111111111117"));
1274 }
1275
1276 #[test]
1277 fn luhn_invalid_altered_digit() {
1278 assert!(!luhn_valid("4532015112830367"));
1279 }
1280
1281 #[test]
1282 fn luhn_rejects_too_short() {
1283 assert!(!luhn_valid("123456789012"));
1284 }
1285
1286 #[test]
1287 fn luhn_rejects_too_long() {
1288 assert!(!luhn_valid("45320151128303661234"));
1289 }
1290
1291 #[test]
1294 fn entropy_zero_for_empty() {
1295 assert_eq!(shannon_entropy(""), 0.0);
1296 }
1297
1298 #[test]
1299 fn entropy_low_for_repeated_char() {
1300 assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
1301 }
1302
1303 #[test]
1304 fn entropy_high_for_random_base64() {
1305 assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
1306 }
1307
1308 #[test]
1309 fn entropy_moderate_for_english_text() {
1310 let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
1311 assert!(e > 3.0 && e < 5.0);
1312 }
1313
1314 #[test]
1317 fn redact_replaces_github_pat() {
1318 let scanner = CredentialScanner::new();
1319 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
1320 let result = scanner.scan(text);
1321 let redacted = result.redact(text);
1322 assert!(!redacted.contains("ghp_"));
1323 assert!(redacted.contains("[REDACTED:GitHubPat]"));
1324 }
1325
1326 #[test]
1327 fn redact_is_deterministic() {
1328 let scanner = CredentialScanner::new();
1329 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1330 let result = scanner.scan(text);
1331 assert_eq!(result.redact(text), result.redact(text));
1332 }
1333
1334 #[test]
1335 fn redact_clean_text_unchanged() {
1336 let scanner = CredentialScanner::new();
1337 let text = "This is a normal sentence with no secrets.";
1338 let result = scanner.scan(text);
1339 assert!(result.is_clean());
1340 assert_eq!(result.redact(text), text);
1341 }
1342
1343 #[test]
1344 fn redact_multiple_findings_in_one_pass() {
1345 let scanner = CredentialScanner::new();
1346 let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
1347 let result = scanner.scan(text);
1348 let redacted = result.redact(text);
1349 assert!(!redacted.contains("ghp_"));
1350 assert!(!redacted.contains("postgres://"));
1351 assert!(redacted.contains("[REDACTED:GitHubPat]"));
1352 assert!(redacted.contains("[REDACTED:PostgresUrl]"));
1353 }
1354
1355 #[test]
1356 fn is_clean_true_for_benign_text() {
1357 let scanner = CredentialScanner::new();
1358 assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
1359 }
1360
1361 #[test]
1364 fn redact_overlapping_findings_leaks_no_secret_fragment() {
1365 let scanner = CredentialScanner::new();
1370 let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
1371 let result = scanner.scan(text);
1372 let redacted = result.redact(text);
1373
1374 assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
1376 assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
1377 assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
1378 assert!(
1379 !redacted.contains("stgresUrl"),
1380 "mangled-splice secret fragment leaked: {redacted}"
1381 );
1382 assert!(redacted.contains("[REDACTED:"));
1384 assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
1385 for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
1388 let close = label.find(']').expect("redaction label must be closed");
1389 let kind = &label["[REDACTED:".len()..close];
1390 assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
1391 }
1392 }
1393
1394 #[test]
1395 fn redact_overlap_at_multibyte_boundary_does_not_panic() {
1396 let scanner = CredentialScanner::new();
1400 let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
1401 let result = scanner.scan(text);
1402 let redacted = result.redact(text);
1404 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1405 }
1406
1407 #[test]
1408 fn redact_adjacent_overlapping_findings_merge_into_one_span() {
1409 let scanner = CredentialScanner::new();
1412 let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
1413 let result = scanner.scan(text);
1414 let redacted = result.redact(text);
1415 assert!(!redacted.contains("ghp_"));
1416 assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
1417 assert!(
1418 redacted.contains(" done"),
1419 "trailing context must be preserved: {redacted}"
1420 );
1421 }
1422
1423 #[test]
1424 fn coalesce_keeps_specific_kind_label_over_generic() {
1425 let scanner = CredentialScanner::new();
1430 let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
1431 let result = scanner.scan(text);
1432 assert!(
1434 result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
1435 "expected a GitHubPat finding: {:?}",
1436 result.findings
1437 );
1438 assert!(
1439 result
1440 .findings
1441 .iter()
1442 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1443 "expected a GenericHighEntropy finding: {:?}",
1444 result.findings
1445 );
1446 let redacted = result.redact(text);
1447 assert!(
1448 redacted.contains("[REDACTED:GitHubPat]"),
1449 "merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
1450 );
1451 assert!(
1452 !redacted.contains("GenericHighEntropy"),
1453 "generic backstop label must not win over a specific detector: {redacted}"
1454 );
1455 assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
1456 }
1457
1458 #[test]
1459 fn coalesce_keeps_db_url_label_over_embedded_email() {
1460 let scanner = CredentialScanner::new();
1464 let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
1465 let result = scanner.scan(text);
1466 let redacted = result.redact(text);
1467 assert_eq!(
1468 redacted, "[REDACTED:PostgresUrl]",
1469 "db-url region must redact to the specific PostgresUrl label: {redacted}"
1470 );
1471 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1472 }
1473
1474 #[test]
1477 fn custom_kind_as_str_returns_custom() {
1478 assert_eq!(CredentialKind::Custom.as_str(), "Custom");
1479 }
1480
1481 #[test]
1482 fn from_regex_match_creates_custom_finding() {
1483 let finding = CredentialFinding::from_regex_match(5, 20);
1484 assert_eq!(finding.kind, CredentialKind::Custom);
1485 assert_eq!(finding.offset, 5);
1486 assert_eq!(finding.matched, "[REDACTED:Custom]");
1487 }
1488
1489 #[test]
1492 fn false_positive_corpus_has_no_hard_credential_hits() {
1493 let scanner = CredentialScanner::new();
1494 let corpus = [
1495 "The quick brown fox jumps over the lazy dog.",
1496 "fn main() { println!(\"Hello, world!\"); }",
1497 "SELECT * FROM users WHERE id = 42;",
1498 "cargo build --release --features std",
1499 "version = \"1.0.0\" edition = \"2021\"",
1500 "2026-04-27T15:34:15.377+0800",
1501 "error[E0382]: borrow of moved value: `x`",
1502 ];
1503 for text in &corpus {
1504 let result = scanner.scan(text);
1505 let hard: Vec<_> = result
1506 .findings
1507 .iter()
1508 .filter(|f| f.kind != CredentialKind::GenericHighEntropy)
1509 .collect();
1510 assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
1511 }
1512 }
1513
1514 #[test]
1517 fn disabled_scanner_returns_empty_result() {
1518 let config = ScannerConfig {
1519 disabled: true,
1520 ..Default::default()
1521 };
1522 let scanner = CredentialScanner::with_config(config);
1523 let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
1524 assert!(result.is_clean(), "disabled scanner must return no findings");
1525 }
1526
1527 #[test]
1528 fn custom_pattern_detected_as_custom_kind() {
1529 let config = ScannerConfig {
1530 custom_patterns: vec!["INTERNAL_SECRET_".into()],
1531 ..Default::default()
1532 };
1533 let scanner = CredentialScanner::with_config(config);
1534 let result = scanner.scan("token=INTERNAL_SECRET_hello");
1535 let custom: Vec<_> = result
1536 .findings
1537 .iter()
1538 .filter(|f| f.kind == CredentialKind::Custom)
1539 .collect();
1540 assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
1541 assert!(custom[0].matched.contains("[REDACTED:Custom]"));
1542 }
1543
1544 #[test]
1545 fn custom_pattern_coexists_with_builtin() {
1546 let config = ScannerConfig {
1547 custom_patterns: vec!["MY_TOKEN_".into()],
1548 ..Default::default()
1549 };
1550 let scanner = CredentialScanner::with_config(config);
1551 let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
1552 let result = scanner.scan(text);
1553 let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
1554 assert!(kinds.contains(&&CredentialKind::GitHubPat));
1555 assert!(kinds.contains(&&CredentialKind::Custom));
1556 }
1557
1558 #[test]
1559 fn default_config_matches_new() {
1560 let default_scanner = CredentialScanner::new();
1561 let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
1562 let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
1563 let r1 = default_scanner.scan(text);
1564 let r2 = config_scanner.scan(text);
1565 assert_eq!(r1.findings.len(), r2.findings.len());
1566 for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
1567 assert_eq!(a.kind, b.kind);
1568 assert_eq!(a.offset, b.offset);
1569 }
1570 }
1571}