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\"", "gho_", "ghu_", "ghr_", "github_pat_", "xapp-", "xoxr-", "ASIA", ];
55
56const AC_KINDS: &[CredentialKind] = &[
58 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, CredentialKind::GitHubOAuthToken, CredentialKind::GitHubUserToken, CredentialKind::GitHubRefreshToken, CredentialKind::GitHubPat, CredentialKind::SlackAppToken, CredentialKind::SlackRefreshToken, CredentialKind::AwsAccessKey, ];
87
88#[derive(Debug, Clone, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub enum CredentialKind {
96 AnthropicKey,
99 AwsAccessKey,
101 GcpServiceAccount,
103 OpenAiKey,
105 AzureConnectionString,
108 GitHubAppToken,
111 GitHubOAuthToken,
113 GitHubPat,
115 GitHubRefreshToken,
117 GitHubUserToken,
119 SlackAppToken,
121 SlackBotToken,
123 SlackOAuthToken,
125 SlackRefreshToken,
127 SlackUserToken,
129 MongodbUrl,
132 MysqlUrl,
134 PostgresUrl,
136 EcPrivateKey,
139 OpensshPrivateKey,
141 PgpPrivateKey,
143 PrivateKey,
145 RsaPrivateKey,
147 CreditCardLuhn,
150 EmailAddress,
152 SsnPattern,
154 GenericHighEntropy,
159 Custom,
162}
163
164impl CredentialKind {
165 pub fn as_str(&self) -> &'static str {
167 match self {
168 Self::AnthropicKey => "AnthropicKey",
169 Self::AwsAccessKey => "AwsAccessKey",
170 Self::AzureConnectionString => "AzureConnectionString",
171 Self::CreditCardLuhn => "CreditCardLuhn",
172 Self::EcPrivateKey => "EcPrivateKey",
173 Self::EmailAddress => "EmailAddress",
174 Self::GcpServiceAccount => "GcpServiceAccount",
175 Self::GenericHighEntropy => "GenericHighEntropy",
176 Self::GitHubAppToken => "GitHubAppToken",
177 Self::GitHubOAuthToken => "GitHubOAuthToken",
178 Self::GitHubPat => "GitHubPat",
179 Self::GitHubRefreshToken => "GitHubRefreshToken",
180 Self::GitHubUserToken => "GitHubUserToken",
181 Self::MongodbUrl => "MongodbUrl",
182 Self::MysqlUrl => "MysqlUrl",
183 Self::OpenAiKey => "OpenAiKey",
184 Self::OpensshPrivateKey => "OpensshPrivateKey",
185 Self::PgpPrivateKey => "PgpPrivateKey",
186 Self::PostgresUrl => "PostgresUrl",
187 Self::PrivateKey => "PrivateKey",
188 Self::RsaPrivateKey => "RsaPrivateKey",
189 Self::SlackAppToken => "SlackAppToken",
190 Self::SlackBotToken => "SlackBotToken",
191 Self::SlackOAuthToken => "SlackOAuthToken",
192 Self::SlackRefreshToken => "SlackRefreshToken",
193 Self::SlackUserToken => "SlackUserToken",
194 Self::SsnPattern => "SsnPattern",
195 Self::Custom => "Custom",
196 }
197 }
198
199 fn priority(&self) -> u8 {
210 match self {
211 Self::GenericHighEntropy => 0,
213 Self::EmailAddress => 1,
214 Self::AnthropicKey
217 | Self::AwsAccessKey
218 | Self::AzureConnectionString
219 | Self::CreditCardLuhn
220 | Self::EcPrivateKey
221 | Self::GcpServiceAccount
222 | Self::GitHubAppToken
223 | Self::GitHubOAuthToken
224 | Self::GitHubPat
225 | Self::GitHubRefreshToken
226 | Self::GitHubUserToken
227 | Self::MongodbUrl
228 | Self::MysqlUrl
229 | Self::OpenAiKey
230 | Self::OpensshPrivateKey
231 | Self::PgpPrivateKey
232 | Self::PostgresUrl
233 | Self::PrivateKey
234 | Self::RsaPrivateKey
235 | Self::SlackAppToken
236 | Self::SlackBotToken
237 | Self::SlackOAuthToken
238 | Self::SlackRefreshToken
239 | Self::SlackUserToken
240 | Self::SsnPattern
241 | Self::Custom => 2,
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
255#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
256pub struct CredentialFinding {
257 pub kind: CredentialKind,
259 pub offset: usize,
261 pub matched: String,
263 #[cfg_attr(feature = "serde", serde(skip))]
264 end: usize,
265}
266
267impl CredentialFinding {
268 fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
269 let label = format!("[REDACTED:{}]", kind.as_str());
270 Self {
271 kind,
272 offset,
273 matched: label,
274 end,
275 }
276 }
277
278 pub fn from_regex_match(offset: usize, end: usize) -> Self {
283 Self::new(CredentialKind::Custom, offset, end)
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
290pub struct ScanResult {
291 pub findings: Vec<CredentialFinding>,
293}
294
295impl ScanResult {
296 pub fn is_clean(&self) -> bool {
298 self.findings.is_empty()
299 }
300
301 pub fn redact(&self, text: &str) -> String {
311 let merged = coalesce_findings(&self.findings);
312 let mut result = text.to_string();
313 for span in merged.iter().rev() {
315 if span.end <= result.len()
316 && span.offset <= span.end
317 && result.is_char_boundary(span.offset)
318 && result.is_char_boundary(span.end)
319 {
320 result.replace_range(span.offset..span.end, &span.label);
321 }
322 }
323 result
324 }
325}
326
327#[derive(Debug, Clone, Default)]
332pub struct ScannerConfig {
333 pub disabled: bool,
336 pub custom_patterns: Vec<String>,
340}
341
342pub struct CredentialScanner {
348 patterns: AhoCorasick,
349 kinds: Vec<CredentialKind>,
353 disabled: bool,
355}
356
357impl Default for CredentialScanner {
358 fn default() -> Self {
359 Self::new()
360 }
361}
362
363impl CredentialScanner {
364 pub fn new() -> Self {
371 Self::with_config(ScannerConfig::default())
372 }
373
374 pub fn with_config(config: ScannerConfig) -> Self {
380 let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
381 let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
383 all_patterns.extend_from_slice(&custom_refs);
384
385 let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
386 kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
387
388 let ac = AhoCorasick::builder()
389 .match_kind(aho_corasick::MatchKind::LeftmostFirst)
390 .ascii_case_insensitive(true)
395 .build(&all_patterns)
396 .expect("AC patterns are always valid");
397
398 Self {
399 patterns: ac,
400 kinds,
401 disabled: config.disabled,
402 }
403 }
404
405 pub fn scan(&self, text: &str) -> ScanResult {
416 if self.disabled {
417 return ScanResult { findings: Vec::new() };
418 }
419
420 let mut findings = Vec::new();
421
422 for mat in self.patterns.find_iter(text) {
425 let kind = self.kinds[mat.pattern()].clone();
426 let offset = mat.start();
427 let end = token_end(text, mat.end());
428 findings.push(CredentialFinding::new(kind, offset, end));
429 }
430
431 scan_digit_sequences(text, &mut findings);
433
434 scan_emails(text, &mut findings);
436
437 scan_high_entropy(text, &mut findings);
439
440 scan_azure_account_key(text, &mut findings);
443
444 findings.sort_by_key(|f| f.offset);
445 dedupe_same_kind_overlaps(&mut findings);
446 ScanResult { findings }
447 }
448}
449
450fn dedupe_same_kind_overlaps(findings: &mut Vec<CredentialFinding>) {
481 let mut kept: Vec<CredentialFinding> = Vec::with_capacity(findings.len());
482 for f in findings.drain(..) {
483 match kept.iter_mut().find(|k| k.kind == f.kind && f.offset < k.end) {
484 Some(k) => k.end = k.end.max(f.end),
487 None => kept.push(f),
488 }
489 }
490 *findings = kept;
491}
492
493struct MergedSpan {
499 offset: usize,
500 end: usize,
501 label: String,
502 kind: CredentialKind,
506}
507
508fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
520 let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
521 sorted.sort_by_key(|f| (f.offset, f.end));
522
523 let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
524 for f in sorted {
525 match merged.last_mut() {
526 Some(last) if f.offset < last.end => {
529 last.end = last.end.max(f.end);
530 if f.kind.priority() > last.kind.priority() {
531 last.label = f.matched.clone();
532 last.kind = f.kind.clone();
533 }
534 }
535 _ => merged.push(MergedSpan {
536 offset: f.offset,
537 end: f.end,
538 label: f.matched.clone(),
539 kind: f.kind.clone(),
540 }),
541 }
542 }
543 merged
544}
545
546fn scan_azure_account_key(text: &str, findings: &mut Vec<CredentialFinding>) {
557 const MARKER: &str = "AccountKey=";
558 let mut search_from = 0;
559 while let Some(rel) = text[search_from..].find(MARKER) {
560 let offset = search_from + rel;
561 let value_start = offset + MARKER.len();
562 let end = text[value_start..]
565 .find(|c: char| c.is_whitespace() || matches!(c, ';' | '"' | '\'' | ',' | ')' | ']' | '}'))
566 .map(|i| value_start + i)
567 .unwrap_or(text.len());
568 findings.push(CredentialFinding::new(
569 CredentialKind::AzureConnectionString,
570 offset,
571 end,
572 ));
573 search_from = end.max(value_start);
575 }
576}
577
578fn token_end(text: &str, from: usize) -> usize {
581 text[from..]
582 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
583 .map(|i| from + i)
584 .unwrap_or(text.len())
585}
586
587fn is_ssn(s: &str) -> bool {
589 let b = s.as_bytes();
590 b.len() == 11
591 && b[0..3].iter().all(u8::is_ascii_digit)
592 && b[3] == b'-'
593 && b[4..6].iter().all(u8::is_ascii_digit)
594 && b[6] == b'-'
595 && b[7..11].iter().all(u8::is_ascii_digit)
596}
597
598fn luhn_valid(digits: &str) -> bool {
601 if digits.len() < 13 || digits.len() > 19 {
602 return false;
603 }
604 let mut sum = 0u32;
605 let mut double = false;
606 for ch in digits.chars().rev() {
607 let Some(d) = ch.to_digit(10) else {
608 return false;
609 };
610 let val = if double {
611 let v = d * 2;
612 if v > 9 {
613 v - 9
614 } else {
615 v
616 }
617 } else {
618 d
619 };
620 sum += val;
621 double = !double;
622 }
623 sum % 10 == 0
624}
625
626fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
628 let bytes = text.as_bytes();
629 let mut i = 0;
630 while i < bytes.len() {
631 if !bytes[i].is_ascii_digit() {
632 i += 1;
633 continue;
634 }
635
636 let start = i;
637 let mut digits = String::new();
638 let mut j = i;
639 let limit = (start + 24).min(bytes.len());
640
641 while j < limit {
642 match bytes[j] {
643 b if b.is_ascii_digit() => {
644 digits.push(b as char);
645 j += 1;
646 }
647 b' ' | b'-' if !digits.is_empty() => {
648 j += 1;
649 }
650 _ => break,
651 }
652 }
653
654 let end = j;
655 let segment = &text[start..end];
656
657 if is_ssn(segment) {
658 findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
659 } else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
660 findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
661 }
662 i = end.max(i + 1);
663 }
664}
665
666fn shannon_entropy(s: &str) -> f64 {
668 if s.is_empty() {
669 return 0.0;
670 }
671 let mut freq = [0u32; 256];
672 for &b in s.as_bytes() {
673 freq[b as usize] += 1;
674 }
675 let len = s.len() as f64;
676 freq.iter()
677 .filter(|&&c| c > 0)
678 .map(|&c| {
679 let p = c as f64 / len;
680 -p * p.log2()
681 })
682 .sum()
683}
684
685const ENTROPY_BITS_GATE: f64 = 4.5;
693
694const HEX_RUN_MIN_LEN: usize = 64;
705
706const BASE64_RUN_MIN_LEN: usize = 20;
720
721fn is_base64_char(b: u8) -> bool {
724 b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'-' | b'_')
725}
726
727fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
750 let mut offset = 0usize;
752 for token in text.split_whitespace() {
753 let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
754 let whitespace_end = token_offset + token.len();
755 let len = token.len();
756 if (20..=64).contains(&len) && shannon_entropy(token) > ENTROPY_BITS_GATE {
757 let end = token_end(text, token_offset);
763 findings.push(CredentialFinding::new(
764 CredentialKind::GenericHighEntropy,
765 token_offset,
766 end,
767 ));
768 }
769 offset = whitespace_end;
770 }
771
772 scan_long_hex_runs(text, findings);
774 scan_long_base64_runs(text, findings);
775 scan_separated_hex_runs(text, findings);
777}
778
779fn scan_long_hex_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
781 let bytes = text.as_bytes();
782 let mut i = 0;
783 while i < bytes.len() {
784 if !bytes[i].is_ascii_hexdigit() {
785 i += 1;
786 continue;
787 }
788 let start = i;
789 while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
790 i += 1;
791 }
792 if i - start >= HEX_RUN_MIN_LEN {
793 findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
794 }
795 }
796}
797
798fn scan_long_base64_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
801 let bytes = text.as_bytes();
802 let mut i = 0;
803 while i < bytes.len() {
804 if !is_base64_char(bytes[i]) {
805 i += 1;
806 continue;
807 }
808 let start = i;
809 while i < bytes.len() && is_base64_char(bytes[i]) {
810 i += 1;
811 }
812 let run = &text[start..i];
813 if run.len() >= BASE64_RUN_MIN_LEN && shannon_entropy(run) > ENTROPY_BITS_GATE {
814 findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
815 }
816 }
817}
818
819fn is_hex_group_separator(b: u8) -> bool {
825 matches!(b, b':' | b'-')
826}
827
828fn scan_separated_hex_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
839 let bytes = text.as_bytes();
840 let mut i = 0;
841 while i < bytes.len() {
842 if !bytes[i].is_ascii_hexdigit() && !is_hex_group_separator(bytes[i]) {
843 i += 1;
844 continue;
845 }
846 let start = i;
847 let mut hex_count = 0usize;
848 let mut has_separator = false;
849 let mut first_hex: Option<usize> = None;
850 let mut last_hex = start;
851 while i < bytes.len() && (bytes[i].is_ascii_hexdigit() || is_hex_group_separator(bytes[i])) {
852 if bytes[i].is_ascii_hexdigit() {
853 hex_count += 1;
854 first_hex.get_or_insert(i);
855 last_hex = i;
856 } else {
857 has_separator = true;
858 }
859 i += 1;
860 }
861 if has_separator && hex_count >= HEX_RUN_MIN_LEN {
862 if let Some(span_start) = first_hex {
863 findings.push(CredentialFinding::new(
864 CredentialKind::GenericHighEntropy,
865 span_start,
866 last_hex + 1,
867 ));
868 }
869 }
870 }
871}
872
873const MAX_EMAIL_LOCAL_LEN: usize = 64;
877
878const MAX_EMAIL_DOMAIN_LEN: usize = 255;
882
883fn bounded_token_end(text: &str, from: usize, max_len: usize) -> usize {
887 let mut end = from;
888 for (i, c) in text[from..].char_indices() {
889 if i >= max_len {
890 return from + i;
891 }
892 if c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}') {
893 return from + i;
894 }
895 end = from + i + c.len_utf8();
896 }
897 end
898}
899
900fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
908 let mut local_start = 0usize;
912
913 for (idx, c) in text.char_indices() {
914 if c == '@' {
915 if idx == local_start || idx - local_start > MAX_EMAIL_LOCAL_LEN {
918 continue;
919 }
920
921 let domain_start = idx + 1;
922 let domain_end = bounded_token_end(text, domain_start, MAX_EMAIL_DOMAIN_LEN);
923 let domain = &text[domain_start..domain_end];
924
925 if domain.contains('.') && domain.len() >= 3 {
926 findings.push(CredentialFinding::new(
927 CredentialKind::EmailAddress,
928 local_start,
929 domain_end,
930 ));
931 }
932 continue;
933 }
934
935 if c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\'') {
936 local_start = idx + c.len_utf8();
937 }
938 }
939}
940
941#[cfg(test)]
946mod tests {
947 use super::*;
948
949 #[test]
952 fn credential_kind_as_str_round_trips() {
953 assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
954 assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
955 assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
956 }
957
958 #[test]
961 fn detects_anthropic_key() {
962 let scanner = CredentialScanner::new();
963 let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
964 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
965 }
966
967 #[test]
968 fn detects_openai_key_not_misclassified_as_anthropic() {
969 let scanner = CredentialScanner::new();
970 let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
971 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
972 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
973 }
974
975 #[test]
976 fn detects_aws_access_key() {
977 let scanner = CredentialScanner::new();
978 let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
979 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
980 }
981
982 #[test]
983 fn detects_gcp_service_account() {
984 let scanner = CredentialScanner::new();
985 let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
986 assert!(result
987 .findings
988 .iter()
989 .any(|f| f.kind == CredentialKind::GcpServiceAccount));
990 }
991
992 #[test]
995 fn detects_gcp_service_account_compact_json() {
996 let scanner = CredentialScanner::new();
998 let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
999 assert!(
1000 result
1001 .findings
1002 .iter()
1003 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
1004 "compact GCP service-account JSON must be detected"
1005 );
1006 }
1007
1008 #[test]
1009 fn detects_gcp_service_account_spaces_around_colon() {
1010 let scanner = CredentialScanner::new();
1011 let result = scanner.scan(r#"{ "type" : "service_account" }"#);
1012 assert!(
1013 result
1014 .findings
1015 .iter()
1016 .any(|f| f.kind == CredentialKind::GcpServiceAccount),
1017 "spaced-colon GCP service-account JSON must be detected"
1018 );
1019 }
1020
1021 #[test]
1022 fn detects_postgres_url_uppercase_scheme() {
1023 let scanner = CredentialScanner::new();
1025 let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
1026 assert!(
1027 result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
1028 "upper-case POSTGRES:// scheme must be detected"
1029 );
1030 }
1031
1032 #[test]
1033 fn detects_lowercase_pem_private_key_header() {
1034 let scanner = CredentialScanner::new();
1035 let result =
1036 scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
1037 assert!(
1038 result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
1039 "lower-case PEM header must be detected"
1040 );
1041 }
1042
1043 #[test]
1046 fn detects_github_pat() {
1047 let scanner = CredentialScanner::new();
1048 let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
1049 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
1050 }
1051
1052 #[test]
1053 fn detects_github_app_token() {
1054 let scanner = CredentialScanner::new();
1055 let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
1056 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
1057 }
1058
1059 #[test]
1060 fn detects_slack_bot_token() {
1061 let scanner = CredentialScanner::new();
1062 let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
1063 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
1064 }
1065
1066 #[test]
1067 fn detects_slack_user_token() {
1068 let scanner = CredentialScanner::new();
1069 let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
1070 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
1071 }
1072
1073 #[test]
1074 fn detects_slack_oauth_token() {
1075 let scanner = CredentialScanner::new();
1076 let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
1077 assert!(result
1078 .findings
1079 .iter()
1080 .any(|f| f.kind == CredentialKind::SlackOAuthToken));
1081 }
1082
1083 #[test]
1086 fn detects_and_redacts_github_oauth_token() {
1087 let scanner = CredentialScanner::new();
1088 let text = "token: gho_1234567890abcdefghijklmnopqrstuvwxyz";
1089 let result = scanner.scan(text);
1090 assert!(result
1091 .findings
1092 .iter()
1093 .any(|f| f.kind == CredentialKind::GitHubOAuthToken));
1094 let redacted = result.redact(text);
1095 assert!(!redacted.contains("gho_"));
1096 assert!(redacted.contains("[REDACTED:GitHubOAuthToken]"));
1097 }
1098
1099 #[test]
1100 fn detects_and_redacts_github_user_token() {
1101 let scanner = CredentialScanner::new();
1102 let text = "token: ghu_1234567890abcdefghijklmnopqrstuvwxyz";
1103 let result = scanner.scan(text);
1104 assert!(result
1105 .findings
1106 .iter()
1107 .any(|f| f.kind == CredentialKind::GitHubUserToken));
1108 let redacted = result.redact(text);
1109 assert!(!redacted.contains("ghu_"));
1110 assert!(redacted.contains("[REDACTED:GitHubUserToken]"));
1111 }
1112
1113 #[test]
1114 fn detects_and_redacts_github_refresh_token() {
1115 let scanner = CredentialScanner::new();
1116 let text = "token: ghr_1234567890abcdefghijklmnopqrstuvwxyz";
1117 let result = scanner.scan(text);
1118 assert!(result
1119 .findings
1120 .iter()
1121 .any(|f| f.kind == CredentialKind::GitHubRefreshToken));
1122 let redacted = result.redact(text);
1123 assert!(!redacted.contains("ghr_"));
1124 assert!(redacted.contains("[REDACTED:GitHubRefreshToken]"));
1125 }
1126
1127 #[test]
1128 fn detects_and_redacts_github_fine_grained_pat() {
1129 let scanner = CredentialScanner::new();
1130 let text = "token: github_pat_11ABCDE0000abcdefghij_1234567890abcdefghijklmnopqrstuvwxyzABCDEF";
1131 let result = scanner.scan(text);
1132 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
1133 let redacted = result.redact(text);
1134 assert!(!redacted.contains("github_pat_"));
1135 assert!(redacted.contains("[REDACTED:GitHubPat]"));
1136 }
1137
1138 #[test]
1139 fn detects_and_redacts_slack_app_token() {
1140 let scanner = CredentialScanner::new();
1141 let text = "SLACK_APP_TOKEN=xapp-1-A012345678-1234567890123-abcdef0123456789abcdef0123456789abcdef";
1142 let result = scanner.scan(text);
1143 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackAppToken));
1144 let redacted = result.redact(text);
1145 assert!(!redacted.contains("xapp-"));
1146 assert!(redacted.contains("[REDACTED:SlackAppToken]"));
1147 }
1148
1149 #[test]
1150 fn detects_and_redacts_slack_refresh_token() {
1151 let scanner = CredentialScanner::new();
1152 let text = "token=xoxr-123456789012-123456789012-XXXXXXXXXXXX";
1153 let result = scanner.scan(text);
1154 assert!(result
1155 .findings
1156 .iter()
1157 .any(|f| f.kind == CredentialKind::SlackRefreshToken));
1158 let redacted = result.redact(text);
1159 assert!(!redacted.contains("xoxr-"));
1160 assert!(redacted.contains("[REDACTED:SlackRefreshToken]"));
1161 }
1162
1163 #[test]
1164 fn detects_and_redacts_aws_sts_temporary_key() {
1165 let scanner = CredentialScanner::new();
1166 let text = "AWS_ACCESS_KEY_ID=ASIAIOSFODNN7EXAMPLE";
1167 let result = scanner.scan(text);
1168 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
1169 let redacted = result.redact(text);
1170 assert!(!redacted.contains("ASIA"));
1171 assert!(redacted.contains("[REDACTED:AwsAccessKey]"));
1172 }
1173
1174 #[test]
1177 fn detects_azure_connection_string() {
1178 let scanner = CredentialScanner::new();
1179 let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
1180 assert!(result
1181 .findings
1182 .iter()
1183 .any(|f| f.kind == CredentialKind::AzureConnectionString));
1184 }
1185
1186 #[test]
1187 fn redacts_azure_account_key_value_after_semicolons() {
1188 let scanner = CredentialScanner::new();
1193 let secret = "abc123DEF456ghi789JKL012mno345PQR678stu901VWX234yz==";
1194 let input = format!(
1195 "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey={secret};EndpointSuffix=core.windows.net"
1196 );
1197 let redacted = scanner.scan(&input).redact(&input);
1198 assert!(
1199 !redacted.contains(secret),
1200 "Azure AccountKey secret leaked past redaction: {redacted}"
1201 );
1202 assert!(
1203 redacted.contains("[REDACTED:AzureConnectionString]"),
1204 "expected an AzureConnectionString redaction label: {redacted}"
1205 );
1206 assert!(redacted.contains("EndpointSuffix=core.windows.net"));
1208 }
1209
1210 #[test]
1213 fn detects_postgres_url() {
1214 let scanner = CredentialScanner::new();
1215 let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
1216 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
1217 }
1218
1219 #[test]
1220 fn detects_mysql_url() {
1221 let scanner = CredentialScanner::new();
1222 let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
1223 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
1224 }
1225
1226 #[test]
1227 fn detects_mongodb_url() {
1228 let scanner = CredentialScanner::new();
1229 let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
1230 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
1231 }
1232
1233 #[test]
1236 fn detects_rsa_private_key() {
1237 let scanner = CredentialScanner::new();
1238 let result =
1239 scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
1240 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
1241 }
1242
1243 #[test]
1244 fn detects_ec_private_key() {
1245 let scanner = CredentialScanner::new();
1246 let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
1247 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
1248 }
1249
1250 #[test]
1251 fn detects_openssh_private_key() {
1252 let scanner = CredentialScanner::new();
1253 let result = scanner
1254 .scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
1255 assert!(result
1256 .findings
1257 .iter()
1258 .any(|f| f.kind == CredentialKind::OpensshPrivateKey));
1259 }
1260
1261 #[test]
1262 fn detects_generic_private_key() {
1263 let scanner = CredentialScanner::new();
1264 let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
1265 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
1266 }
1267
1268 #[test]
1269 fn detects_pgp_private_key() {
1270 let scanner = CredentialScanner::new();
1271 let result =
1272 scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
1273 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
1274 }
1275
1276 #[test]
1279 fn detects_credit_card_luhn() {
1280 let scanner = CredentialScanner::new();
1281 let result = scanner.scan("card: 4532015112830366");
1282 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1283 }
1284
1285 #[test]
1286 fn detects_credit_card_with_spaces() {
1287 let scanner = CredentialScanner::new();
1288 let result = scanner.scan("card: 4532 0151 1283 0366");
1289 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1290 }
1291
1292 #[test]
1293 fn does_not_flag_invalid_luhn() {
1294 let scanner = CredentialScanner::new();
1295 let result = scanner.scan("num: 4532015112830367");
1296 assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1297 }
1298
1299 #[test]
1300 fn detects_ssn() {
1301 let scanner = CredentialScanner::new();
1302 let result = scanner.scan("SSN: 123-45-6789");
1303 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
1304 }
1305
1306 #[test]
1307 fn detects_email_address() {
1308 let scanner = CredentialScanner::new();
1309 let result = scanner.scan("contact: user@example.com for support");
1310 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
1311 }
1312
1313 #[test]
1314 fn detects_email_after_delimiter() {
1315 let input = "mail to: <alice@example.org>";
1318 let scanner = CredentialScanner::new();
1319 let result = scanner.scan(input);
1320 assert!(
1322 result
1323 .findings
1324 .iter()
1325 .any(|f| f.kind == CredentialKind::EmailAddress
1326 && input[f.offset..f.end].starts_with("alice@example.org"))
1327 );
1328 }
1329
1330 #[test]
1331 fn email_scan_is_linear_on_pathological_at_run() {
1332 let scanner = CredentialScanner::new();
1337 let payload = "@".repeat(1_000_000);
1338
1339 let start = std::time::Instant::now();
1340 let result = scanner.scan(&payload);
1341 let elapsed = start.elapsed();
1342
1343 assert!(
1344 !result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress),
1345 "delimiter-free '@' run must not be flagged as an email",
1346 );
1347 assert!(
1348 elapsed < std::time::Duration::from_secs(1),
1349 "email scan took {elapsed:?}; expected well under a second",
1350 );
1351 }
1352
1353 #[test]
1354 fn email_scan_is_linear_on_alternating_at_run() {
1355 let scanner = CredentialScanner::new();
1357 let payload = "a@".repeat(500_000);
1358
1359 let start = std::time::Instant::now();
1360 let _ = scanner.scan(&payload);
1361 assert!(
1362 start.elapsed() < std::time::Duration::from_secs(1),
1363 "alternating '@' run must scan in linear time",
1364 );
1365 }
1366
1367 #[test]
1370 fn detects_high_entropy_token() {
1371 let scanner = CredentialScanner::new();
1372 let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
1373 assert!(result
1374 .findings
1375 .iter()
1376 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1377 }
1378
1379 #[test]
1380 fn does_not_flag_short_token_as_high_entropy() {
1381 let scanner = CredentialScanner::new();
1382 let result = scanner.scan("word: hello");
1383 assert!(!result
1384 .findings
1385 .iter()
1386 .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1387 }
1388
1389 #[test]
1395 fn detects_64_char_lowercase_hex_secret() {
1396 let scanner = CredentialScanner::new();
1397 let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1399 assert_eq!(secret.len(), 64, "fixture must be exactly 64 hex chars");
1400 let result = scanner.scan(&format!("token={secret}"));
1401 assert!(
1402 result
1403 .findings
1404 .iter()
1405 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1406 "64-char hex secret must be flagged: {:?}",
1407 result.findings
1408 );
1409 assert!(!scanner.scan(secret).is_clean());
1410 }
1411
1412 #[test]
1415 fn detects_base64_token_beyond_64_chars() {
1416 let scanner = CredentialScanner::new();
1417 let secret = "aGVsbG9Xb3JsZFRoaXNJc0FWZXJ5TG9uZ0Jhc2U2NFNlY3JldFRva2VuQmV5b25kU2l4dHlGb3VyQ2hhcnM5OQ";
1419 assert!(secret.len() > 64, "fixture must exceed the old 64-char cap");
1420 let result = scanner.scan(&format!("authorization: {secret}"));
1421 assert!(
1422 result
1423 .findings
1424 .iter()
1425 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1426 ">64-char base64 token must be flagged: {:?}",
1427 result.findings
1428 );
1429 }
1430
1431 #[test]
1436 fn detects_separator_delimited_hex_secret() {
1437 let scanner = CredentialScanner::new();
1438 let raw = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1441 let colon = raw
1442 .as_bytes()
1443 .chunks(2)
1444 .map(|c| std::str::from_utf8(c).unwrap())
1445 .collect::<Vec<_>>()
1446 .join(":");
1447 let dash = colon.replace(':', "-");
1448 for secret in [&colon, &dash] {
1449 let result = scanner.scan(&format!("token={secret}"));
1450 assert!(
1451 result
1452 .findings
1453 .iter()
1454 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1455 "separator-delimited hex secret must be flagged: {secret:?} -> {:?}",
1456 result.findings
1457 );
1458 let text = format!(r#"{{"api_token":"{secret}"}}"#);
1460 let redacted = scanner.scan(&text).redact(&text);
1461 assert!(!redacted.contains(secret.as_str()), "raw secret survived: {redacted}");
1462 }
1463 }
1464
1465 #[test]
1469 fn does_not_flag_short_separated_hex() {
1470 let scanner = CredentialScanner::new();
1471 for text in ["mac de:ad:be:ef:00:01 up", "id 550e8400-e29b-41d4-a716-446655440000 ok"] {
1472 let result = scanner.scan(text);
1473 assert!(
1474 !result
1475 .findings
1476 .iter()
1477 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1478 "short separated hex wrongly flagged: {text:?} -> {:?}",
1479 result.findings
1480 );
1481 }
1482 }
1483
1484 #[test]
1491 fn detects_64_char_base64_secret_in_compact_json() {
1492 let scanner = CredentialScanner::new();
1493 let secret = "xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0cE5gI7kM1oQ3uW9zA2bD4fH6jL8pR0tV5xZ";
1495 assert_eq!(secret.len(), 64, "fixture must be exactly 64 base64 chars");
1496 let text = format!(r#"{{"api_token":"{secret}"}}"#);
1497 let result = scanner.scan(&text);
1498 assert!(
1499 result
1500 .findings
1501 .iter()
1502 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1503 "64-char base64 secret in compact JSON must be flagged: {:?}",
1504 result.findings
1505 );
1506 let redacted = result.redact(&text);
1507 assert!(!redacted.contains(secret), "raw base64 secret survived: {redacted}");
1508 }
1509
1510 #[test]
1513 fn branded_prefixes_still_flagged_after_rewrite() {
1514 let scanner = CredentialScanner::new();
1515 let result = scanner.scan("k=AKIAIOSFODNN7EXAMPLE p=ghp_0123456789abcdefghijklmnopqrstuvwxyz");
1516 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
1517 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
1518 }
1519
1520 #[test]
1524 fn does_not_flag_benign_hex_ids_or_prose() {
1525 let scanner = CredentialScanner::new();
1526 let benign = [
1527 "commit 0123456789abcdef0123456789abcdef01234567 fixed it",
1529 "etag d41d8cd98f00b204e9800998ecf8427e cached",
1531 "id 550e8400-e29b-41d4-a716-446655440000 ok",
1533 "The quarterly report is ready for review by the team.",
1535 "user id 42 logged in",
1536 ];
1537 for text in &benign {
1538 let result = scanner.scan(text);
1539 assert!(
1540 !result
1541 .findings
1542 .iter()
1543 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1544 "benign text wrongly flagged: {:?} -> {:?}",
1545 text,
1546 result.findings
1547 );
1548 }
1549 }
1550
1551 #[test]
1554 fn redact_removes_long_hex_secret() {
1555 let scanner = CredentialScanner::new();
1556 let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1557 let text = format!(r#"{{"api_token":"{secret}"}}"#);
1558 let result = scanner.scan(&text);
1559 let redacted = result.redact(&text);
1560 assert!(!redacted.contains(secret), "raw hex secret survived: {redacted}");
1561 assert!(redacted.contains("[REDACTED:GenericHighEntropy]"));
1562 }
1563
1564 #[test]
1572 fn redact_covers_base64_tail_after_long_hex_run() {
1573 let scanner = CredentialScanner::new();
1574 let hex = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1578 let tail = "GHIJKLMNOPQRSTUVWXYZ";
1579 let secret = format!("{hex}{tail}");
1580 assert_eq!(hex.len(), 64);
1581 assert!(!tail.is_empty(), "tail must add bytes beyond the 64-hex span");
1582
1583 let text = format!(r#"{{"api_token":"{secret}"}}"#);
1584 let result = scanner.scan(&text);
1585
1586 let entropy_findings = result
1589 .findings
1590 .iter()
1591 .filter(|f| f.kind == CredentialKind::GenericHighEntropy)
1592 .count();
1593 assert_eq!(
1594 entropy_findings, 1,
1595 "expected exactly one GenericHighEntropy finding: {:?}",
1596 result.findings
1597 );
1598
1599 let redacted = result.redact(&text);
1601 assert!(!redacted.contains(&secret), "raw secret survived: {redacted}");
1602 assert!(!redacted.contains(tail), "base64 tail survived un-redacted: {redacted}");
1603 assert!(!redacted.contains(hex), "hex prefix survived: {redacted}");
1604 assert!(redacted.contains("[REDACTED:GenericHighEntropy]"));
1605 }
1606
1607 #[test]
1612 fn additive_passes_preserve_url_and_whole_blob_entropy_findings() {
1613 let scanner = CredentialScanner::new();
1614 let result = scanner.scan("MONGO_URI=mongodb://admin:pass@cluster0.mongodb.net/mydb");
1615 assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
1616 assert!(result
1617 .findings
1618 .iter()
1619 .any(|f| f.kind == CredentialKind::GenericHighEntropy && f.offset == 0));
1620 }
1621
1622 #[test]
1625 fn luhn_valid_visa_test_number() {
1626 assert!(luhn_valid("4532015112830366"));
1627 }
1628
1629 #[test]
1630 fn luhn_valid_mastercard_test_number() {
1631 assert!(luhn_valid("5425233430109903"));
1632 }
1633
1634 #[test]
1635 fn luhn_valid_amex_test_number() {
1636 assert!(luhn_valid("371449635398431"));
1637 }
1638
1639 #[test]
1640 fn luhn_valid_discover_test_number() {
1641 assert!(luhn_valid("6011111111111117"));
1642 }
1643
1644 #[test]
1645 fn luhn_invalid_altered_digit() {
1646 assert!(!luhn_valid("4532015112830367"));
1647 }
1648
1649 #[test]
1650 fn luhn_rejects_too_short() {
1651 assert!(!luhn_valid("123456789012"));
1652 }
1653
1654 #[test]
1655 fn luhn_rejects_too_long() {
1656 assert!(!luhn_valid("45320151128303661234"));
1657 }
1658
1659 #[test]
1662 fn entropy_zero_for_empty() {
1663 assert_eq!(shannon_entropy(""), 0.0);
1664 }
1665
1666 #[test]
1667 fn entropy_low_for_repeated_char() {
1668 assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
1669 }
1670
1671 #[test]
1672 fn entropy_high_for_random_base64() {
1673 assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
1674 }
1675
1676 #[test]
1677 fn entropy_moderate_for_english_text() {
1678 let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
1679 assert!(e > 3.0 && e < 5.0);
1680 }
1681
1682 #[test]
1685 fn redact_replaces_github_pat() {
1686 let scanner = CredentialScanner::new();
1687 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
1688 let result = scanner.scan(text);
1689 let redacted = result.redact(text);
1690 assert!(!redacted.contains("ghp_"));
1691 assert!(redacted.contains("[REDACTED:GitHubPat]"));
1692 }
1693
1694 #[test]
1695 fn redact_is_deterministic() {
1696 let scanner = CredentialScanner::new();
1697 let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1698 let result = scanner.scan(text);
1699 assert_eq!(result.redact(text), result.redact(text));
1700 }
1701
1702 #[test]
1703 fn redact_clean_text_unchanged() {
1704 let scanner = CredentialScanner::new();
1705 let text = "This is a normal sentence with no secrets.";
1706 let result = scanner.scan(text);
1707 assert!(result.is_clean());
1708 assert_eq!(result.redact(text), text);
1709 }
1710
1711 #[test]
1712 fn redact_multiple_findings_in_one_pass() {
1713 let scanner = CredentialScanner::new();
1714 let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
1715 let result = scanner.scan(text);
1716 let redacted = result.redact(text);
1717 assert!(!redacted.contains("ghp_"));
1718 assert!(!redacted.contains("postgres://"));
1719 assert!(redacted.contains("[REDACTED:GitHubPat]"));
1720 assert!(redacted.contains("[REDACTED:PostgresUrl]"));
1721 }
1722
1723 #[test]
1724 fn is_clean_true_for_benign_text() {
1725 let scanner = CredentialScanner::new();
1726 assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
1727 }
1728
1729 #[test]
1732 fn redact_overlapping_findings_leaks_no_secret_fragment() {
1733 let scanner = CredentialScanner::new();
1738 let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
1739 let result = scanner.scan(text);
1740 let redacted = result.redact(text);
1741
1742 assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
1744 assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
1745 assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
1746 assert!(
1747 !redacted.contains("stgresUrl"),
1748 "mangled-splice secret fragment leaked: {redacted}"
1749 );
1750 assert!(redacted.contains("[REDACTED:"));
1752 assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
1753 for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
1756 let close = label.find(']').expect("redaction label must be closed");
1757 let kind = &label["[REDACTED:".len()..close];
1758 assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
1759 }
1760 }
1761
1762 #[test]
1763 fn redact_overlap_at_multibyte_boundary_does_not_panic() {
1764 let scanner = CredentialScanner::new();
1768 let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
1769 let result = scanner.scan(text);
1770 let redacted = result.redact(text);
1772 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1773 }
1774
1775 #[test]
1776 fn redact_adjacent_overlapping_findings_merge_into_one_span() {
1777 let scanner = CredentialScanner::new();
1780 let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
1781 let result = scanner.scan(text);
1782 let redacted = result.redact(text);
1783 assert!(!redacted.contains("ghp_"));
1784 assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
1785 assert!(
1786 redacted.contains(" done"),
1787 "trailing context must be preserved: {redacted}"
1788 );
1789 }
1790
1791 #[test]
1792 fn coalesce_keeps_specific_kind_label_over_generic() {
1793 let scanner = CredentialScanner::new();
1798 let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
1799 let result = scanner.scan(text);
1800 assert!(
1802 result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
1803 "expected a GitHubPat finding: {:?}",
1804 result.findings
1805 );
1806 assert!(
1807 result
1808 .findings
1809 .iter()
1810 .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1811 "expected a GenericHighEntropy finding: {:?}",
1812 result.findings
1813 );
1814 let redacted = result.redact(text);
1815 assert!(
1816 redacted.contains("[REDACTED:GitHubPat]"),
1817 "merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
1818 );
1819 assert!(
1820 !redacted.contains("GenericHighEntropy"),
1821 "generic backstop label must not win over a specific detector: {redacted}"
1822 );
1823 assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
1824 }
1825
1826 #[test]
1827 fn coalesce_keeps_db_url_label_over_embedded_email() {
1828 let scanner = CredentialScanner::new();
1832 let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
1833 let result = scanner.scan(text);
1834 let redacted = result.redact(text);
1835 assert_eq!(
1836 redacted, "[REDACTED:PostgresUrl]",
1837 "db-url region must redact to the specific PostgresUrl label: {redacted}"
1838 );
1839 assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1840 }
1841
1842 #[test]
1845 fn custom_kind_as_str_returns_custom() {
1846 assert_eq!(CredentialKind::Custom.as_str(), "Custom");
1847 }
1848
1849 #[test]
1850 fn from_regex_match_creates_custom_finding() {
1851 let finding = CredentialFinding::from_regex_match(5, 20);
1852 assert_eq!(finding.kind, CredentialKind::Custom);
1853 assert_eq!(finding.offset, 5);
1854 assert_eq!(finding.matched, "[REDACTED:Custom]");
1855 }
1856
1857 #[test]
1860 fn false_positive_corpus_has_no_hard_credential_hits() {
1861 let scanner = CredentialScanner::new();
1862 let corpus = [
1863 "The quick brown fox jumps over the lazy dog.",
1864 "fn main() { println!(\"Hello, world!\"); }",
1865 "SELECT * FROM users WHERE id = 42;",
1866 "cargo build --release --features std",
1867 "version = \"1.0.0\" edition = \"2021\"",
1868 "2026-04-27T15:34:15.377+0800",
1869 "error[E0382]: borrow of moved value: `x`",
1870 ];
1871 for text in &corpus {
1872 let result = scanner.scan(text);
1873 let hard: Vec<_> = result
1874 .findings
1875 .iter()
1876 .filter(|f| f.kind != CredentialKind::GenericHighEntropy)
1877 .collect();
1878 assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
1879 }
1880 }
1881
1882 #[test]
1885 fn disabled_scanner_returns_empty_result() {
1886 let config = ScannerConfig {
1887 disabled: true,
1888 ..Default::default()
1889 };
1890 let scanner = CredentialScanner::with_config(config);
1891 let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
1892 assert!(result.is_clean(), "disabled scanner must return no findings");
1893 }
1894
1895 #[test]
1896 fn custom_pattern_detected_as_custom_kind() {
1897 let config = ScannerConfig {
1898 custom_patterns: vec!["INTERNAL_SECRET_".into()],
1899 ..Default::default()
1900 };
1901 let scanner = CredentialScanner::with_config(config);
1902 let result = scanner.scan("token=INTERNAL_SECRET_hello");
1903 let custom: Vec<_> = result
1904 .findings
1905 .iter()
1906 .filter(|f| f.kind == CredentialKind::Custom)
1907 .collect();
1908 assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
1909 assert!(custom[0].matched.contains("[REDACTED:Custom]"));
1910 }
1911
1912 #[test]
1913 fn custom_pattern_coexists_with_builtin() {
1914 let config = ScannerConfig {
1915 custom_patterns: vec!["MY_TOKEN_".into()],
1916 ..Default::default()
1917 };
1918 let scanner = CredentialScanner::with_config(config);
1919 let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
1920 let result = scanner.scan(text);
1921 let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
1922 assert!(kinds.contains(&&CredentialKind::GitHubPat));
1923 assert!(kinds.contains(&&CredentialKind::Custom));
1924 }
1925
1926 #[test]
1927 fn default_config_matches_new() {
1928 let default_scanner = CredentialScanner::new();
1929 let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
1930 let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
1931 let r1 = default_scanner.scan(text);
1932 let r2 = config_scanner.scan(text);
1933 assert_eq!(r1.findings.len(), r2.findings.len());
1934 for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
1935 assert_eq!(a.kind, b.kind);
1936 assert_eq!(a.offset, b.offset);
1937 }
1938 }
1939}