use crate::diagnostics::{Category, Diagnostic, Severity};
use crate::process;
use crate::scanner::AnalysisPass;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const AUDIT_TIMEOUT_SECS: u64 = 60;
const MAX_OUTPUT_BYTES: u64 = 10 * 1024 * 1024;
pub struct AuditPass {
pub offline: bool,
}
impl AnalysisPass for AuditPass {
fn name(&self) -> &'static str {
"dependencies (cargo-audit)"
}
fn run(&self, project_root: &Path) -> Result<Vec<Diagnostic>, crate::error::PassError> {
if !is_cargo_audit_available() {
eprintln!(
"Info: Install cargo-audit for vulnerability scanning: cargo install cargo-audit"
);
return Err(crate::error::PassError::Skipped {
pass: self.name().to_string(),
reason: "cargo-audit is not installed — CVE scanning disabled. \
Install with: cargo install cargo-audit"
.to_string(),
});
}
run_audit(project_root, self.offline).map_err(|message| crate::error::PassError::Failed {
pass: "dependencies (cargo-audit)".to_string(),
message,
})
}
}
fn is_cargo_audit_available() -> bool {
static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*AVAILABLE.get_or_init(|| {
Command::new("cargo")
.args(["audit", "--version"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
}
fn run_audit(project_root: &Path, offline: bool) -> Result<Vec<Diagnostic>, String> {
let mut args = vec!["audit", "--json"];
if offline {
args.push("--no-fetch");
}
let child = Command::new("cargo")
.args(&args)
.current_dir(project_root)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("failed to spawn cargo audit: {e}"))?;
let result = process::run_with_timeout(child, AUDIT_TIMEOUT_SECS, MAX_OUTPUT_BYTES)?;
if result.timed_out {
eprintln!("Warning: cargo-audit timed out after {AUDIT_TIMEOUT_SECS}s");
return Ok(vec![]);
}
if result.exit_code == Some(2) {
return Err(
"cargo-audit encountered an error (missing Cargo.lock or fetch failure)".into(),
);
}
let output = &result.stdout;
if output.is_empty() {
return Ok(vec![]);
}
let report: AuditReport = serde_json::from_str(output)
.map_err(|e| format!("failed to parse cargo-audit JSON: {e}"))?;
let mut diagnostics = Vec::new();
if let Some(vulns) = &report.vulnerabilities {
for vuln in &vulns.list {
let advisory = &vuln.advisory;
let pkg = &vuln.package;
let severity = advisory_to_severity(advisory);
let patched = &vuln.versions.patched;
let fix_hint = if patched.is_empty() {
"No patched version available — consider an alternative crate".to_string()
} else {
format!("Upgrade {} to {}", pkg.name, patched.join(" or "))
};
let url_hint = advisory
.url
.as_deref()
.map(|u| format!("\n {u}"))
.unwrap_or_default();
diagnostics.push(Diagnostic {
file_path: PathBuf::from("Cargo.lock"),
rule: advisory.id.clone(),
category: Category::Dependencies,
severity,
message: format!(
"{}: {} v{} — {}",
advisory.id, pkg.name, pkg.version, advisory.title
),
help: Some(format!("{fix_hint}{url_hint}")),
line: None,
column: None,
fix: None,
});
}
}
for (kind, warnings) in &report.warnings {
for warn in warnings {
if let Some(advisory) = &warn.advisory {
diagnostics.push(Diagnostic {
file_path: PathBuf::from("Cargo.lock"),
rule: advisory.id.clone(),
category: Category::Dependencies,
severity: Severity::Warning,
message: format!(
"{}: {} v{} — {} ({})",
advisory.id, warn.package.name, warn.package.version, advisory.title, kind
),
help: advisory
.url
.as_deref()
.map(std::string::ToString::to_string),
line: None,
column: None,
fix: None,
});
}
}
}
Ok(diagnostics)
}
fn advisory_to_severity(advisory: &Advisory) -> Severity {
if let Some(ref sev) = advisory.severity {
return match sev.as_str() {
"critical" | "high" => Severity::Error,
_ => Severity::Warning,
};
}
if let Some(ref cvss) = advisory.cvss
&& let Some(score) = parse_cvss_base_score(cvss)
{
return if score >= 7.0 {
Severity::Error
} else {
Severity::Warning
};
}
Severity::Warning
}
fn parse_cvss_base_score(cvss: &str) -> Option<f32> {
if let Ok(score) = cvss.parse::<f32>() {
return Some(score);
}
let is_network = cvss.contains("AV:N");
let is_low_complexity = cvss.contains("AC:L");
let is_no_priv = cvss.contains("PR:N");
let has_high_impact = cvss.contains("C:H") || cvss.contains("I:H") || cvss.contains("A:H");
if has_high_impact && is_network && is_low_complexity && is_no_priv {
Some(9.0) } else if has_high_impact && is_network {
Some(7.5) } else if has_high_impact {
Some(6.0) } else {
None
}
}
#[derive(Deserialize)]
struct AuditReport {
vulnerabilities: Option<Vulnerabilities>,
#[serde(default)]
warnings: std::collections::HashMap<String, Vec<WarningEntry>>,
}
#[derive(Deserialize)]
struct Vulnerabilities {
#[serde(default)]
list: Vec<VulnerabilityEntry>,
}
#[derive(Deserialize)]
struct VulnerabilityEntry {
advisory: Advisory,
versions: Versions,
package: Package,
}
#[derive(Deserialize)]
struct Advisory {
id: String,
title: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
cvss: Option<String>,
#[serde(default)]
severity: Option<String>,
}
#[derive(Deserialize)]
struct Versions {
#[serde(default)]
patched: Vec<String>,
}
#[derive(Deserialize)]
struct Package {
name: String,
version: String,
}
#[derive(Deserialize)]
struct WarningEntry {
advisory: Option<Advisory>,
package: Package,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_advisory(severity: Option<&str>, cvss: Option<&str>) -> Advisory {
Advisory {
id: "TEST-001".into(),
title: "Test".into(),
url: None,
cvss: cvss.map(std::string::ToString::to_string),
severity: severity.map(std::string::ToString::to_string),
}
}
#[test]
fn test_severity_critical_is_error() {
let adv = make_advisory(Some("critical"), None);
assert_eq!(advisory_to_severity(&adv), Severity::Error);
}
#[test]
fn test_severity_high_is_error() {
let adv = make_advisory(Some("high"), None);
assert_eq!(advisory_to_severity(&adv), Severity::Error);
}
#[test]
fn test_severity_medium_is_warning() {
let adv = make_advisory(Some("medium"), None);
assert_eq!(advisory_to_severity(&adv), Severity::Warning);
}
#[test]
fn test_severity_low_is_warning() {
let adv = make_advisory(Some("low"), None);
assert_eq!(advisory_to_severity(&adv), Severity::Warning);
}
#[test]
fn test_cvss_fallback_network_critical() {
let adv = make_advisory(None, Some("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"));
assert_eq!(advisory_to_severity(&adv), Severity::Error);
}
#[test]
fn test_cvss_fallback_local_medium() {
let adv = make_advisory(None, Some("CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"));
assert_eq!(advisory_to_severity(&adv), Severity::Warning);
}
#[test]
fn test_no_severity_no_cvss_is_warning() {
let adv = make_advisory(None, None);
assert_eq!(advisory_to_severity(&adv), Severity::Warning);
}
#[test]
fn test_parse_audit_report_empty() {
let json = r#"{"vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{}}"#;
let report: AuditReport = serde_json::from_str(json).unwrap();
assert!(report.vulnerabilities.unwrap().list.is_empty());
assert!(report.warnings.is_empty());
}
#[test]
fn test_parse_audit_report_with_vuln() {
let json = r#"{
"vulnerabilities": {
"found": true,
"count": 1,
"list": [{
"advisory": {
"id": "RUSTSEC-2023-0071",
"title": "Marvin Attack",
"url": "https://example.com",
"cvss": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"
},
"versions": {
"patched": [">=0.10.0"]
},
"package": {
"name": "rsa",
"version": "0.9.6"
}
}]
},
"warnings": {}
}"#;
let report: AuditReport = serde_json::from_str(json).unwrap();
let vulns = report.vulnerabilities.unwrap();
assert_eq!(vulns.list.len(), 1);
assert_eq!(vulns.list[0].advisory.id, "RUSTSEC-2023-0071");
assert_eq!(vulns.list[0].package.name, "rsa");
assert_eq!(vulns.list[0].versions.patched, vec![">=0.10.0"]);
}
#[test]
fn test_parse_audit_report_with_warning() {
let json = r#"{
"vulnerabilities": {"found": false, "count": 0, "list": []},
"warnings": {
"unmaintained": [{
"advisory": {
"id": "RUSTSEC-2021-0145",
"title": "Potential unaligned read",
"url": null,
"cvss": null
},
"package": {
"name": "atty",
"version": "0.2.14"
}
}]
}
}"#;
let report: AuditReport = serde_json::from_str(json).unwrap();
assert_eq!(report.warnings["unmaintained"].len(), 1);
assert_eq!(report.warnings["unmaintained"][0].package.name, "atty");
}
#[test]
#[ignore = "depends on optional external tool cargo-audit"]
fn test_cargo_audit_availability() {
assert!(
is_cargo_audit_available(),
"cargo-audit should be installed for this test"
);
}
}