1use std::collections::BTreeMap;
4
5use zeroize::Zeroizing;
6
7#[derive(Debug)]
9pub struct ScanFinding {
10 pub key: String,
12 pub path: String,
14}
15
16pub fn scan_for_leaks(
22 paths: &[&str],
23 secrets: &BTreeMap<String, Zeroizing<String>>,
24 min_length: usize,
25) -> Vec<ScanFinding> {
26 let mut findings = Vec::new();
27
28 for base in paths {
29 let walker = walkdir::WalkDir::new(base)
30 .follow_links(false)
31 .into_iter()
32 .filter_entry(|e| {
33 let name = e.file_name().to_string_lossy();
34 if e.file_type().is_dir() && e.depth() > 0 {
35 return !name.starts_with('.') && name != "target" && name != "node_modules";
36 }
37 true
38 });
39
40 for entry in walker.flatten() {
41 if !entry.file_type().is_file() {
42 continue;
43 }
44 let path = entry.path();
45
46 let name = path.file_name().unwrap_or_default().to_string_lossy();
47 if name.ends_with(".murk") || name.ends_with(".lock") {
48 continue;
49 }
50
51 let Ok(content) = std::fs::read_to_string(path) else {
52 continue;
53 };
54
55 for (key, value) in secrets {
56 if value.len() < min_length {
57 continue;
58 }
59 if content.contains(value.as_str()) {
60 findings.push(ScanFinding {
61 key: key.clone(),
62 path: path.display().to_string(),
63 });
64 }
65 }
66 }
67 }
68
69 findings
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use std::collections::BTreeMap;
76
77 #[test]
78 fn scan_finds_leaked_value() {
79 let dir = tempfile::TempDir::new().unwrap();
80 std::fs::write(
81 dir.path().join("config.yml"),
82 "db_password: supersecretvalue123",
83 )
84 .unwrap();
85
86 let mut secrets = BTreeMap::new();
87 secrets.insert(
88 "DB_PASSWORD".into(),
89 Zeroizing::new("supersecretvalue123".to_string()),
90 );
91
92 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
93 assert_eq!(findings.len(), 1);
94 assert_eq!(findings[0].key, "DB_PASSWORD");
95 }
96
97 #[test]
98 fn scan_skips_short_values() {
99 let dir = tempfile::TempDir::new().unwrap();
100 std::fs::write(dir.path().join("file.txt"), "abc").unwrap();
101
102 let mut secrets = BTreeMap::new();
103 secrets.insert("SHORT".into(), Zeroizing::new("abc".to_string()));
104
105 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
106 assert!(findings.is_empty());
107 }
108
109 #[test]
110 fn scan_skips_murk_files() {
111 let dir = tempfile::TempDir::new().unwrap();
112 std::fs::write(dir.path().join("test.murk"), "supersecretvalue123").unwrap();
113
114 let mut secrets = BTreeMap::new();
115 secrets.insert(
116 "KEY".into(),
117 Zeroizing::new("supersecretvalue123".to_string()),
118 );
119
120 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
121 assert!(findings.is_empty());
122 }
123
124 #[test]
125 fn scan_skips_hidden_dirs() {
126 let dir = tempfile::TempDir::new().unwrap();
127 let hidden = dir.path().join(".hidden");
128 std::fs::create_dir(&hidden).unwrap();
129 std::fs::write(hidden.join("leaked.txt"), "supersecretvalue123").unwrap();
130
131 let mut secrets = BTreeMap::new();
132 secrets.insert(
133 "KEY".into(),
134 Zeroizing::new("supersecretvalue123".to_string()),
135 );
136
137 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
138 assert!(findings.is_empty());
139 }
140
141 #[test]
142 fn scan_no_secrets_returns_empty() {
143 let dir = tempfile::TempDir::new().unwrap();
144 std::fs::write(dir.path().join("file.txt"), "some content").unwrap();
145
146 let secrets = BTreeMap::new();
147 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
148 assert!(findings.is_empty());
149 }
150
151 #[test]
152 fn scan_multiple_findings() {
153 let dir = tempfile::TempDir::new().unwrap();
154 std::fs::write(
155 dir.path().join("a.env"),
156 "KEY1=secretvalue1\nKEY2=secretvalue2",
157 )
158 .unwrap();
159
160 let mut secrets = BTreeMap::new();
161 secrets.insert("K1".into(), Zeroizing::new("secretvalue1".to_string()));
162 secrets.insert("K2".into(), Zeroizing::new("secretvalue2".to_string()));
163
164 let findings = scan_for_leaks(&[dir.path().to_str().unwrap()], &secrets, 8);
165 assert_eq!(findings.len(), 2);
166 }
167}