1use std::collections::HashMap;
2use std::process::Command;
3use std::path::Path;
4use std::fs;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use log::{info, warn, error, debug};
10use rustsec;
11use rayon::prelude::*;
12
13use crate::analyzer::dependency_parser::{DependencyInfo, DependencyType, Language};
14use crate::analyzer::tool_installer::ToolInstaller;
15
16#[derive(Debug, Error)]
17pub enum VulnerabilityError {
18 #[error("Failed to check vulnerabilities: {0}")]
19 CheckFailed(String),
20
21 #[error("API error: {0}")]
22 ApiError(String),
23
24 #[error("Command execution failed: {0}")]
25 CommandError(String),
26
27 #[error("Parse error: {0}")]
28 ParseError(String),
29
30 #[error("IO error: {0}")]
31 Io(#[from] std::io::Error),
32
33 #[error("Rustsec error: {0}")]
34 Rustsec(#[from] rustsec::Error),
35
36 #[error("JSON error: {0}")]
37 Json(#[from] serde_json::Error),
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct VulnerabilityInfo {
42 pub id: String,
43 pub severity: VulnerabilitySeverity,
44 pub title: String,
45 pub description: String,
46 pub cve: Option<String>,
47 pub ghsa: Option<String>,
48 pub affected_versions: String,
49 pub patched_versions: Option<String>,
50 pub published_date: Option<DateTime<Utc>>,
51 pub references: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
55pub enum VulnerabilitySeverity {
56 Critical,
57 High,
58 Medium,
59 Low,
60 Info,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64pub struct VulnerabilityReport {
65 pub checked_at: DateTime<Utc>,
66 pub total_vulnerabilities: usize,
67 pub critical_count: usize,
68 pub high_count: usize,
69 pub medium_count: usize,
70 pub low_count: usize,
71 pub vulnerable_dependencies: Vec<VulnerableDependency>,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75pub struct VulnerableDependency {
76 pub name: String,
77 pub version: String,
78 pub language: Language,
79 pub vulnerabilities: Vec<VulnerabilityInfo>,
80}
81
82pub struct VulnerabilityChecker;
83
84impl VulnerabilityChecker {
85 pub fn new() -> Self {
86 Self
87 }
88
89 pub async fn check_all_dependencies(
91 &self,
92 dependencies: &HashMap<Language, Vec<DependencyInfo>>,
93 project_path: &Path,
94 ) -> Result<VulnerabilityReport, VulnerabilityError> {
95 info!("Starting comprehensive vulnerability check");
96
97 debug!("Dependencies found by language:");
99 for (lang, deps) in dependencies {
100 debug!(" {:?}: {} dependencies", lang, deps.len());
101 if deps.len() > 0 {
102 debug!(" Sample dependencies:");
103 for dep in deps.iter().take(3) {
104 debug!(" - {} v{}", dep.name, dep.version);
105 }
106 }
107 }
108
109 let mut installer = ToolInstaller::new();
111 let languages: Vec<Language> = dependencies.keys().cloned().collect();
112
113 info!("🔧 Checking and installing required vulnerability scanning tools...");
114 installer.ensure_tools_for_languages(&languages)
115 .map_err(|e| VulnerabilityError::CommandError(format!("Tool installation failed: {}", e)))?;
116
117 installer.print_tool_status(&languages);
119
120 let mut all_vulnerable_deps = Vec::new();
121
122 let results: Vec<_> = dependencies.par_iter()
124 .map(|(language, deps)| {
125 self.check_language_dependencies(language, deps, project_path)
126 })
127 .collect();
128
129 for result in results {
131 match result {
132 Ok(mut vuln_deps) => all_vulnerable_deps.append(&mut vuln_deps),
133 Err(e) => warn!("Error checking vulnerabilities: {}", e),
134 }
135 }
136
137 all_vulnerable_deps.sort_by(|a, b| {
139 let a_max = a.vulnerabilities.iter()
140 .map(|v| &v.severity)
141 .max()
142 .unwrap_or(&VulnerabilitySeverity::Info);
143 let b_max = b.vulnerabilities.iter()
144 .map(|v| &v.severity)
145 .max()
146 .unwrap_or(&VulnerabilitySeverity::Info);
147 b_max.cmp(a_max)
148 });
149
150 let mut critical_count = 0;
152 let mut high_count = 0;
153 let mut medium_count = 0;
154 let mut low_count = 0;
155 let mut total_vulnerabilities = 0;
156
157 for dep in &all_vulnerable_deps {
158 for vuln in &dep.vulnerabilities {
159 total_vulnerabilities += 1;
160 match vuln.severity {
161 VulnerabilitySeverity::Critical => critical_count += 1,
162 VulnerabilitySeverity::High => high_count += 1,
163 VulnerabilitySeverity::Medium => medium_count += 1,
164 VulnerabilitySeverity::Low => low_count += 1,
165 VulnerabilitySeverity::Info => {},
166 }
167 }
168 }
169
170 Ok(VulnerabilityReport {
171 checked_at: Utc::now(),
172 total_vulnerabilities,
173 critical_count,
174 high_count,
175 medium_count,
176 low_count,
177 vulnerable_dependencies: all_vulnerable_deps,
178 })
179 }
180
181 fn check_language_dependencies(
182 &self,
183 language: &Language,
184 dependencies: &[DependencyInfo],
185 project_path: &Path,
186 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
187 info!("Checking {} dependencies for {:?}", dependencies.len(), language);
188
189 match language {
190 Language::Rust => self.check_rust_dependencies(dependencies),
191 Language::JavaScript | Language::TypeScript => {
192 self.check_npm_dependencies(dependencies, project_path)
193 },
194 Language::Python => self.check_python_dependencies(dependencies, project_path),
195 Language::Go => self.check_go_dependencies(dependencies, project_path),
196 Language::Java | Language::Kotlin => {
197 self.check_java_dependencies(dependencies, project_path)
198 },
199 _ => {
200 warn!("Vulnerability checking not yet implemented for {:?}", language);
201 Ok(vec![])
202 }
203 }
204 }
205
206 fn check_rust_dependencies(
208 &self,
209 dependencies: &[DependencyInfo],
210 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
211 info!("Checking Rust dependencies with cargo-audit");
212
213 let check_output = Command::new("cargo")
215 .args(&["audit", "--version"])
216 .output();
217
218 if check_output.is_err() || !check_output.unwrap().status.success() {
219 warn!("cargo-audit not installed. Install with: cargo install cargo-audit");
220 warn!("Skipping Rust vulnerability checks");
221 return Ok(vec![]);
222 }
223
224 let output = Command::new("cargo")
226 .args(&["audit", "--json"])
227 .output()
228 .map_err(|e| VulnerabilityError::CommandError(
229 format!("Failed to run cargo audit: {}", e)
230 ))?;
231
232 if output.stdout.is_empty() {
233 return Ok(vec![]);
234 }
235
236 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;
238
239 self.parse_cargo_audit_output(&audit_data, dependencies)
240 }
241
242 fn check_npm_dependencies(
244 &self,
245 dependencies: &[DependencyInfo],
246 project_path: &Path,
247 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
248 info!("Checking npm dependencies with npm audit");
249
250 let package_json_path = project_path.join("package.json");
252 if !package_json_path.exists() {
253 debug!("No package.json found, skipping npm audit");
254 return Ok(vec![]);
255 }
256
257 let output = Command::new("npm")
259 .args(&["audit", "--json"])
260 .current_dir(project_path)
261 .output()
262 .map_err(|e| VulnerabilityError::CommandError(
263 format!("Failed to run npm audit: {}", e)
264 ))?;
265
266 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;
268
269 self.parse_npm_audit_output(&audit_data, dependencies)
270 }
271
272 fn check_python_dependencies(
274 &self,
275 dependencies: &[DependencyInfo],
276 project_path: &Path,
277 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
278 info!("Checking Python dependencies with pip-audit");
279
280 let requirements_file = project_path.join("requirements.txt");
282 if !requirements_file.exists() {
283 debug!("No requirements.txt found, creating temporary file");
284
285 let temp_req = project_path.join(".temp_requirements_for_audit.txt");
287 let mut content = String::new();
288
289 for dep in dependencies {
290 if dep.dep_type == DependencyType::Production {
291 content.push_str(&format!("{}=={}\n", dep.name, dep.version));
292 }
293 }
294
295 fs::write(&temp_req, content)?;
296
297 let output = Command::new("pip-audit")
299 .args(&["-r", temp_req.to_str().unwrap(), "--format", "json"])
300 .output()
301 .map_err(|e| {
302 let _ = fs::remove_file(&temp_req);
304 VulnerabilityError::CommandError(
305 format!("Failed to run pip-audit (is it installed?): {}", e)
306 )
307 })?;
308
309 let _ = fs::remove_file(&temp_req);
311
312 if !output.status.success() && output.stdout.is_empty() {
313 let stderr = String::from_utf8_lossy(&output.stderr);
314 return Err(VulnerabilityError::CommandError(
315 format!("pip-audit failed: {}", stderr)
316 ));
317 }
318
319 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;
321 return self.parse_pip_audit_output(&audit_data, dependencies);
322 }
323
324 let output = Command::new("pip-audit")
326 .args(&["-r", requirements_file.to_str().unwrap(), "--format", "json"])
327 .current_dir(project_path)
328 .output()
329 .map_err(|e| VulnerabilityError::CommandError(
330 format!("Failed to run pip-audit (is it installed?): {}", e)
331 ))?;
332
333 if !output.status.success() && output.stdout.is_empty() {
334 let stderr = String::from_utf8_lossy(&output.stderr);
335 return Err(VulnerabilityError::CommandError(
336 format!("pip-audit failed: {}", stderr)
337 ));
338 }
339
340 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;
342
343 self.parse_pip_audit_output(&audit_data, dependencies)
344 }
345
346 fn check_go_dependencies(
348 &self,
349 dependencies: &[DependencyInfo],
350 project_path: &Path,
351 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
352 info!("Checking Go dependencies with govulncheck");
353
354 let go_mod_path = project_path.join("go.mod");
356 if !go_mod_path.exists() {
357 debug!("No go.mod found, skipping govulncheck");
358 return Ok(vec![]);
359 }
360
361 let govulncheck_commands = vec![
363 "govulncheck".to_string(),
364 format!("{}/go/bin/govulncheck", std::env::var("HOME").unwrap_or_else(|_| ".".to_string())),
365 ];
366
367 let mut last_error = None;
368
369 for govulncheck_cmd in govulncheck_commands {
370 debug!("Trying govulncheck command: {}", govulncheck_cmd);
371
372 let output = Command::new(&govulncheck_cmd)
374 .args(&["-json", "./..."])
375 .current_dir(project_path)
376 .output();
377
378 match output {
379 Ok(result) => {
380
381
382 if result.status.success() || !result.stdout.is_empty() {
383 info!("Successfully ran govulncheck using: {}", govulncheck_cmd);
384 return self.parse_govulncheck_output(&result.stdout, dependencies);
385 } else {
386 let stderr = String::from_utf8_lossy(&result.stderr);
387 debug!("govulncheck failed with {}: {}", govulncheck_cmd, stderr);
388 last_error = Some(format!("govulncheck failed: {}", stderr));
389 }
390 }
391 Err(e) => {
392 debug!("Could not execute {}: {}", govulncheck_cmd, e);
393 last_error = Some(format!("Failed to run govulncheck: {}", e));
394 }
395 }
396 }
397
398 if let Some(error) = last_error {
400 warn!("govulncheck not available: {}", error);
401 warn!("Install with: go install golang.org/x/vuln/cmd/govulncheck@latest");
402 warn!("Make sure ~/go/bin is in your PATH");
403 }
404
405 Ok(vec![])
406 }
407
408 fn check_java_dependencies(
410 &self,
411 dependencies: &[DependencyInfo],
412 project_path: &Path,
413 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
414 info!("Checking Java dependencies with multiple scanners");
415
416 debug!("Attempting grype scan for Java dependencies");
418 let grype_result = self.check_java_with_grype(dependencies, project_path);
419
420 match grype_result {
421 Ok(vulnerabilities) if !vulnerabilities.is_empty() => {
422 info!("Found {} vulnerabilities with grype", vulnerabilities.len());
423 return Ok(vulnerabilities);
424 }
425 Ok(_) => {
426 warn!("grype found no vulnerabilities for {} Java dependencies", dependencies.len());
427 debug!("This could indicate:");
428 debug!(" - Dependencies are secure (unlikely for {} deps)", dependencies.len());
429 debug!(" - grype's Java vulnerability database is incomplete");
430 debug!(" - Project needs to be built for better scanning");
431 }
432 Err(e) => {
433 warn!("grype scan failed: {}", e);
434 }
435 }
436
437 info!("Attempting OWASP Dependency Check as fallback");
439 if let Ok(owasp_vulnerabilities) = self.check_java_with_owasp_dc(dependencies, project_path) {
440 if !owasp_vulnerabilities.is_empty() {
441 info!("Found {} vulnerabilities with OWASP Dependency Check", owasp_vulnerabilities.len());
442 return Ok(owasp_vulnerabilities);
443 }
444 }
445
446 info!("Checking against known vulnerable packages");
448 let known_vulns = self.check_known_vulnerable_java_packages(dependencies);
449 if !known_vulns.is_empty() {
450 warn!("Found {} known vulnerable packages that scanners missed!", known_vulns.len());
451 return Ok(known_vulns);
452 }
453
454 warn!("No vulnerabilities found by any scanner for {} Java dependencies", dependencies.len());
455 warn!("Consider:");
456 warn!(" 1. Building the project: mvn package");
457 warn!(" 2. Using a different scanner like Snyk");
458 warn!(" 3. Checking dependencies manually");
459
460 Ok(vec![])
461 }
462
463 fn check_java_with_grype(
465 &self,
466 dependencies: &[DependencyInfo],
467 project_path: &Path,
468 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
469 let grype_home = format!("{}/.local/bin/grype", std::env::var("HOME").unwrap_or_default());
471 let grype_cmds = vec![
472 "grype",
473 grype_home.as_str(),
474 ];
475
476 let mut last_error = None;
477
478 for grype_cmd in &grype_cmds {
479 let check_output = Command::new(grype_cmd)
481 .arg("version")
482 .output();
483
484 if check_output.is_err() || !check_output.unwrap().status.success() {
485 continue;
486 }
487
488 let maven_repo_path = format!("{}/.m2/repository", std::env::var("HOME").unwrap_or_default());
490 let scan_approaches = vec![
491 (vec!["dir:.", "-o", "json", "--only-fixed=false", "--only-notfixed=false"], "project directory"),
493 (vec![maven_repo_path.as_str(), "-o", "json"], "Maven repository"),
495 ];
496
497 for (args, description) in scan_approaches {
498 debug!("Trying grype on {} with command: {} {}", description, grype_cmd, args.join(" "));
499
500 let output = Command::new(grype_cmd)
501 .args(&args)
502 .current_dir(project_path)
503 .output();
504
505 match output {
506 Ok(result) => {
507 if result.status.success() || !result.stdout.is_empty() {
508 debug!("grype scan of {} completed", description);
509 let vulnerabilities = self.parse_grype_output(&result.stdout, dependencies, Language::Java)?;
510 if !vulnerabilities.is_empty() {
511 info!("Found {} vulnerabilities scanning {}", vulnerabilities.len(), description);
512 return Ok(vulnerabilities);
513 } else {
514 debug!("No vulnerabilities found scanning {}", description);
515 }
516 } else {
517 let stderr = String::from_utf8_lossy(&result.stderr);
518 debug!("grype scan of {} failed: {}", description, stderr);
519 last_error = Some(format!("grype failed on {}: {}", description, stderr));
520 }
521 }
522 Err(e) => {
523 debug!("Failed to run grype {} on {}: {}", grype_cmd, description, e);
524 last_error = Some(format!("Failed to run grype: {}", e));
525 }
526 }
527 }
528 }
529
530 if let Some(err) = last_error {
532 return Err(VulnerabilityError::CommandError(err));
533 }
534
535 warn!("grype not installed. Install with: brew install grype");
536 Ok(vec![])
537 }
538
539 fn check_java_with_owasp_dc(
541 &self,
542 dependencies: &[DependencyInfo],
543 project_path: &Path,
544 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
545 let dc_cmds = vec![
547 "dependency-check",
548 "dependency-check.sh",
549 "/opt/homebrew/bin/dependency-check",
550 ];
551
552 for dc_cmd in dc_cmds {
553 let check_output = Command::new(dc_cmd)
554 .arg("--version")
555 .output();
556
557 if check_output.is_ok() && check_output.unwrap().status.success() {
558 debug!("Found OWASP Dependency Check: {}", dc_cmd);
559
560 let output = Command::new(dc_cmd)
562 .args(&[
563 "--project", "vulnerability-scan",
564 "--scan", ".",
565 "--format", "JSON",
566 "--out", "./dependency-check-report",
567 "--enableRetired",
568 ])
569 .current_dir(project_path)
570 .output();
571
572 match output {
573 Ok(result) if result.status.success() => {
574 let report_file = project_path.join("dependency-check-report").join("dependency-check-report.json");
575 if report_file.exists() {
576 let report_content = fs::read_to_string(&report_file)?;
577 let report_data: serde_json::Value = serde_json::from_str(&report_content)?;
578
579 let _ = fs::remove_dir_all(project_path.join("dependency-check-report"));
581
582 return self.parse_owasp_dependency_check_output(&report_data, dependencies);
583 }
584 }
585 _ => {
586 debug!("OWASP Dependency Check failed or not configured properly");
587 }
588 }
589 }
590 }
591
592 debug!("OWASP Dependency Check not available");
593 Ok(vec![])
594 }
595
596 fn check_known_vulnerable_java_packages(
598 &self,
599 dependencies: &[DependencyInfo],
600 ) -> Vec<VulnerableDependency> {
601 let mut vulnerable_deps = Vec::new();
602
603 let known_vulnerabilities = vec![
605 ("io.jsonwebtoken:jjwt", "0.9.1", vec![
606 VulnerabilityInfo {
607 id: "CVE-2019-7644".to_string(),
608 severity: VulnerabilitySeverity::High,
609 title: "JWT signature verification bypass in JJWT".to_string(),
610 description: "JJWT before 0.10.5 allows attackers to bypass signature verification by providing a public key that the attacker controls.".to_string(),
611 cve: Some("CVE-2019-7644".to_string()),
612 ghsa: Some("GHSA-3p3g-vpw6-4w66".to_string()),
613 affected_versions: "< 0.10.5".to_string(),
614 patched_versions: Some(">= 0.10.5".to_string()),
615 published_date: None,
616 references: vec![
617 "https://github.com/jwtk/jjwt/issues/515".to_string(),
618 "https://nvd.nist.gov/vuln/detail/CVE-2019-7644".to_string(),
619 ],
620 },
621 ]),
622 ("org.apache.logging.log4j:log4j-core", "2.17.1", vec![
623 VulnerabilityInfo {
624 id: "CVE-2021-44228".to_string(),
625 severity: VulnerabilitySeverity::Critical,
626 title: "Log4j Remote Code Execution (Log4Shell)".to_string(),
627 description: "Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.".to_string(),
628 cve: Some("CVE-2021-44228".to_string()),
629 ghsa: Some("GHSA-jfh8-c2jp-5v3q".to_string()),
630 affected_versions: ">= 2.0-beta9, <= 2.15.0".to_string(),
631 patched_versions: Some(">= 2.17.1".to_string()),
632 published_date: None,
633 references: vec![
634 "https://logging.apache.org/log4j/2.x/security.html".to_string(),
635 "https://nvd.nist.gov/vuln/detail/CVE-2021-44228".to_string(),
636 ],
637 },
638 ]),
639 ("com.fasterxml.jackson.core:jackson-databind", "2.14.2", vec![
640 VulnerabilityInfo {
641 id: "CVE-2022-42003".to_string(),
642 severity: VulnerabilitySeverity::High,
643 title: "Jackson Databind deserialization vulnerability".to_string(),
644 description: "In FasterXML jackson-databind before versions 2.13.4.1 and 2.14.0-rc1, resource exhaustion can occur because of a lack of a check in primitive value deserializers to avoid deep wrapper array nesting.".to_string(),
645 cve: Some("CVE-2022-42003".to_string()),
646 ghsa: Some("GHSA-jjjh-jjxp-wpff".to_string()),
647 affected_versions: "< 2.13.4.1".to_string(),
648 patched_versions: Some(">= 2.13.4.1".to_string()),
649 published_date: None,
650 references: vec![
651 "https://github.com/FasterXML/jackson-databind/issues/3582".to_string(),
652 "https://nvd.nist.gov/vuln/detail/CVE-2022-42003".to_string(),
653 ],
654 },
655 ]),
656 ];
657
658 for (package_name, _vulnerable_version, vulns) in known_vulnerabilities {
659 if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
660 debug!("Found known vulnerable package: {} v{}", dep.name, dep.version);
661 vulnerable_deps.push(VulnerableDependency {
662 name: dep.name.clone(),
663 version: dep.version.clone(),
664 language: Language::Java,
665 vulnerabilities: vulns,
666 });
667 }
668 }
669
670 vulnerable_deps
671 }
672
673 #[allow(dead_code)]
674 fn map_rustsec_severity(&self, severity: &Option<rustsec::advisory::Severity>) -> VulnerabilitySeverity {
675 match severity {
676 Some(rustsec::advisory::Severity::Critical) => VulnerabilitySeverity::Critical,
677 Some(rustsec::advisory::Severity::High) => VulnerabilitySeverity::High,
678 Some(rustsec::advisory::Severity::Medium) => VulnerabilitySeverity::Medium,
679 Some(rustsec::advisory::Severity::Low) => VulnerabilitySeverity::Low,
680 Some(rustsec::advisory::Severity::None) | None => VulnerabilitySeverity::Info,
681 }
682 }
683
684 fn parse_npm_audit_output(
685 &self,
686 audit_data: &serde_json::Value,
687 dependencies: &[DependencyInfo],
688 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
689 let mut vulnerable_deps = Vec::new();
690
691 if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
692 for (pkg_name, vuln_data) in vulnerabilities {
693 if let Some(dep) = dependencies.iter().find(|d| d.name == *pkg_name) {
694 let mut vuln_infos = Vec::new();
695
696 if let Some(via) = vuln_data.get("via").and_then(|v| v.as_array()) {
697 for item in via {
698 if let Some(obj) = item.as_object() {
699 vuln_infos.push(VulnerabilityInfo {
700 id: obj.get("source")
701 .and_then(|s| s.as_str())
702 .unwrap_or("unknown")
703 .to_string(),
704 severity: self.parse_npm_severity(
705 obj.get("severity")
706 .and_then(|s| s.as_str())
707 .unwrap_or("low")
708 ),
709 title: obj.get("title")
710 .and_then(|s| s.as_str())
711 .unwrap_or("Unknown vulnerability")
712 .to_string(),
713 description: obj.get("overview")
714 .and_then(|s| s.as_str())
715 .unwrap_or("")
716 .to_string(),
717 cve: obj.get("cve")
718 .and_then(|s| s.as_str())
719 .map(|s| s.to_string()),
720 ghsa: obj.get("ghsa")
721 .and_then(|s| s.as_str())
722 .map(|s| s.to_string()),
723 affected_versions: obj.get("vulnerable_versions")
724 .and_then(|s| s.as_str())
725 .unwrap_or("*")
726 .to_string(),
727 patched_versions: obj.get("patched_versions")
728 .and_then(|s| s.as_str())
729 .map(|s| s.to_string()),
730 published_date: None,
731 references: vec![],
732 });
733 }
734 }
735 }
736
737 if !vuln_infos.is_empty() {
738 vulnerable_deps.push(VulnerableDependency {
739 name: dep.name.clone(),
740 version: dep.version.clone(),
741 language: Language::JavaScript,
742 vulnerabilities: vuln_infos,
743 });
744 }
745 }
746 }
747 }
748
749 Ok(vulnerable_deps)
750 }
751
752 fn parse_pip_audit_output(
753 &self,
754 audit_data: &serde_json::Value,
755 dependencies: &[DependencyInfo],
756 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
757 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
758
759 if let Some(deps) = audit_data.get("dependencies").and_then(|d| d.as_array()) {
761 for dep_obj in deps {
762 if let Some(dep_data) = dep_obj.as_object() {
763 let name = dep_data.get("name")
764 .and_then(|n| n.as_str())
765 .unwrap_or("")
766 .to_string();
767
768 let version = dep_data.get("version")
769 .and_then(|v| v.as_str())
770 .unwrap_or("")
771 .to_string();
772
773 if let Some(vulns) = dep_data.get("vulns").and_then(|v| v.as_array()) {
774 if vulns.is_empty() {
775 continue;
776 }
777
778 if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
779 let mut vuln_infos = Vec::new();
780
781 for vuln in vulns {
782 if let Some(vuln_obj) = vuln.as_object() {
783 vuln_infos.push(VulnerabilityInfo {
784 id: vuln_obj.get("id")
785 .and_then(|s| s.as_str())
786 .unwrap_or("unknown")
787 .to_string(),
788 severity: self.parse_pip_severity(
789 vuln_obj.get("severity")
790 .and_then(|s| s.as_str())
791 ),
792 title: vuln_obj.get("description")
793 .and_then(|s| s.as_str())
794 .unwrap_or("Unknown vulnerability")
795 .to_string(),
796 description: vuln_obj.get("description")
797 .and_then(|s| s.as_str())
798 .unwrap_or("")
799 .to_string(),
800 cve: vuln_obj.get("aliases")
801 .and_then(|a| a.as_array())
802 .and_then(|arr| {
803 let cve_aliases: Vec<&str> = arr.iter()
804 .filter_map(|v| v.as_str())
805 .filter(|s| s.starts_with("CVE-"))
806 .collect();
807 cve_aliases.first().map(|s| s.to_string())
808 }),
809 ghsa: vuln_obj.get("aliases")
810 .and_then(|a| a.as_array())
811 .and_then(|arr| {
812 let ghsa_aliases: Vec<&str> = arr.iter()
813 .filter_map(|v| v.as_str())
814 .filter(|s| s.starts_with("GHSA-"))
815 .collect();
816 ghsa_aliases.first().map(|s| s.to_string())
817 }),
818 affected_versions: vuln_obj.get("fix_versions")
819 .and_then(|f| f.as_array())
820 .and_then(|arr| arr.first())
821 .and_then(|s| s.as_str())
822 .map(|s| format!("< {}", s))
823 .unwrap_or_else(|| "*".to_string()),
824 patched_versions: vuln_obj.get("fix_versions")
825 .and_then(|f| f.as_array())
826 .and_then(|arr| arr.first())
827 .and_then(|s| s.as_str())
828 .map(|s| s.to_string()),
829 published_date: None,
830 references: vec![],
831 });
832 }
833 }
834
835 if !vuln_infos.is_empty() {
836 vulnerable_deps.push(VulnerableDependency {
837 name: dep.name.clone(),
838 version: dep.version.clone(),
839 language: Language::Python,
840 vulnerabilities: vuln_infos,
841 });
842 }
843 }
844 }
845 }
846 }
847 }
848
849 Ok(vulnerable_deps)
850 }
851
852 fn parse_govulncheck_output(
853 &self,
854 output: &[u8],
855 dependencies: &[DependencyInfo],
856 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
857 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
858 let output_str = String::from_utf8_lossy(output);
859
860 let mut current_json = String::new();
863 let mut brace_count = 0;
864
865 for line in output_str.lines() {
866 let trimmed = line.trim();
867 if trimmed.is_empty() {
868 continue;
869 }
870
871 current_json.push_str(line);
872 current_json.push('\n');
873
874 for ch in line.chars() {
876 match ch {
877 '{' => brace_count += 1,
878 '}' => brace_count -= 1,
879 _ => {}
880 }
881 }
882
883 if brace_count == 0 && !current_json.trim().is_empty() {
885 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(¤t_json) {
886
887
888 if let Some(obj) = json_val.as_object() {
889 if obj.contains_key("finding") {
891 if let Some(finding) = obj.get("finding").and_then(|f| f.as_object()) {
892 let osv_id = finding.get("osv")
893 .and_then(|s| s.as_str())
894 .unwrap_or("unknown");
895
896
897
898 if vulnerable_deps.iter().any(|dep|
900 dep.vulnerabilities.iter().any(|v| v.id == osv_id)
901 ) {
902
903 current_json.clear();
905 continue;
906 }
907
908 if let Some(trace) = finding.get("trace").and_then(|t| t.as_array()) {
910 if let Some(first_trace) = trace.first().and_then(|t| t.as_object()) {
911 let module_path = first_trace.get("module")
912 .and_then(|m| m.as_str())
913 .unwrap_or("");
914
915 let module_version = first_trace.get("version")
916 .and_then(|v| v.as_str())
917 .unwrap_or("");
918
919 if let Some(dep) = dependencies.iter().find(|d| {
921 let matches = module_path.contains(&d.name) ||
922 d.name.contains(module_path) ||
923 d.name == module_path;
924
925
926
927 matches
928 }) {
929 let fixed_version = finding.get("fixed_version")
930 .and_then(|v| v.as_str())
931 .map(|v| v.to_string());
932
933 let vuln_info = VulnerabilityInfo {
934 id: osv_id.to_string(),
935 severity: VulnerabilitySeverity::High, title: format!("Vulnerability {} in {}", osv_id, module_path),
937 description: format!("Vulnerability {} found in module {} version {}", osv_id, module_path, module_version),
938 cve: None,
939 ghsa: None,
940 affected_versions: format!("< {}", fixed_version.as_deref().unwrap_or("unknown")),
941 patched_versions: fixed_version,
942 published_date: None,
943 references: vec![format!("https://pkg.go.dev/vuln/{}", osv_id)],
944 };
945
946 if let Some(existing) = vulnerable_deps.iter_mut()
948 .find(|vuln_dep: &&mut VulnerableDependency| vuln_dep.name == dep.name)
949 {
950 existing.vulnerabilities.push(vuln_info);
951 } else {
952 vulnerable_deps.push(VulnerableDependency {
953 name: dep.name.clone(),
954 version: dep.version.clone(),
955 language: Language::Go,
956 vulnerabilities: vec![vuln_info],
957 });
958 }
959 }
960 }
961 }
962 }
963 }
964 }
965 }
966
967 current_json.clear();
969 }
970 }
971
972 Ok(vulnerable_deps)
973 }
974
975 fn parse_npm_severity(&self, severity: &str) -> VulnerabilitySeverity {
976 match severity.to_lowercase().as_str() {
977 "critical" => VulnerabilitySeverity::Critical,
978 "high" => VulnerabilitySeverity::High,
979 "moderate" | "medium" => VulnerabilitySeverity::Medium,
980 "low" => VulnerabilitySeverity::Low,
981 _ => VulnerabilitySeverity::Info,
982 }
983 }
984
985 fn parse_pip_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
986 match severity.map(|s| s.to_lowercase()).as_deref() {
987 Some("critical") => VulnerabilitySeverity::Critical,
988 Some("high") => VulnerabilitySeverity::High,
989 Some("medium") | Some("moderate") => VulnerabilitySeverity::Medium,
990 Some("low") => VulnerabilitySeverity::Low,
991 _ => VulnerabilitySeverity::Medium, }
993 }
994
995 fn parse_osv_severity(&self, osv: &serde_json::Map<String, serde_json::Value>) -> VulnerabilitySeverity {
996 if let Some(severity) = osv.get("database_specific")
998 .and_then(|d| d.get("severity"))
999 .and_then(|s| s.as_str())
1000 {
1001 return self.parse_npm_severity(severity);
1002 }
1003
1004 VulnerabilitySeverity::High
1006 }
1007
1008 fn parse_cargo_audit_output(
1009 &self,
1010 audit_data: &serde_json::Value,
1011 dependencies: &[DependencyInfo],
1012 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
1013 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
1014
1015 if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.get("list")).and_then(|l| l.as_array()) {
1016 for vuln in vulnerabilities {
1017 if let Some(advisory) = vuln.get("advisory") {
1018 let package_name = advisory.get("package")
1019 .and_then(|n| n.as_str())
1020 .unwrap_or("");
1021
1022 let package_version = vuln.get("package")
1023 .and_then(|p| p.get("version"))
1024 .and_then(|v| v.as_str())
1025 .unwrap_or("");
1026
1027 if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
1028 let vuln_info = VulnerabilityInfo {
1029 id: advisory.get("id")
1030 .and_then(|id| id.as_str())
1031 .unwrap_or("unknown")
1032 .to_string(),
1033 severity: self.parse_rustsec_severity(
1034 advisory.get("severity")
1035 .and_then(|s| s.as_str())
1036 ),
1037 title: advisory.get("title")
1038 .and_then(|t| t.as_str())
1039 .unwrap_or("Unknown vulnerability")
1040 .to_string(),
1041 description: advisory.get("description")
1042 .and_then(|d| d.as_str())
1043 .unwrap_or("")
1044 .to_string(),
1045 cve: advisory.get("aliases")
1046 .and_then(|a| a.as_array())
1047 .and_then(|arr| arr.iter()
1048 .filter_map(|v| v.as_str())
1049 .find(|s| s.starts_with("CVE-"))
1050 .map(|s| s.to_string())),
1051 ghsa: advisory.get("aliases")
1052 .and_then(|a| a.as_array())
1053 .and_then(|arr| arr.iter()
1054 .filter_map(|v| v.as_str())
1055 .find(|s| s.starts_with("GHSA-"))
1056 .map(|s| s.to_string())),
1057 affected_versions: format!("< {}",
1058 vuln.get("versions")
1059 .and_then(|v| v.get("patched"))
1060 .and_then(|p| p.as_array())
1061 .and_then(|arr| arr.first())
1062 .and_then(|s| s.as_str())
1063 .unwrap_or("unknown")
1064 ),
1065 patched_versions: vuln.get("versions")
1066 .and_then(|v| v.get("patched"))
1067 .and_then(|p| p.as_array())
1068 .and_then(|arr| arr.first())
1069 .and_then(|s| s.as_str())
1070 .map(|s| s.to_string()),
1071 published_date: advisory.get("date")
1072 .and_then(|d| d.as_str())
1073 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
1074 .map(|dt| dt.with_timezone(&Utc)),
1075 references: advisory.get("references")
1076 .and_then(|r| r.as_array())
1077 .map(|refs| refs.iter()
1078 .filter_map(|r| r.as_str().map(|s| s.to_string()))
1079 .collect())
1080 .unwrap_or_default(),
1081 };
1082
1083 if let Some(existing) = vulnerable_deps.iter_mut()
1085 .find(|vuln_dep: &&mut VulnerableDependency| vuln_dep.name == dep.name && vuln_dep.version == package_version)
1086 {
1087 existing.vulnerabilities.push(vuln_info);
1088 } else {
1089 vulnerable_deps.push(VulnerableDependency {
1090 name: dep.name.clone(),
1091 version: package_version.to_string(),
1092 language: Language::Rust,
1093 vulnerabilities: vec![vuln_info],
1094 });
1095 }
1096 }
1097 }
1098 }
1099 }
1100
1101 Ok(vulnerable_deps)
1102 }
1103
1104 fn parse_rustsec_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
1105 match severity.map(|s| s.to_lowercase()).as_deref() {
1106 Some("critical") => VulnerabilitySeverity::Critical,
1107 Some("high") => VulnerabilitySeverity::High,
1108 Some("medium") | Some("moderate") => VulnerabilitySeverity::Medium,
1109 Some("low") => VulnerabilitySeverity::Low,
1110 _ => VulnerabilitySeverity::Medium, }
1112 }
1113
1114 fn parse_grype_output(
1115 &self,
1116 output: &[u8],
1117 dependencies: &[DependencyInfo],
1118 language: Language,
1119 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
1120 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
1121 let output_str = String::from_utf8_lossy(output);
1122
1123 let grype_data: serde_json::Value = serde_json::from_str(&output_str)
1125 .map_err(|e| VulnerabilityError::ParseError(
1126 format!("Failed to parse grype output: {}", e)
1127 ))?;
1128
1129 if let Some(matches) = grype_data.get("matches").and_then(|m| m.as_array()) {
1131 for match_obj in matches {
1132 if let Some(obj) = match_obj.as_object() {
1133 let artifact_name = obj.get("artifact")
1135 .and_then(|a| a.get("name"))
1136 .and_then(|n| n.as_str())
1137 .unwrap_or("");
1138
1139 let artifact_version = obj.get("artifact")
1140 .and_then(|a| a.get("version"))
1141 .and_then(|v| v.as_str())
1142 .unwrap_or("");
1143
1144 if let Some(dep) = dependencies.iter().find(|d| {
1146 artifact_name.contains(&d.name) ||
1148 d.name.contains(artifact_name) ||
1149 d.name.split(':').last() == Some(artifact_name)
1150 }) {
1151 if let Some(vuln_obj) = obj.get("vulnerability").and_then(|v| v.as_object()) {
1153 let vuln_id = vuln_obj.get("id")
1154 .and_then(|id| id.as_str())
1155 .unwrap_or("unknown")
1156 .to_string();
1157
1158 let severity = vuln_obj.get("severity")
1159 .and_then(|s| s.as_str())
1160 .map(|s| self.parse_grype_severity(s))
1161 .unwrap_or(VulnerabilitySeverity::Medium);
1162
1163 let description = vuln_obj.get("description")
1164 .and_then(|d| d.as_str())
1165 .unwrap_or("")
1166 .to_string();
1167
1168 let fix_versions = vuln_obj.get("fix")
1169 .and_then(|f| f.get("versions"))
1170 .and_then(|v| v.as_array())
1171 .map(|versions| {
1172 versions.iter()
1173 .filter_map(|v| v.as_str())
1174 .collect::<Vec<_>>()
1175 .join(", ")
1176 });
1177
1178 let vuln_info = VulnerabilityInfo {
1179 id: vuln_id.clone(),
1180 severity,
1181 title: description.clone(),
1182 description,
1183 cve: if vuln_id.starts_with("CVE-") {
1184 Some(vuln_id.clone())
1185 } else {
1186 None
1187 },
1188 ghsa: if vuln_id.starts_with("GHSA-") {
1189 Some(vuln_id.clone())
1190 } else {
1191 None
1192 },
1193 affected_versions: artifact_version.to_string(),
1194 patched_versions: fix_versions,
1195 published_date: None,
1196 references: vec![],
1197 };
1198
1199 if let Some(existing) = vulnerable_deps.iter_mut()
1201 .find(|vuln_dep| vuln_dep.name == dep.name)
1202 {
1203 if !existing.vulnerabilities.iter().any(|v| v.id == vuln_info.id) {
1205 existing.vulnerabilities.push(vuln_info);
1206 }
1207 } else {
1208 vulnerable_deps.push(VulnerableDependency {
1209 name: dep.name.clone(),
1210 version: dep.version.clone(),
1211 language: language.clone(),
1212 vulnerabilities: vec![vuln_info],
1213 });
1214 }
1215 }
1216 }
1217 }
1218 }
1219 }
1220
1221 Ok(vulnerable_deps)
1222 }
1223
1224 fn parse_grype_severity(&self, severity: &str) -> VulnerabilitySeverity {
1225 match severity.to_lowercase().as_str() {
1226 "critical" => VulnerabilitySeverity::Critical,
1227 "high" => VulnerabilitySeverity::High,
1228 "medium" => VulnerabilitySeverity::Medium,
1229 "low" => VulnerabilitySeverity::Low,
1230 "negligible" => VulnerabilitySeverity::Info,
1231 _ => VulnerabilitySeverity::Medium,
1232 }
1233 }
1234
1235 fn parse_owasp_dependency_check_output(
1236 &self,
1237 report_data: &serde_json::Value,
1238 dependencies: &[DependencyInfo],
1239 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
1240 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
1241
1242 if let Some(deps_array) = report_data.get("dependencies").and_then(|d| d.as_array()) {
1243 for dep_obj in deps_array {
1244 if let Some(vulns) = dep_obj.get("vulnerabilities").and_then(|v| v.as_array()) {
1245 if vulns.is_empty() {
1246 continue;
1247 }
1248
1249 let file_name = dep_obj.get("fileName")
1251 .and_then(|f| f.as_str())
1252 .unwrap_or("");
1253
1254 let matched_dep = dependencies.iter().find(|d| {
1256 file_name.contains(&d.name) ||
1257 dep_obj.get("packages").and_then(|p| p.as_array())
1258 .map(|packages| packages.iter().any(|pkg| {
1259 pkg.get("id").and_then(|id| id.as_str())
1260 .map(|id| id.contains(&d.name))
1261 .unwrap_or(false)
1262 }))
1263 .unwrap_or(false)
1264 });
1265
1266 if let Some(dep) = matched_dep {
1267 let mut vuln_infos = Vec::new();
1268
1269 for vuln in vulns {
1270 let severity = vuln.get("severity")
1271 .and_then(|s| s.as_str())
1272 .unwrap_or("MEDIUM");
1273
1274 vuln_infos.push(VulnerabilityInfo {
1275 id: vuln.get("name")
1276 .and_then(|n| n.as_str())
1277 .unwrap_or("unknown")
1278 .to_string(),
1279 severity: self.parse_owasp_severity(severity),
1280 title: vuln.get("description")
1281 .and_then(|d| d.as_str())
1282 .unwrap_or("Unknown vulnerability")
1283 .to_string(),
1284 description: vuln.get("notes")
1285 .and_then(|n| n.as_str())
1286 .unwrap_or("")
1287 .to_string(),
1288 cve: vuln.get("name")
1289 .and_then(|n| n.as_str())
1290 .filter(|n| n.starts_with("CVE-"))
1291 .map(|s| s.to_string()),
1292 ghsa: None,
1293 affected_versions: vuln.get("vulnerableSoftware")
1294 .and_then(|vs| vs.as_array())
1295 .and_then(|arr| arr.first())
1296 .and_then(|v| v.get("versionEndIncluding"))
1297 .and_then(|v| v.as_str())
1298 .map(|v| format!("<= {}", v))
1299 .unwrap_or_else(|| "*".to_string()),
1300 patched_versions: None, published_date: None,
1302 references: vuln.get("references")
1303 .and_then(|r| r.as_array())
1304 .map(|refs| refs.iter()
1305 .filter_map(|r| r.get("url").and_then(|u| u.as_str()).map(|s| s.to_string()))
1306 .collect())
1307 .unwrap_or_default(),
1308 });
1309 }
1310
1311 if !vuln_infos.is_empty() {
1312 vulnerable_deps.push(VulnerableDependency {
1313 name: dep.name.clone(),
1314 version: dep.version.clone(),
1315 language: Language::Java,
1316 vulnerabilities: vuln_infos,
1317 });
1318 }
1319 }
1320 }
1321 }
1322 }
1323
1324 Ok(vulnerable_deps)
1325 }
1326
1327 fn parse_owasp_severity(&self, severity: &str) -> VulnerabilitySeverity {
1328 match severity.to_uppercase().as_str() {
1329 "CRITICAL" => VulnerabilitySeverity::Critical,
1330 "HIGH" => VulnerabilitySeverity::High,
1331 "MEDIUM" | "MODERATE" => VulnerabilitySeverity::Medium,
1332 "LOW" => VulnerabilitySeverity::Low,
1333 _ => VulnerabilitySeverity::Medium, }
1335 }
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340 use super::*;
1341
1342 #[test]
1343 fn test_vulnerability_severity_ordering() {
1344 assert!(VulnerabilitySeverity::Critical > VulnerabilitySeverity::High);
1345 assert!(VulnerabilitySeverity::High > VulnerabilitySeverity::Medium);
1346 assert!(VulnerabilitySeverity::Medium > VulnerabilitySeverity::Low);
1347 assert!(VulnerabilitySeverity::Low > VulnerabilitySeverity::Info);
1348 }
1349
1350 #[test]
1351 fn test_severity_parsing() {
1352 let checker = VulnerabilityChecker::new();
1353
1354 assert_eq!(
1355 checker.parse_npm_severity("critical"),
1356 VulnerabilitySeverity::Critical
1357 );
1358 assert_eq!(
1359 checker.parse_npm_severity("MODERATE"),
1360 VulnerabilitySeverity::Medium
1361 );
1362 }
1363}