Skip to main content

murk_cli/
scan.rs

1//! Scan files for leaked secret values.
2
3use std::collections::BTreeMap;
4
5use zeroize::Zeroizing;
6
7/// A single scan finding: a secret key was found in a file.
8#[derive(Debug)]
9pub struct ScanFinding {
10    /// The secret key name that was found.
11    pub key: String,
12    /// The file path where the value was found.
13    pub path: String,
14}
15
16/// Scan files under the given paths for leaked secret values.
17///
18/// Skips hidden directories, `target/`, `node_modules/`, `.murk` files,
19/// `.lock` files, and binary/unreadable files. Values shorter than
20/// `min_length` are skipped to reduce false positives.
21pub 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}