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)]
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 {
Unsupported(PackageManager),
Failed(String),
}
impl std::fmt::Display for AuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unsupported(manager) => {
write!(f, "opi cannot read {manager}'s audit output")
}
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>,
}
pub fn run(manager: PackageManager, root: &Path) -> Result<Audit, AuditError> {
if !matches!(manager, PackageManager::Npm | PackageManager::Pnpm) {
return Err(AuditError::Unsupported(manager));
}
let output = Command::new(manager.program())
.args(["audit", "--json"])
.current_dir(root)
.output()
.map_err(|error| AuditError::Failed(format!("could not run {manager}: {error}")))?;
let text = String::from_utf8_lossy(&output.stdout);
let report: AdvisoryReport = serde_json::from_str(&text).map_err(|error| {
AuditError::Failed(format!("could not read {manager}'s audit output: {error}"))
})?;
let mut advisories: Vec<Advisory> = 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();
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);
Ok(Audit { advisories })
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Vec<Advisory> {
let report: AdvisoryReport = serde_json::from_str(json).expect("parse");
report
.advisories
.into_values()
.filter_map(|raw| {
Some(Advisory {
module: raw.module_name,
severity: Severity::parse(&raw.severity)?,
patched: raw.patched_versions,
})
})
.collect()
}
#[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"));
}
#[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 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");
}
}