Skip to main content

murk_cli/
scan.rs

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