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