use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::project::PackageManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Critical,
High,
Moderate,
Low,
Info,
}
impl Severity {
fn parse(value: &str) -> Option<Self> {
match value {
"critical" => Some(Self::Critical),
"high" => Some(Self::High),
"moderate" => Some(Self::Moderate),
"low" => Some(Self::Low),
"info" => Some(Self::Info),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::High => "high",
Self::Moderate => "moderate",
Self::Low => "low",
Self::Info => "info",
}
}
pub fn serious(self) -> bool {
matches!(self, Self::Critical | Self::High)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Advisory {
pub module: String,
pub severity: Severity,
pub patched: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct Audit {
pub advisories: Vec<Advisory>,
}
impl Audit {
pub fn counts(&self) -> BTreeMap<Severity, usize> {
let mut counts = BTreeMap::new();
for advisory in &self.advisories {
*counts.entry(advisory.severity).or_insert(0) += 1;
}
counts
}
pub fn is_empty(&self) -> bool {
self.advisories.is_empty()
}
}
#[derive(Debug)]
pub enum AuditError {
NotInstalled,
Failed(String),
}
impl std::fmt::Display for AuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInstalled => {
f.write_str("cargo audit is not installed; cargo install cargo-audit adds it")
}
Self::Failed(reason) => f.write_str(reason),
}
}
}
#[derive(Debug, Deserialize)]
struct AdvisoryReport {
#[serde(default)]
advisories: BTreeMap<String, RawAdvisory>,
#[serde(default)]
vulnerabilities: BTreeMap<String, RawVulnerability>,
}
#[derive(Debug, Deserialize)]
struct RawAdvisory {
#[serde(default)]
module_name: String,
#[serde(default)]
severity: String,
#[serde(default)]
patched_versions: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawVulnerability {
#[serde(default)]
name: String,
#[serde(default)]
severity: String,
#[serde(default)]
range: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BunAdvisory {
#[serde(default)]
severity: String,
}
#[derive(Debug, Deserialize)]
struct YarnAdvisory {
value: String,
children: YarnChildren,
}
#[derive(Debug, Deserialize)]
struct YarnChildren {
#[serde(rename = "Severity", default)]
severity: String,
}
pub fn run(manager: PackageManager, root: &Path) -> Result<Audit, AuditError> {
let mut command = Command::new(manager.program());
if matches!(manager, PackageManager::Yarn) {
command.args(["npm", "audit", "--all", "--recursive", "--json"]);
} else {
command.args(["audit", "--json"]);
}
let output = command
.current_dir(root)
.output()
.map_err(|error| AuditError::Failed(format!("could not run {manager}: {error}")))?;
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
let silent = String::from_utf8_lossy(&output.stderr).trim().is_empty();
if matches!(manager, PackageManager::Yarn) && silent {
return Ok(Audit::default());
}
return Err(AuditError::Failed(format!(
"{manager} audit: {}",
stderr_reason(&output.stderr)
)));
}
let mut advisories = match manager {
PackageManager::Bun => read_bun(&text),
PackageManager::Yarn => read_yarn(&text),
PackageManager::Npm | PackageManager::Pnpm => read_npm(&text),
}
.map_err(|error| {
AuditError::Failed(format!("could not read {manager}'s audit output: {error}"))
})?;
condense(&mut advisories);
Ok(Audit { advisories })
}
fn stderr_reason(stderr: &[u8]) -> String {
String::from_utf8_lossy(stderr)
.lines()
.last()
.unwrap_or("no output")
.trim()
.to_owned()
}
fn condense(advisories: &mut Vec<Advisory>) {
advisories.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.module.cmp(&b.module))
});
advisories.dedup_by(|a, b| a.module == b.module && a.severity == b.severity);
}
fn read_npm(text: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let report: AdvisoryReport = serde_json::from_str(text)?;
Ok(report
.advisories
.into_values()
.filter_map(|raw| {
Some(Advisory {
module: raw.module_name,
severity: Severity::parse(&raw.severity)?,
patched: raw.patched_versions,
})
})
.chain(report.vulnerabilities.into_values().filter_map(|raw| {
Some(Advisory {
module: raw.name,
severity: Severity::parse(&raw.severity)?,
patched: raw.range,
})
}))
.collect())
}
fn read_bun(text: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let report: BTreeMap<String, Vec<BunAdvisory>> = serde_json::from_str(text)?;
Ok(report
.into_iter()
.flat_map(|(module, found)| {
found.into_iter().filter_map(move |raw| {
Some(Advisory {
module: module.clone(),
severity: Severity::parse(&raw.severity)?,
patched: None,
})
})
})
.collect())
}
fn read_yarn(text: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let lines: Vec<YarnAdvisory> = text
.lines()
.filter(|line| !line.trim().is_empty())
.map(serde_json::from_str)
.collect::<Result<_, _>>()?;
Ok(lines
.into_iter()
.filter_map(|raw| {
Some(Advisory {
module: raw.value,
severity: Severity::parse(&raw.children.severity)?,
patched: None,
})
})
.collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CargoAdvisory {
pub package: String,
pub version: String,
pub id: String,
pub patched: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CargoReport {
#[serde(default)]
vulnerabilities: CargoVulnerabilities,
}
#[derive(Debug, Default, Deserialize)]
struct CargoVulnerabilities {
#[serde(default)]
list: Vec<CargoEntry>,
}
#[derive(Debug, Deserialize)]
struct CargoEntry {
advisory: RawCargoAdvisory,
#[serde(default)]
versions: RawVersions,
package: RawPackage,
}
#[derive(Debug, Deserialize)]
struct RawCargoAdvisory {
#[serde(default)]
id: String,
}
#[derive(Debug, Default, Deserialize)]
struct RawVersions {
#[serde(default)]
patched: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawPackage {
#[serde(default)]
name: String,
#[serde(default)]
version: String,
}
pub fn cargo(root: &Path) -> Result<Vec<CargoAdvisory>, AuditError> {
if !crate::cargo::has_subcommand("audit") {
return Err(AuditError::NotInstalled);
}
let output = Command::new("cargo")
.args(["audit", "--json"])
.current_dir(root)
.output()
.map_err(|error| AuditError::Failed(format!("could not run cargo audit: {error}")))?;
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
return Err(AuditError::Failed(format!(
"cargo audit: {}",
stderr_reason(&output.stderr)
)));
}
let report: CargoReport = serde_json::from_str(&text)
.map_err(|error| AuditError::Failed(format!("could not read cargo audit: {error}")))?;
Ok(read_cargo(report))
}
fn read_cargo(report: CargoReport) -> Vec<CargoAdvisory> {
let mut advisories: Vec<CargoAdvisory> = report
.vulnerabilities
.list
.into_iter()
.map(|entry| CargoAdvisory {
package: entry.package.name,
version: entry.package.version,
id: entry.advisory.id,
patched: Some(entry.versions.patched.join(", ")).filter(|range| !range.is_empty()),
})
.collect();
advisories.sort_by(|a, b| {
a.package
.cmp(&b.package)
.then_with(|| a.version.cmp(&b.version))
.then_with(|| a.id.cmp(&b.id))
});
advisories.dedup();
advisories
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_cargo(json: &str) -> Vec<CargoAdvisory> {
read_cargo(serde_json::from_str(json).expect("parse"))
}
const CARGO_SAMPLE: &str = r#"{
"database": { "advisory-count": 1251 },
"vulnerabilities": {
"found": true,
"count": 2,
"list": [
{
"advisory": {
"id": "RUSTSEC-2026-0195",
"package": "quick-xml",
"title": "Unbounded namespace-declaration allocation",
"severity": null,
"cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"versions": { "patched": [">=0.41.0"], "unaffected": [] },
"package": { "name": "quick-xml", "version": "0.38.4" }
},
{
"advisory": { "id": "RUSTSEC-2024-0001", "package": "openssl" },
"versions": { "patched": [] },
"package": { "name": "openssl", "version": "0.10.1" }
}
]
},
"warnings": {
"unmaintained": [
{
"kind": "unmaintained",
"advisory": { "id": "RUSTSEC-2020-0999" },
"versions": { "patched": [] },
"package": { "name": "abandoned", "version": "1.0.0" }
}
]
}
}"#;
#[test]
fn a_cargo_advisory_carries_the_crate_the_id_and_the_fix() {
let found = parse_cargo(CARGO_SAMPLE);
assert_eq!(found.len(), 2);
let quick_xml = found
.iter()
.find(|advisory| advisory.package == "quick-xml")
.expect("quick-xml");
assert_eq!(quick_xml.version, "0.38.4");
assert_eq!(quick_xml.id, "RUSTSEC-2026-0195");
assert_eq!(quick_xml.patched.as_deref(), Some(">=0.41.0"));
}
#[test]
fn an_advisory_with_no_fix_yet_has_no_remedy() {
let found = parse_cargo(CARGO_SAMPLE);
let openssl = found
.iter()
.find(|advisory| advisory.package == "openssl")
.expect("openssl");
assert_eq!(
openssl.patched, None,
"an empty patched list is no range, not an empty one"
);
}
#[test]
fn warnings_are_not_vulnerabilities() {
let found = parse_cargo(CARGO_SAMPLE);
assert!(!found.iter().any(|advisory| advisory.package == "abandoned"));
}
#[test]
fn a_clean_project_parses_to_nothing() {
let found = parse_cargo(r#"{"vulnerabilities":{"found":false,"count":0,"list":[]}}"#);
assert!(found.is_empty());
}
#[test]
fn the_same_advisory_twice_is_reported_once() {
let found = parse_cargo(
r#"{"vulnerabilities":{"list":[
{"advisory":{"id":"RUSTSEC-1"},"versions":{"patched":[">=2"]},
"package":{"name":"a","version":"1.0"}},
{"advisory":{"id":"RUSTSEC-1"},"versions":{"patched":[">=2"]},
"package":{"name":"a","version":"1.0"}}
]}}"#,
);
assert_eq!(found.len(), 1);
}
fn parse(json: &str) -> Vec<Advisory> {
read_npm(json).expect("parse")
}
fn parse_bun(json: &str) -> Vec<Advisory> {
let mut found = read_bun(json).expect("parse");
condense(&mut found);
found
}
#[test]
fn the_pnpm_shape_is_read() {
let json = r#"{"advisories":{"1095100":{"id":1095100,
"title":"Uncontrolled Resource Consumption in trim-newlines",
"module_name":"trim-newlines","severity":"high",
"patched_versions":">=3.0.1"}},"metadata":{}}"#;
let found = parse(json);
assert_eq!(found.len(), 1);
assert_eq!(found[0].module, "trim-newlines");
assert_eq!(found[0].severity, Severity::High);
assert_eq!(found[0].patched.as_deref(), Some(">=3.0.1"));
}
const BUN: &str = r#"{"lodash":[
{"id":1106913,"title":"Command Injection in lodash","severity":"high",
"vulnerable_versions":"<4.17.21","cwe":["CWE-77"]},
{"id":1108258,"title":"ReDoS in lodash","severity":"moderate",
"vulnerable_versions":">=4.0.0 <4.17.21","cwe":["CWE-400"]},
{"id":1120370,"title":"Prototype Pollution","severity":"moderate",
"vulnerable_versions":">=4.0.0 <=4.17.22","cwe":["CWE-1321"]},
{"id":1115806,"title":"Code Injection via _.template","severity":"high",
"vulnerable_versions":">=4.0.0 <=4.17.23","cwe":["CWE-94"]},
{"id":1115810,"title":"Prototype Pollution via array path","severity":"moderate",
"vulnerable_versions":"<=4.17.23","cwe":["CWE-1321"]}],
"minimist":[
{"id":1097678,"title":"Prototype Pollution in minimist","severity":"critical",
"vulnerable_versions":"<0.2.1","cwe":["CWE-1321"]},
{"id":1096466,"title":"Prototype Pollution in minimist","severity":"moderate",
"vulnerable_versions":"<1.2.6","cwe":["CWE-1321"]}]}"#;
#[test]
fn the_bun_shape_is_read() {
let found = parse_bun(BUN);
assert!(found.iter().any(|a| a.module == "lodash"));
assert!(found.iter().any(|a| a.module == "minimist"));
}
#[test]
fn several_advisories_for_one_package_condense_to_one_per_severity() {
let found = parse_bun(BUN);
let lines: Vec<(&str, Severity)> = found
.iter()
.map(|a| (a.module.as_str(), a.severity))
.collect();
assert_eq!(
lines,
[
("minimist", Severity::Critical),
("lodash", Severity::High),
("lodash", Severity::Moderate),
("minimist", Severity::Moderate),
]
);
}
#[test]
fn bun_names_no_patched_range_so_none_is_shown() {
assert!(parse_bun(BUN).iter().all(|a| a.patched.is_none()));
}
#[test]
fn a_clean_bun_audit_reports_nothing() {
assert!(parse_bun("{}").is_empty());
}
const YARN: &str = concat!(
r#"{"value":"lodash","children":{"ID":1106913,"Severity":"high","#,
r#""Vulnerable Versions":"<4.17.21","Dependents":["app@workspace:."]}}"#,
"\n",
r#"{"value":"minimist","children":{"ID":1097678,"Severity":"critical","#,
r#""Vulnerable Versions":"<0.2.1","Dependents":["app@workspace:."]}}"#,
);
fn parse_yarn(text: &str) -> Vec<Advisory> {
read_yarn(text).expect("parse")
}
#[test]
fn the_yarn_shape_is_read() {
let found = parse_yarn(YARN);
assert_eq!(found.len(), 2);
assert!(
found
.iter()
.any(|a| a.module == "lodash" && a.severity == Severity::High)
);
assert!(
found
.iter()
.any(|a| a.module == "minimist" && a.severity == Severity::Critical)
);
}
#[test]
fn yarn_names_no_patched_range_so_none_is_shown() {
assert!(parse_yarn(YARN).iter().all(|a| a.patched.is_none()));
}
#[test]
fn a_clean_yarn_audit_reports_nothing() {
assert!(parse_yarn("").is_empty());
}
#[test]
fn a_malformed_yarn_line_is_a_malformed_report() {
assert!(read_yarn("not json").is_err());
}
#[test]
fn an_unknown_severity_is_skipped_rather_than_guessed() {
let json = r#"{"advisories":{"1":{"module_name":"x","severity":"spicy"}}}"#;
assert!(parse(json).is_empty());
}
#[test]
fn a_clean_audit_has_no_findings() {
let json = r#"{"advisories":{},"metadata":{"vulnerabilities":{"high":0}}}"#;
assert!(parse(json).is_empty());
}
#[test]
fn stderr_reason_takes_the_last_line() {
assert_eq!(
stderr_reason(b"npm warn config deprecated\nnpm error network timeout"),
"npm error network timeout"
);
}
#[test]
fn stderr_reason_falls_back_when_stderr_is_empty() {
assert_eq!(stderr_reason(b""), "no output");
}
#[test]
fn severity_orders_worst_first() {
let mut all = [
Severity::Low,
Severity::Critical,
Severity::Moderate,
Severity::High,
];
all.sort();
assert_eq!(
all,
[
Severity::Critical,
Severity::High,
Severity::Moderate,
Severity::Low
]
);
assert!(Severity::Critical.serious() && Severity::High.serious());
assert!(!Severity::Moderate.serious());
}
#[test]
fn counts_group_by_severity() {
let audit = Audit {
advisories: vec![
Advisory {
module: "a".into(),
severity: Severity::High,
patched: None,
},
Advisory {
module: "b".into(),
severity: Severity::High,
patched: None,
},
Advisory {
module: "c".into(),
severity: Severity::Low,
patched: None,
},
],
};
let counts = audit.counts();
assert_eq!(counts.get(&Severity::High), Some(&2));
assert_eq!(counts.get(&Severity::Low), Some(&1));
assert_eq!(counts.keys().next(), Some(&Severity::High), "worst first");
}
}