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,
126 Custom,
129}
130
131impl CredentialKind {
132 pub fn as_str(&self) -> &'static str {
134 match self {
135 Self::AnthropicKey => "AnthropicKey",
136 Self::AwsAccessKey => "AwsAccessKey",
137 Self::AzureConnectionString => "AzureConnectionString",
138 Self::CreditCardLuhn => "CreditCardLuhn",
139 Self::EcPrivateKey => "EcPrivateKey",
140 Self::EmailAddress => "EmailAddress",
141 Self::GcpServiceAccount => "GcpServiceAccount",
142 Self::GenericHighEntropy => "GenericHighEntropy",
143 Self::GitHubAppToken => "GitHubAppToken",
144 Self::GitHubPat => "GitHubPat",
145 Self::MongodbUrl => "MongodbUrl",
146 Self::MysqlUrl => "MysqlUrl",
147 Self::OpenAiKey => "OpenAiKey",
148 Self::OpensshPrivateKey => "OpensshPrivateKey",
149 Self::PgpPrivateKey => "PgpPrivateKey",
150 Self::PostgresUrl => "PostgresUrl",
151 Self::PrivateKey => "PrivateKey",
152 Self::RsaPrivateKey => "RsaPrivateKey",
153 Self::SlackBotToken => "SlackBotToken",
154 Self::SlackOAuthToken => "SlackOAuthToken",
155 Self::SlackUserToken => "SlackUserToken",
156 Self::SsnPattern => "SsnPattern",
157 Self::Custom => "Custom",
158 }
159 }
160
161 fn priority(&self) -> u8 {
172 match self {
173 Self::GenericHighEntropy => 0,
175 Self::EmailAddress => 1,
176 Self::AnthropicKey
179 | Self::AwsAccessKey
180 | Self::AzureConnectionString
181 | Self::CreditCardLuhn
182 | Self::EcPrivateKey
183 | Self::GcpServiceAccount
184 | Self::GitHubAppToken
185 | Self::GitHubPat
186 | Self::MongodbUrl
187 | Self::MysqlUrl
188 | Self::OpenAiKey
189 | Self::OpensshPrivateKey
190 | Self::PgpPrivateKey
191 | Self::PostgresUrl
192 | Self::PrivateKey
193 | Self::RsaPrivateKey
194 | Self::SlackBotToken
195 | Self::SlackOAuthToken
196 | Self::SlackUserToken
197 | Self::SsnPattern
198 | Self::Custom => 2,
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct CredentialFinding {
214 pub kind: CredentialKind,
216 pub offset: usize,
218 pub matched: String,
220 #[cfg_attr(feature = "serde", serde(skip))]
221 end: usize,
222}
223
224impl CredentialFinding {
225 fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
226 let label = format!("[REDACTED:{}]", kind.as_str());
227 Self {
228 kind,
229 offset,
230 matched: label,
231 end,
232 }
233 }
234
235 pub fn from_regex_match(offset: usize, end: usize) -> Self {
240 Self::new(CredentialKind::Custom, offset, end)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
247pub struct ScanResult {
248 pub findings: Vec<CredentialFinding>,
250}
251
252impl ScanResult {
253 pub fn is_clean(&self) -> bool {
255 self.findings.is_empty()
256 }
257
258 pub fn redact(&self, text: &str) -> String {
268 let merged = coalesce_findings(&self.findings);
269 let mut result = text.to_string();
270 for span in merged.iter().rev() {
272 if span.end <= result.len()
273 && span.offset <= span.end
274 && result.is_char_boundary(span.offset)
275 && result.is_char_boundary(span.end)
276 {
277 result.replace_range(span.offset..span.end, &span.label);
278 }
279 }
280 result
281 }
282}
283
284#[derive(Debug, Clone, Default)]
289pub struct ScannerConfig {
290 pub disabled: bool,
293 pub custom_patterns: Vec<String>,
297}
298
299pub struct CredentialScanner {
305 patterns: AhoCorasick,
306 kinds: Vec<CredentialKind>,
310 disabled: bool,
312}
313
314impl Default for CredentialScanner {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320impl CredentialScanner {
321 pub fn new() -> Self {
328 Self::with_config(ScannerConfig::default())
329 }
330
331 pub fn with_config(config: ScannerConfig) -> Self {
337 let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
338 let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
340 all_patterns.extend_from_slice(&custom_refs);
341
342 let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
343 kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
344
345 let ac = AhoCorasick::builder()
346 .match_kind(aho_corasick::MatchKind::LeftmostFirst)
347 .ascii_case_insensitive(true)
352 .build(&all_patterns)
353 .expect("AC patterns are always valid");
354
355 Self {
356 patterns: ac,
357 kinds,
358 disabled: config.disabled,
359 }
360 }
361
362 pub fn scan(&self, text: &str) -> ScanResult {
371 if self.disabled {
372 return ScanResult { findings: Vec::new() };
373 }
374
375 let mut findings = Vec::new();
376
377 for mat in self.patterns.find_iter(text) {
380 let kind = self.kinds[mat.pattern()].clone();
381 let offset = mat.start();
382 let end = token_end(text, mat.end());
383 findings.push(CredentialFinding::new(kind, offset, end));
384 }
385
386 scan_digit_sequences(text, &mut findings);
388
389 scan_emails(text, &mut findings);
391
392 scan_high_entropy(text, &mut findings);
394
395 findings.sort_by_key(|f| f.offset);
396 ScanResult { findings }
397 }
398}
399
400struct MergedSpan {
406 offset: usize,
407 end: usize,
408 label: String,
409 kind: CredentialKind,
413}
414
415fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
427 let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
428 sorted.sort_by_key(|f| (f.offset, f.end));
429
430 let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
431 for f in sorted {
432 match merged.last_mut() {
433 Some(last) if f.offset < last.end => {
436 last.end = last.end.max(f.end);
437 if f.kind.priority() > last.kind.priority() {
438 last.label = f.matched.clone();
439 last.kind = f.kind.clone();
440 }
441 }
442 _ => merged.push(MergedSpan {
443 offset: f.offset,
444 end: f.end,
445 label: f.matched.clone(),
446 kind: f.kind.clone(),
447 }),
448 }
449 }
450 merged
451}
452
453fn token_end(text: &str, from: usize) -> usize {
456 text[from..]
457 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
458 .map(|i| from + i)
459 .unwrap_or(text.len())
460}
461
462fn is_ssn(s: &str) -> bool {
464 let b = s.as_bytes();
465 b.len() == 11
466 && b[0..3].iter().all(u8::is_ascii_digit)
467 && b[3] == b'-'
468 && b[4..6].iter().all(u8::is_ascii_digit)
469 && b[6] == b'-'
470 && b[7..11].iter().all(u8::is_ascii_digit)
471}
472
473fn luhn_valid(digits: &str) -> bool {
476 if digits.len() < 13 || digits.len() > 19 {
477 return false;
478 }
479 let mut sum = 0u32;
480 let mut double = false;
481 for ch in digits.chars().rev() {
482 let Some(d) = ch.to_digit(10) else {
483 return false;
484 };
485 let val = if double {
486 let v = d * 2;
487 if v > 9 {
488 v - 9
489 } else {
490 v
491 }
492 } else {
493 d
494 };
495 sum += val;
496 double = !double;
497 }
498 sum % 10 == 0
499}
500
501fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
503 let bytes = text.as_bytes();
504 let mut i = 0;
505 while i < bytes.len() {
506 if !bytes[i].is_ascii_digit() {
507 i += 1;
508 continue;
509 }
510
511 let start = i;
512 let mut digits = String::new();
513 let mut j = i;
514 let limit = (start + 24).min(bytes.len());
515
516 while j < limit {
517 match bytes[j] {
518 b if b.is_ascii_digit() => {
519 digits.push(b as char);
520 j += 1;
521 }
522 b' ' | b'-' if !digits.is_empty() => {
523 j += 1;
524 }
525 _ => break,
526 }
527 }
528
529 let end = j;
530 let segment = &text[start..end];
531
532 if is_ssn(segment) {
533 findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
534 } else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
535 findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
536 }
537 i = end.max(i + 1);
538 }
539}
540
541fn shannon_entropy(s: &str) -> f64 {
543 if s.is_empty() {
544 return 0.0;
545 }
546 let mut freq = [0u32; 256];
547 for &b in s.as_bytes() {
548 freq[b as usize] += 1;
549 }
550 let len = s.len() as f64;
551 freq.iter()
552 .filter(|&&c| c > 0)
553 .map(|&c| {
554 let p = c as f64 / len;
555 -p * p.log2()
556 })
557 .sum()
558}
559
560fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
563 let mut offset = 0usize;
564 for token in text.split_whitespace() {
565 let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
566 let whitespace_end = token_offset + token.len();
567 let len = token.len();
568 if (20..=64).contains(&len) && shannon_entropy(token) > 4.5 {
569 let end = token_end(text, token_offset);
578 findings.push(CredentialFinding::new(
579 CredentialKind::GenericHighEntropy,
580 token_offset,
581 end,
582 ));
583 }
584 offset = whitespace_end;
585 }
586}
587
588fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
590 let mut search = text;
591 let mut base = 0usize;
592
593 while let Some(at) = search.find('@') {
594 let abs_at = base + at;
595
596 let local_start = text[..abs_at]
597 .rfind(|c: char| c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\''))
598 .map(|i| i + 1)
599 .unwrap_or(0);
600
601 let domain_end = token_end(text, abs_at + 1);
602 let local = &text[local_start..abs_at];
603 let domain = &text[abs_at + 1..domain_end];
604
605 if !local.is_empty() && domain.contains('.') && domain.len() >= 3 {
606 findings.push(CredentialFinding::new(
607 CredentialKind::EmailAddress,
608 local_start,
609 domain_end,
610 ));
611 }
612
613 let next = abs_at + 1;
614 if next >= text.len() {
615 break;
616 }
617 search = &text[next..];
618 base = next;
619 }
620}
621
622#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
633 fn credential_kind_as_str_round_trips() {
634 assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
635 assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
636 assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
637 }
638
639 #[test]
642 fn detects_anthropic_key() {
643 let scanner = CredentialScanner::new();
644 let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
645 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
646 }
647
648 #[test]
649 fn detects_openai_key_not_misclassified_as_anthropic() {
650 let scanner = CredentialScanner::new();
651 let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
652 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
653 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
654 }
655
656 #[test]
657 fn detects_aws_access_key() {
658 let scanner = CredentialScanner::new();
659 let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
660 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
661 }
662
663 #[test]
664 fn detects_gcp_service_account() {
665 let scanner = CredentialScanner::new();
666 let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
667 assert!(result
668 .findings
669 .iter()
670 .any(|f| f.kind == CredentialKind::GcpServiceAccount));
671 }
672
673 #[test]
676 fn detects_gcp_service_account_compact_json() {
677 let scanner = CredentialScanner::new();
679 let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
680 assert!(
681 result
682 .findings
683 .iter()
684 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
685 "compact GCP service-account JSON must be detected"
686 );
687 }
688
689 #[test]
690 fn detects_gcp_service_account_spaces_around_colon() {
691 let scanner = CredentialScanner::new();
692 let result = scanner.scan(r#"{ "type" : "service_account" }"#);
693 assert!(
694 result
695 .findings
696 .iter()
697 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
698 "spaced-colon GCP service-account JSON must be detected"
699 );
700 }
701
702 #[test]
703 fn detects_postgres_url_uppercase_scheme() {
704 let scanner = CredentialScanner::new();
706 let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
707 assert!(
708 result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
709 "upper-case POSTGRES:// scheme must be detected"
710 );
711 }
712
713 #[test]
714 fn detects_lowercase_pem_private_key_header() {
715 let scanner = CredentialScanner::new();
716 let result =
717 scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
718 assert!(
719 result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
720 "lower-case PEM header must be detected"
721 );
722 }
723
724 #[test]
727 fn detects_github_pat() {
728 let scanner = CredentialScanner::new();
729 let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
730 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
731 }
732
733 #[test]
734 fn detects_github_app_token() {
735 let scanner = CredentialScanner::new();
736 let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
737 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
738 }
739
740 #[test]
741 fn detects_slack_bot_token() {
742 let scanner = CredentialScanner::new();
743 let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
744 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
745 }
746
747 #[test]
748 fn detects_slack_user_token() {
749 let scanner = CredentialScanner::new();
750 let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
751 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
752 }
753
754 #[test]
755 fn detects_slack_oauth_token() {
756 let scanner = CredentialScanner::new();
757 let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
758 assert!(result
759 .findings
760 .iter()
761 .any(|f| f.kind == CredentialKind::SlackOAuthToken));
762 }
763
764 #[test]
767 fn detects_azure_connection_string() {
768 let scanner = CredentialScanner::new();
769 let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
770 assert!(result
771 .findings
772 .iter()
773 .any(|f| f.kind == CredentialKind::AzureConnectionString));
774 }
775
776 #[test]
779 fn detects_postgres_url() {
780 let scanner = CredentialScanner::new();
781 let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
782 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
783 }
784
785 #[test]
786 fn detects_mysql_url() {
787 let scanner = CredentialScanner::new();
788 let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
789 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
790 }
791
792 #[test]
793 fn detects_mongodb_url() {
794 let scanner = CredentialScanner::new();
795 let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
796 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
797 }
798
799 #[test]
802 fn detects_rsa_private_key() {
803 let scanner = CredentialScanner::new();
804 let result =
805 scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
806 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
807 }
808
809 #[test]
810 fn detects_ec_private_key() {
811 let scanner = CredentialScanner::new();
812 let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
813 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
814 }
815
816 #[test]
817 fn detects_openssh_private_key() {
818 let scanner = CredentialScanner::new();
819 let result = scanner
820 .scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
821 assert!(result
822 .findings
823 .iter()
824 .any(|f| f.kind == CredentialKind::OpensshPrivateKey));
825 }
826
827 #[test]
828 fn detects_generic_private_key() {
829 let scanner = CredentialScanner::new();
830 let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
831 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
832 }
833
834 #[test]
835 fn detects_pgp_private_key() {
836 let scanner = CredentialScanner::new();
837 let result =
838 scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
839 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
840 }
841
842 #[test]
845 fn detects_credit_card_luhn() {
846 let scanner = CredentialScanner::new();
847 let result = scanner.scan("card: 4532015112830366");
848 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
849 }
850
851 #[test]
852 fn detects_credit_card_with_spaces() {
853 let scanner = CredentialScanner::new();
854 let result = scanner.scan("card: 4532 0151 1283 0366");
855 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
856 }
857
858 #[test]
859 fn does_not_flag_invalid_luhn() {
860 let scanner = CredentialScanner::new();
861 let result = scanner.scan("num: 4532015112830367");
862 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
863 }
864
865 #[test]
866 fn detects_ssn() {
867 let scanner = CredentialScanner::new();
868 let result = scanner.scan("SSN: 123-45-6789");
869 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
870 }
871
872 #[test]
873 fn detects_email_address() {
874 let scanner = CredentialScanner::new();
875 let result = scanner.scan("contact: user@example.com for support");
876 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
877 }
878
879 #[test]
882 fn detects_high_entropy_token() {
883 let scanner = CredentialScanner::new();
884 let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
885 assert!(result
886 .findings
887 .iter()
888 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
889 }
890
891 #[test]
892 fn does_not_flag_short_token_as_high_entropy() {
893 let scanner = CredentialScanner::new();
894 let result = scanner.scan("word: hello");
895 assert!(!result
896 .findings
897 .iter()
898 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
899 }
900
901 #[test]
904 fn luhn_valid_visa_test_number() {
905 assert!(luhn_valid("4532015112830366"));
906 }
907
908 #[test]
909 fn luhn_valid_mastercard_test_number() {
910 assert!(luhn_valid("5425233430109903"));
911 }
912
913 #[test]
914 fn luhn_valid_amex_test_number() {
915 assert!(luhn_valid("371449635398431"));
916 }
917
918 #[test]
919 fn luhn_valid_discover_test_number() {
920 assert!(luhn_valid("6011111111111117"));
921 }
922
923 #[test]
924 fn luhn_invalid_altered_digit() {
925 assert!(!luhn_valid("4532015112830367"));
926 }
927
928 #[test]
929 fn luhn_rejects_too_short() {
930 assert!(!luhn_valid("123456789012"));
931 }
932
933 #[test]
934 fn luhn_rejects_too_long() {
935 assert!(!luhn_valid("45320151128303661234"));
936 }
937
938 #[test]
941 fn entropy_zero_for_empty() {
942 assert_eq!(shannon_entropy(""), 0.0);
943 }
944
945 #[test]
946 fn entropy_low_for_repeated_char() {
947 assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
948 }
949
950 #[test]
951 fn entropy_high_for_random_base64() {
952 assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
953 }
954
955 #[test]
956 fn entropy_moderate_for_english_text() {
957 let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
958 assert!(e > 3.0 && e < 5.0);
959 }
960
961 #[test]
964 fn redact_replaces_github_pat() {
965 let scanner = CredentialScanner::new();
966 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
967 let result = scanner.scan(text);
968 let redacted = result.redact(text);
969 assert!(!redacted.contains("ghp_"));
970 assert!(redacted.contains("[REDACTED:GitHubPat]"));
971 }
972
973 #[test]
974 fn redact_is_deterministic() {
975 let scanner = CredentialScanner::new();
976 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
977 let result = scanner.scan(text);
978 assert_eq!(result.redact(text), result.redact(text));
979 }
980
981 #[test]
982 fn redact_clean_text_unchanged() {
983 let scanner = CredentialScanner::new();
984 let text = "This is a normal sentence with no secrets.";
985 let result = scanner.scan(text);
986 assert!(result.is_clean());
987 assert_eq!(result.redact(text), text);
988 }
989
990 #[test]
991 fn redact_multiple_findings_in_one_pass() {
992 let scanner = CredentialScanner::new();
993 let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
994 let result = scanner.scan(text);
995 let redacted = result.redact(text);
996 assert!(!redacted.contains("ghp_"));
997 assert!(!redacted.contains("postgres://"));
998 assert!(redacted.contains("[REDACTED:GitHubPat]"));
999 assert!(redacted.contains("[REDACTED:PostgresUrl]"));
1000 }
1001
1002 #[test]
1003 fn is_clean_true_for_benign_text() {
1004 let scanner = CredentialScanner::new();
1005 assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
1006 }
1007
1008 #[test]
1011 fn redact_overlapping_findings_leaks_no_secret_fragment() {
1012 let scanner = CredentialScanner::new();
1017 let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
1018 let result = scanner.scan(text);
1019 let redacted = result.redact(text);
1020
1021 assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
1023 assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
1024 assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
1025 assert!(
1026 !redacted.contains("stgresUrl"),
1027 "mangled-splice secret fragment leaked: {redacted}"
1028 );
1029 assert!(redacted.contains("[REDACTED:"));
1031 assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
1032 for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
1035 let close = label.find(']').expect("redaction label must be closed");
1036 let kind = &label["[REDACTED:".len()..close];
1037 assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
1038 }
1039 }
1040
1041 #[test]
1042 fn redact_overlap_at_multibyte_boundary_does_not_panic() {
1043 let scanner = CredentialScanner::new();
1047 let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
1048 let result = scanner.scan(text);
1049 let redacted = result.redact(text);
1051 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1052 }
1053
1054 #[test]
1055 fn redact_adjacent_overlapping_findings_merge_into_one_span() {
1056 let scanner = CredentialScanner::new();
1059 let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
1060 let result = scanner.scan(text);
1061 let redacted = result.redact(text);
1062 assert!(!redacted.contains("ghp_"));
1063 assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
1064 assert!(
1065 redacted.contains(" done"),
1066 "trailing context must be preserved: {redacted}"
1067 );
1068 }
1069
1070 #[test]
1071 fn coalesce_keeps_specific_kind_label_over_generic() {
1072 let scanner = CredentialScanner::new();
1077 let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
1078 let result = scanner.scan(text);
1079 assert!(
1081 result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
1082 "expected a GitHubPat finding: {:?}",
1083 result.findings
1084 );
1085 assert!(
1086 result
1087 .findings
1088 .iter()
1089 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1090 "expected a GenericHighEntropy finding: {:?}",
1091 result.findings
1092 );
1093 let redacted = result.redact(text);
1094 assert!(
1095 redacted.contains("[REDACTED:GitHubPat]"),
1096 "merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
1097 );
1098 assert!(
1099 !redacted.contains("GenericHighEntropy"),
1100 "generic backstop label must not win over a specific detector: {redacted}"
1101 );
1102 assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
1103 }
1104
1105 #[test]
1106 fn coalesce_keeps_db_url_label_over_embedded_email() {
1107 let scanner = CredentialScanner::new();
1111 let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
1112 let result = scanner.scan(text);
1113 let redacted = result.redact(text);
1114 assert_eq!(
1115 redacted, "[REDACTED:PostgresUrl]",
1116 "db-url region must redact to the specific PostgresUrl label: {redacted}"
1117 );
1118 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1119 }
1120
1121 #[test]
1124 fn custom_kind_as_str_returns_custom() {
1125 assert_eq!(CredentialKind::Custom.as_str(), "Custom");
1126 }
1127
1128 #[test]
1129 fn from_regex_match_creates_custom_finding() {
1130 let finding = CredentialFinding::from_regex_match(5, 20);
1131 assert_eq!(finding.kind, CredentialKind::Custom);
1132 assert_eq!(finding.offset, 5);
1133 assert_eq!(finding.matched, "[REDACTED:Custom]");
1134 }
1135
1136 #[test]
1139 fn false_positive_corpus_has_no_hard_credential_hits() {
1140 let scanner = CredentialScanner::new();
1141 let corpus = [
1142 "The quick brown fox jumps over the lazy dog.",
1143 "fn main() { println!(\"Hello, world!\"); }",
1144 "SELECT * FROM users WHERE id = 42;",
1145 "cargo build --release --features std",
1146 "version = \"1.0.0\" edition = \"2021\"",
1147 "2026-04-27T15:34:15.377+0800",
1148 "error[E0382]: borrow of moved value: `x`",
1149 ];
1150 for text in &corpus {
1151 let result = scanner.scan(text);
1152 let hard: Vec<_> = result
1153 .findings
1154 .iter()
1155 .filter(|f| f.kind != CredentialKind::GenericHighEntropy)
1156 .collect();
1157 assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
1158 }
1159 }
1160
1161 #[test]
1164 fn disabled_scanner_returns_empty_result() {
1165 let config = ScannerConfig {
1166 disabled: true,
1167 ..Default::default()
1168 };
1169 let scanner = CredentialScanner::with_config(config);
1170 let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
1171 assert!(result.is_clean(), "disabled scanner must return no findings");
1172 }
1173
1174 #[test]
1175 fn custom_pattern_detected_as_custom_kind() {
1176 let config = ScannerConfig {
1177 custom_patterns: vec!["INTERNAL_SECRET_".into()],
1178 ..Default::default()
1179 };
1180 let scanner = CredentialScanner::with_config(config);
1181 let result = scanner.scan("token=INTERNAL_SECRET_hello");
1182 let custom: Vec<_> = result
1183 .findings
1184 .iter()
1185 .filter(|f| f.kind == CredentialKind::Custom)
1186 .collect();
1187 assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
1188 assert!(custom[0].matched.contains("[REDACTED:Custom]"));
1189 }
1190
1191 #[test]
1192 fn custom_pattern_coexists_with_builtin() {
1193 let config = ScannerConfig {
1194 custom_patterns: vec!["MY_TOKEN_".into()],
1195 ..Default::default()
1196 };
1197 let scanner = CredentialScanner::with_config(config);
1198 let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
1199 let result = scanner.scan(text);
1200 let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
1201 assert!(kinds.contains(&&CredentialKind::GitHubPat));
1202 assert!(kinds.contains(&&CredentialKind::Custom));
1203 }
1204
1205 #[test]
1206 fn default_config_matches_new() {
1207 let default_scanner = CredentialScanner::new();
1208 let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
1209 let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
1210 let r1 = default_scanner.scan(text);
1211 let r2 = config_scanner.scan(text);
1212 assert_eq!(r1.findings.len(), r2.findings.len());
1213 for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
1214 assert_eq!(a.kind, b.kind);
1215 assert_eq!(a.offset, b.offset);
1216 }
1217 }
1218}