1use regex::Regex;
50use serde::{Deserialize, Serialize};
51use sha2::{Digest, Sha256};
52use std::borrow::Cow;
53use std::collections::HashMap;
54use std::ops::Range;
55use std::path::Path;
56use std::sync::OnceLock;
57
58const TRUNCATION_MARKER: &str = "… [truncated]";
60
61const REDACTED: &str = "[REDACTED]";
63
64pub const SECRET_ALLOWLIST_PATH: &str = ".kranz/secret-allowlist";
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SecretFinding {
71 pub rule_id: String,
72 pub fingerprint: String,
73 pub location: String,
74 pub start: usize,
75 pub end: usize,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct SecretScan {
81 pub redacted: String,
82 pub findings: Vec<SecretFinding>,
83}
84
85struct Rule {
89 id: &'static str,
90 re: Regex,
91 replacement: &'static str,
92 secret_group: Option<usize>,
93}
94
95fn rule(id: &'static str, pattern: &str, replacement: &'static str) -> Rule {
96 Rule {
97 id,
98 re: Regex::new(pattern).expect("static scrub regex must compile"),
99 replacement,
100 secret_group: None,
101 }
102}
103
104fn grouped_rule(
105 id: &'static str,
106 pattern: &str,
107 replacement: &'static str,
108 secret_group: usize,
109) -> Rule {
110 Rule {
111 id,
112 re: Regex::new(pattern).expect("static scrub regex must compile"),
113 replacement,
114 secret_group: Some(secret_group),
115 }
116}
117
118fn rules() -> &'static [Rule] {
124 static RULES: OnceLock<Vec<Rule>> = OnceLock::new();
125 RULES.get_or_init(|| {
126 vec![
127 rule(
130 "pem-private-key",
131 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|-----BEGIN [A-Z ]*PRIVATE KEY-----[^\r\n]*",
132 REDACTED,
133 ),
134 grouped_rule(
138 "gcp-private-key-json",
139 r#"(?i)("private_key"\s*:\s*")(-----BEGIN[^"]*)"#,
140 "${1}[REDACTED]",
141 2,
142 ),
143 rule("anthropic-api-key", r"\bsk-ant-[A-Za-z0-9_-]{8,}", REDACTED),
145 rule(
149 "openai-project-key",
150 r"\bsk-proj-[A-Za-z0-9_-]{20,}",
151 REDACTED,
152 ),
153 rule("openai-api-key", r"\bsk-[A-Za-z0-9]{20,}", REDACTED),
155 rule("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b", REDACTED),
157 rule(
159 "stripe-live-key",
160 r"\b(?:sk|rk|pk)_live_[0-9A-Za-z]{16,}",
161 REDACTED,
162 ),
163 rule("npm-token", r"\bnpm_[0-9A-Za-z]{36}\b", REDACTED),
165 rule("github-token", r"\bgh[pos]_[A-Za-z0-9]{20,}", REDACTED),
167 rule(
169 "github-fine-grained-token",
170 r"\bgithub_pat_[A-Za-z0-9_]{20,}",
171 REDACTED,
172 ),
173 rule("aws-access-key-id", r"\bAKIA[0-9A-Z]{16}\b", REDACTED),
175 grouped_rule(
177 "aws-secret-access-key",
178 r"(?i)\b(aws_secret_access_key\s*[=:]\s*)(\S+)",
179 "${1}[REDACTED]",
180 2,
181 ),
182 rule("slack-token", r"\bxox[baprs]-[A-Za-z0-9-]{10,}", REDACTED),
184 rule(
186 "jwt",
187 r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}",
188 REDACTED,
189 ),
190 grouped_rule(
192 "authorization-bearer",
193 r"(?i)\b(bearer\s+)([a-z0-9._~+/=-]{16,})",
194 "${1}[REDACTED]",
195 2,
196 ),
197 grouped_rule(
199 "authorization-basic",
200 r"(?i)\b(basic\s+)([a-z0-9+/]{16,}={0,2})",
201 "${1}[REDACTED]",
202 2,
203 ),
204 grouped_rule(
208 "connection-string-password",
209 r"([a-zA-Z][a-zA-Z0-9+.-]*://[^\s:/@]+:)([^\s:/@]+)(@)",
210 "${1}[REDACTED]${3}",
211 2,
212 ),
213 ]
214 })
215}
216
217fn generic_assignment_re() -> &'static Regex {
224 static RE: OnceLock<Regex> = OnceLock::new();
225 RE.get_or_init(|| {
226 Regex::new(
227 r#"(?i)((?:api[_-]?key|secret|token|password|passwd|credential)["']?\s*[:=]\s*["']?)([^\s"']{8,})"#,
228 )
229 .expect("generic assignment regex must compile")
230 })
231}
232
233fn entropy_assignment_re() -> &'static Regex {
239 static RE: OnceLock<Regex> = OnceLock::new();
240 RE.get_or_init(|| {
241 Regex::new(
242 r#"(?i)((?:access[_-]?token|auth[_-]?token|auth|client[_-]?secret|private[_-]?key)["']?\s*[:=]\s*["']?)([A-Za-z0-9+/_=-]{24,})"#,
243 )
244 .expect("entropy assignment regex must compile")
245 })
246}
247
248pub(crate) fn shannon_entropy(s: &str) -> f64 {
253 if s.is_empty() {
254 return 0.0;
255 }
256 let mut counts: HashMap<char, usize> = HashMap::new();
257 for c in s.chars() {
258 *counts.entry(c).or_insert(0) += 1;
259 }
260 let len = s.chars().count() as f64;
261 counts
262 .values()
263 .map(|&count| {
264 let p = count as f64 / len;
265 -p * p.log2()
266 })
267 .sum()
268}
269
270fn looks_like_secret_charset(s: &str) -> bool {
275 !s.is_empty()
276 && s.chars()
277 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-' | '='))
278}
279
280fn is_allowlisted(value: &str) -> bool {
285 let lower = value.to_ascii_lowercase();
286
287 const PLACEHOLDERS: &[&str] = &[
289 "xxxx",
290 "replace",
291 "example",
292 "changeme",
293 "your",
294 "dummy",
295 "placeholder",
296 "todo",
297 "none",
298 "redacted",
299 ];
300 if PLACEHOLDERS.iter().any(|p| lower.contains(p)) {
301 return true;
302 }
303
304 if let Some(first) = value.chars().next() {
306 if value.chars().all(|c| c == first) {
307 return true;
308 }
309 }
310
311 if is_uuid(value) {
313 return true;
314 }
315
316 if value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) && lower == value {
319 return true;
320 }
321
322 false
323}
324
325fn is_uuid(s: &str) -> bool {
327 let groups: Vec<&str> = s.split('-').collect();
328 if groups.len() != 5 {
329 return false;
330 }
331 let widths = [8usize, 4, 4, 4, 12];
332 groups
333 .iter()
334 .zip(widths)
335 .all(|(g, w)| g.len() == w && g.chars().all(|c| c.is_ascii_hexdigit()))
336}
337
338fn is_high_entropy_secret(value: &str) -> bool {
342 value.len() >= 24
343 && looks_like_secret_charset(value)
344 && !is_allowlisted(value)
345 && shannon_entropy(value) >= 4.0
346}
347
348fn secret_fingerprint(rule_id: &str, value: &str) -> String {
349 let mut hasher = Sha256::new();
350 hasher.update(rule_id.as_bytes());
351 hasher.update([0]);
352 hasher.update(value.as_bytes());
353 let digest = hasher.finalize();
354 digest[..12].iter().map(|b| format!("{b:02x}")).collect()
355}
356
357fn push_finding(
358 out: &mut Vec<SecretFinding>,
359 occupied: &mut Vec<Range<usize>>,
360 rule_id: &str,
361 location: &str,
362 range: Range<usize>,
363 value: &str,
364) {
365 if is_allowlisted(value) {
366 return;
367 }
368 if occupied
369 .iter()
370 .any(|existing| existing.start < range.end && range.start < existing.end)
371 {
372 return;
373 }
374 occupied.push(range.clone());
375 out.push(SecretFinding {
376 rule_id: rule_id.to_string(),
377 fingerprint: secret_fingerprint(rule_id, value),
378 location: location.to_string(),
379 start: range.start,
380 end: range.end,
381 });
382}
383
384pub fn scan_text_at(text: &str, location: &str) -> Vec<SecretFinding> {
386 scan_text_with_assignments(text, text, location)
387}
388
389fn scan_text_with_assignments(
390 text: &str,
391 assignment_text: &str,
392 location: &str,
393) -> Vec<SecretFinding> {
394 let mut out = Vec::new();
395 let mut occupied: Vec<Range<usize>> = Vec::new();
396 for rule in rules() {
397 for caps in rule.re.captures_iter(text) {
398 let m = rule
399 .secret_group
400 .and_then(|idx| caps.get(idx))
401 .or_else(|| caps.get(0));
402 if let Some(m) = m {
403 push_finding(
404 &mut out,
405 &mut occupied,
406 rule.id,
407 location,
408 m.start()..m.end(),
409 m.as_str(),
410 );
411 }
412 }
413 }
414
415 for caps in generic_assignment_re().captures_iter(assignment_text) {
416 if let Some(value) = caps.get(2) {
417 push_finding(
418 &mut out,
419 &mut occupied,
420 "generic-secret-assignment",
421 location,
422 value.start()..value.end(),
423 value.as_str(),
424 );
425 }
426 }
427
428 for caps in entropy_assignment_re().captures_iter(assignment_text) {
429 if let Some(value) = caps.get(2) {
430 if is_high_entropy_secret(value.as_str()) {
431 push_finding(
432 &mut out,
433 &mut occupied,
434 "high-entropy-secret-assignment",
435 location,
436 value.start()..value.end(),
437 value.as_str(),
438 );
439 }
440 }
441 }
442 out
443}
444
445pub fn scan_text(text: &str) -> Vec<SecretFinding> {
447 scan_text_at(text, "text")
448}
449
450fn scrub_assignments(text: &str) -> Cow<'_, str> {
455 generic_assignment_re().replace_all(text, |caps: ®ex::Captures<'_>| {
456 let prefix = &caps[1];
457 let value = &caps[2];
458 if is_allowlisted(value) {
459 caps[0].to_owned()
460 } else {
461 format!("{prefix}{REDACTED}")
462 }
463 })
464}
465
466fn scrub_entropy(text: &str) -> Cow<'_, str> {
472 entropy_assignment_re().replace_all(text, |caps: ®ex::Captures<'_>| {
473 let prefix = &caps[1];
474 let value = &caps[2];
475 if is_high_entropy_secret(value) {
476 format!("{prefix}{REDACTED}")
477 } else {
478 caps[0].to_owned()
479 }
480 })
481}
482
483fn scrub_plain(text: &str) -> String {
492 let mut out = text.to_owned();
493 for rule in rules() {
494 if let Cow::Owned(replaced) = rule.re.replace_all(&out, rule.replacement) {
495 out = replaced;
496 }
497 }
498 if let Cow::Owned(replaced) = scrub_assignments(&out) {
501 out = replaced;
502 }
503 if let Cow::Owned(replaced) = scrub_entropy(&out) {
508 out = replaced;
509 }
510 out
511}
512
513fn scrub_json_text(text: &str) -> Option<String> {
517 serde_json::from_str::<serde_json::Value>(text).ok()?;
521 let bytes = text.as_bytes();
522 let mut cursor = 0;
523 let mut copied = 0;
524 let mut out = String::new();
525 let mut key: Option<String> = None;
526 while cursor < bytes.len() {
527 let start = cursor;
528 if bytes[cursor] == b'"' {
529 cursor += 1;
530 while cursor < bytes.len() {
531 match bytes[cursor] {
532 b'\\' => cursor += 2,
533 b'"' => {
534 cursor += 1;
535 break;
536 }
537 _ => cursor += 1,
538 }
539 }
540 let decoded: String = serde_json::from_str(&text[start..cursor]).ok()?;
541 let is_key = text[cursor..].trim_start().starts_with(':');
542 let redacted = if is_key {
543 scrub_plain(&decoded)
544 } else {
545 scrub_json_assignment(scrub_impl(&decoded), key.as_deref())
546 };
547 if redacted != decoded {
548 out.push_str(&text[copied..start]);
549 out.push_str(
552 &serde_json::to_string(&redacted)
553 .ok()?
554 .replace('<', "\\u003c")
555 .replace('>', "\\u003e"),
556 );
557 copied = cursor;
558 }
559 key = is_key.then_some(decoded);
560 } else if bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b':' {
561 cursor += 1;
562 } else {
563 if key.is_some() && matches!(bytes[cursor], b'-' | b'0'..=b'9') {
566 while cursor < bytes.len()
567 && matches!(
568 bytes[cursor],
569 b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9'
570 )
571 {
572 cursor += 1;
573 }
574 let value = &text[start..cursor];
575 let redacted = scrub_json_assignment(value.to_owned(), key.as_deref());
576 if redacted != value {
577 out.push_str(&text[copied..start]);
578 out.push_str(&serde_json::to_string(&redacted).ok()?);
579 copied = cursor;
580 }
581 } else {
582 cursor += 1;
583 }
584 key = None;
585 }
586 }
587 out.push_str(&text[copied..]);
588 Some(out)
589}
590
591fn scrub_json_assignment(mut value: String, key: Option<&str>) -> String {
592 if let Some(key) = key {
593 let prefix = format!("{key}=\"");
596 let contextual = format!("{prefix}{value}");
597 for (regex, entropy_only) in [
598 (generic_assignment_re(), false),
599 (entropy_assignment_re(), true),
600 ] {
601 let Some(caps) = regex.captures(&contextual) else {
602 continue;
603 };
604 let candidate = caps.get(2).expect("assignment value capture");
605 if candidate.start() == prefix.len()
606 && if entropy_only {
607 is_high_entropy_secret(candidate.as_str())
608 } else {
609 !is_allowlisted(candidate.as_str())
610 }
611 {
612 value.replace_range(..candidate.len(), REDACTED);
613 break;
614 }
615 }
616 }
617 value
618}
619
620fn scrub_impl(text: &str) -> String {
621 if let Some(redacted) = scrub_json_text(text) {
622 return redacted;
623 }
624 let mut out = String::new();
627 let mut plain_start = 0;
628 let mut body_start = None;
629 let mut cursor = 0;
630 for line in text.split_inclusive('\n') {
631 let start = cursor;
632 cursor += line.len();
633 let trimmed = line.trim();
634 if body_start.is_none() && matches!(trimmed, "```" | "```json" | "```JSON") {
635 body_start = Some(cursor);
636 } else if trimmed == "```" {
637 if let Some(body) = body_start.take() {
638 if let Some(redacted) = scrub_json_text(&text[body..start]) {
639 out.push_str(&scrub_plain(&text[plain_start..body]));
640 out.push_str(&redacted);
641 if !redacted.ends_with('\n') {
643 out.push('\n');
644 }
645 plain_start = start;
646 }
647 }
648 }
649 }
650 out.push_str(&scrub_plain(&text[plain_start..]));
651 out
652}
653
654pub fn scrub_with_findings(text: &str, location: &str) -> SecretScan {
656 SecretScan {
657 redacted: scrub_impl(text),
658 findings: scan_text_at(text, location),
659 }
660}
661
662pub fn scrub(text: &str) -> String {
663 scrub_impl(text)
664}
665
666pub fn scrub_json_value(value: &mut serde_json::Value, location: &str) -> Vec<SecretFinding> {
669 fn walk(value: &mut serde_json::Value, path: String, findings: &mut Vec<SecretFinding>) {
670 match value {
671 serde_json::Value::String(s) => {
672 let scan = scrub_with_findings(s, &path);
673 *s = scan.redacted;
674 findings.extend(scan.findings);
675 }
676 serde_json::Value::Array(items) => {
677 for (idx, item) in items.iter_mut().enumerate() {
678 walk(item, format!("{path}/{idx}"), findings);
679 }
680 }
681 serde_json::Value::Object(map) => {
682 for (key, item) in map.iter_mut() {
683 walk(item, format!("{path}/{key}"), findings);
684 }
685 }
686 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
687 }
688 }
689 }
690
691 let mut findings = Vec::new();
692 walk(value, location.to_string(), &mut findings);
693 findings
694}
695
696const GENERATED_DIFF_PATH_PREFIXES: &[&str] =
700 &["apps/dashboard/dist/", "crates/cli/assets/dashboard/dist/"];
701
702pub fn scan_unified_diff(diff: &str) -> Vec<SecretFinding> {
704 let mut findings = Vec::new();
705 let mut path = "<diff>".to_string();
706 let mut generated_dashboard_bundle = false;
707 let mut new_line: Option<usize> = None;
708
709 for line in diff.lines() {
710 if let Some(rest) = line.strip_prefix("+++ b/") {
711 path = rest.to_string();
712 generated_dashboard_bundle = GENERATED_DIFF_PATH_PREFIXES
713 .iter()
714 .any(|prefix| path.starts_with(prefix));
715 continue;
716 }
717 if line.starts_with("@@ ") {
718 new_line = parse_new_hunk_start(line);
719 continue;
720 }
721 if line.starts_with("+++") {
722 continue;
723 }
724 if let Some(added) = line.strip_prefix('+') {
725 let line_no = new_line.unwrap_or(0);
726 let location = if line_no == 0 {
727 path.clone()
728 } else {
729 format!("{path}:{line_no}")
730 };
731 let mut line_findings = scan_text_at(added, &location);
732 if generated_dashboard_bundle {
733 line_findings.retain(|finding| finding.rule_id != "generic-secret-assignment");
734 }
735 findings.extend(line_findings);
736 if let Some(n) = &mut new_line {
737 *n += 1;
738 }
739 } else if !line.starts_with('-') {
740 if let Some(n) = &mut new_line {
741 *n += 1;
742 }
743 }
744 }
745
746 findings
747}
748
749fn parse_new_hunk_start(line: &str) -> Option<usize> {
750 let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
751 let number = plus
752 .trim_start_matches('+')
753 .split(',')
754 .next()
755 .filter(|s| !s.is_empty())?;
756 number.parse().ok()
757}
758
759pub fn read_allowlist_text(text: &str) -> std::collections::BTreeSet<String> {
760 text.lines()
761 .map(str::trim)
762 .filter(|line| !line.is_empty() && !line.starts_with('#'))
763 .filter_map(|line| line.split_whitespace().next())
764 .map(str::to_string)
765 .collect()
766}
767
768pub fn filter_allowed(
769 findings: Vec<SecretFinding>,
770 allowed: &std::collections::BTreeSet<String>,
771) -> Vec<SecretFinding> {
772 findings
773 .into_iter()
774 .filter(|f| !allowed.contains(&f.fingerprint))
775 .collect()
776}
777
778pub fn format_findings(findings: &[SecretFinding]) -> String {
779 findings
780 .iter()
781 .map(|finding| {
782 format!(
783 "{} [{}] {} bytes {}..{}",
784 finding.fingerprint, finding.rule_id, finding.location, finding.start, finding.end
785 )
786 })
787 .collect::<Vec<_>>()
788 .join("\n")
789}
790
791const SCAN_PATH_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
798
799fn read_scan_candidate(path: &Path) -> Option<Vec<u8>> {
819 use std::io::Read as _;
820 let (parent, name) = crate::paths::open_parent_nofollow(path).ok()?;
821 let mut options = cap_std::fs::OpenOptions::new();
822 {
823 use cap_fs_ext::OpenOptionsFollowExt as _;
824 use cap_primitives::fs::FollowSymlinks;
825 options.read(true).follow(FollowSymlinks::No);
826 }
827 #[cfg(unix)]
828 {
829 use cap_fs_ext::OpenOptionsExt as _;
830 options.custom_flags(libc::O_NONBLOCK);
831 }
832 let file = parent.open_with(name, &options).ok()?.into_std();
833 let metadata = file.metadata().ok()?;
834 if !metadata.file_type().is_file() || metadata.len() > SCAN_PATH_MAX_FILE_BYTES {
835 return None;
836 }
837 let mut buf = Vec::new();
838 (&mut &file)
839 .take(SCAN_PATH_MAX_FILE_BYTES + 1)
840 .read_to_end(&mut buf)
841 .ok()?;
842 if buf.len() as u64 > SCAN_PATH_MAX_FILE_BYTES {
843 return None;
844 }
845 Some(buf)
846}
847
848pub fn scan_paths(repo_root: &Path, paths: &[&Path]) -> Vec<SecretFinding> {
861 let mut findings = Vec::new();
862 for path in paths {
863 let full = if path.is_absolute() {
864 path.to_path_buf()
865 } else {
866 repo_root.join(path)
867 };
868 let Some(bytes) = read_scan_candidate(&full) else {
869 continue;
870 };
871 let text = String::from_utf8_lossy(&bytes);
872 let location = full
873 .strip_prefix(repo_root)
874 .ok()
875 .and_then(|p| p.to_str())
876 .unwrap_or_else(|| full.to_str().unwrap_or("<path>"));
877 let assignments = if full.extension().is_some_and(|ext| ext == "py") {
878 python_assignment_text(&text)
879 } else {
880 Cow::Borrowed(text.as_ref())
881 };
882 findings.extend(scan_text_with_assignments(&text, &assignments, location));
883 }
884 findings
885}
886
887fn python_assignment_text(text: &str) -> Cow<'_, str> {
893 let mut out = Cow::Borrowed(text);
894 let mut offset = 0;
895 for line in text.split_inclusive('\n') {
896 let trimmed = line.trim_end();
897 let keyword = trimmed.split_whitespace().next().unwrap_or("");
898 if trimmed.ends_with(':')
899 && matches!(
900 keyword,
901 "if" | "elif" | "while" | "for" | "with" | "except" | "class" | "match" | "case"
902 )
903 {
904 let colon = offset + trimmed.len() - 1;
905 out.to_mut().replace_range(colon..colon + 1, " ");
906 }
907 offset += line.len();
908 }
909 out
910}
911
912pub fn truncate_chars(text: &str, max: usize) -> String {
916 match text.char_indices().nth(max) {
917 None => text.to_owned(),
919 Some((cut_at, _)) => {
920 let mut out = String::with_capacity(cut_at + TRUNCATION_MARKER.len());
921 out.push_str(&text[..cut_at]);
922 out.push_str(TRUNCATION_MARKER);
923 out
924 }
925 }
926}
927
928pub fn scrub_and_truncate(text: &str, max: usize) -> String {
931 truncate_chars(&scrub(text), max)
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 #[test]
939 fn entropy_of_empty_is_zero() {
940 assert_eq!(shannon_entropy(""), 0.0);
941 }
942
943 #[test]
944 fn entropy_of_uniform_string_is_zero() {
945 assert_eq!(shannon_entropy("aaaaaaaa"), 0.0);
946 }
947
948 #[test]
949 fn entropy_of_random_base64_is_high() {
950 let e = shannon_entropy("aB3xQ9zK7mP2wR5tY8uV1nJ4kL6dF0sG");
952 assert!(e >= 4.0, "entropy too low: {e}");
953 }
954
955 #[test]
956 fn entropy_of_english_word_is_low() {
957 let e = shannon_entropy("bureaucracy");
958 assert!(e < 4.0, "prose entropy unexpectedly high: {e}");
959 }
960
961 #[test]
962 fn uuid_recognized() {
963 assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
964 assert!(!is_uuid("not-a-uuid"));
965 assert!(!is_uuid("550e8400e29b41d4a716446655440000"));
966 }
967
968 #[test]
969 fn allowlist_covers_placeholders_and_shas() {
970 assert!(is_allowlisted("REPLACE_ME_WITH_REAL_KEY_1234567890"));
971 assert!(is_allowlisted("xxxxxxxxxxxxxxxxxxxxxxxx"));
972 assert!(is_allowlisted("aaaaaaaaaaaaaaaaaaaaaaaa"));
973 assert!(is_allowlisted("550e8400-e29b-41d4-a716-446655440000"));
974 assert!(is_allowlisted("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
976 }
977
978 const SCRUB_NOFOLLOW_SECRET: &str = "sk-ant-api03-ScrubNofollowTestValue1";
987
988 fn scan_with_timeout(root: &Path, paths: &[&Path], secs: u64) -> Vec<SecretFinding> {
994 let root = root.to_path_buf();
995 let paths: Vec<std::path::PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
996 let (tx, rx) = std::sync::mpsc::channel();
997 std::thread::spawn(move || {
998 let refs: Vec<&Path> = paths.iter().map(std::path::PathBuf::as_path).collect();
999 let _ = tx.send(scan_paths(&root, &refs));
1000 });
1001 rx.recv_timeout(std::time::Duration::from_secs(secs))
1002 .expect("scan_paths must not block")
1003 }
1004
1005 #[cfg(unix)]
1009 #[test]
1010 fn scrub_nofollow_fifo_does_not_block_checkpoint_scan() {
1011 let dir = tempfile::tempdir().unwrap();
1012 let fifo = dir.path().join("planted.fifo");
1013 let c_path = std::ffi::CString::new(fifo.to_str().expect("utf-8 temp path")).unwrap();
1014 let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
1015 assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
1016
1017 let findings = scan_with_timeout(dir.path(), &[Path::new("planted.fifo")], 10);
1018 assert!(
1019 findings.is_empty(),
1020 "a FIFO is skipped, never scanned: {findings:?}"
1021 );
1022 }
1023
1024 #[cfg(unix)]
1027 #[test]
1028 fn scrub_nofollow_symlink_to_dev_zero_is_skipped() {
1029 let dir = tempfile::tempdir().unwrap();
1030 std::os::unix::fs::symlink("/dev/zero", dir.path().join("zero")).unwrap();
1031
1032 let findings = scan_with_timeout(dir.path(), &[Path::new("zero")], 10);
1033 assert!(
1034 findings.is_empty(),
1035 "a symlink to an unbounded source is skipped, never read through: {findings:?}"
1036 );
1037 }
1038
1039 #[cfg(unix)]
1043 #[test]
1044 fn scrub_nofollow_symlinked_file_is_not_read_through() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let outside = tempfile::tempdir().unwrap();
1047 let real = outside.path().join("real.txt");
1048 std::fs::write(&real, SCRUB_NOFOLLOW_SECRET).unwrap();
1049 std::os::unix::fs::symlink(&real, dir.path().join("linked.txt")).unwrap();
1050
1051 let findings = scan_paths(dir.path(), &[Path::new("linked.txt")]);
1052 assert!(
1053 findings.is_empty(),
1054 "a symlink is never read through: {findings:?}"
1055 );
1056 let findings = scan_paths(dir.path(), &[real.as_path()]);
1058 assert!(
1059 findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
1060 "the direct scan must flag the secret: {findings:?}"
1061 );
1062 }
1063
1064 #[test]
1069 fn scrub_nofollow_oversized_file_is_skipped_and_under_cap_scans() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let mut content = SCRUB_NOFOLLOW_SECRET.as_bytes().to_vec();
1072 content.resize(SCAN_PATH_MAX_FILE_BYTES as usize + 1, b'x');
1073 std::fs::write(dir.path().join("big.txt"), &content).unwrap();
1074
1075 let findings = scan_with_timeout(dir.path(), &[Path::new("big.txt")], 10);
1076 assert!(
1077 findings.is_empty(),
1078 "an oversized file is skipped whole, never partially scanned: {findings:?}"
1079 );
1080
1081 std::fs::write(dir.path().join("small.txt"), SCRUB_NOFOLLOW_SECRET).unwrap();
1083 let findings = scan_paths(dir.path(), &[Path::new("small.txt")]);
1084 assert!(
1085 findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
1086 "under-cap content still scans: {findings:?}"
1087 );
1088 }
1089
1090 #[test]
1097 fn composition_audit_secret_allowlist_waives_one_fingerprint_never_a_rule() {
1098 let text_a = "sk-ant-api03-CompositionAuditValueA1";
1099 let text_b = "sk-ant-api03-CompositionAuditValueB2";
1100 let findings = scan_text(&format!("{text_a} {text_b}"));
1101 assert_eq!(findings.len(), 2, "both keys must be found: {findings:?}");
1102
1103 let waived: std::collections::BTreeSet<String> =
1106 [findings[0].fingerprint.clone()].into_iter().collect();
1107 let remaining = filter_allowed(findings, &waived);
1108 assert_eq!(remaining.len(), 1);
1109 assert_eq!(remaining[0].rule_id, "anthropic-api-key");
1110
1111 let findings = scan_text(text_a);
1113 assert_eq!(
1114 filter_allowed(findings.clone(), &Default::default()),
1115 findings
1116 );
1117 let garbage = read_allowlist_text("# reviewed\nnot-a-fingerprint\n");
1118 assert_eq!(filter_allowed(findings.clone(), &garbage), findings);
1119 }
1120}