use std::collections::{BTreeMap, BTreeSet};
use semver::Version;
use serde_json::{json, Map, Value};
use crate::package_json::spec::Range;
use crate::sbom::Component;
pub mod npm;
pub mod osv;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Low,
Moderate,
High,
Critical,
}
impl Severity {
pub fn from_str_loose(s: &str) -> Option<Severity> {
match s.trim().to_ascii_lowercase().as_str() {
"low" => Some(Severity::Low),
"moderate" | "medium" => Some(Severity::Moderate),
"high" => Some(Severity::High),
"critical" => Some(Severity::Critical),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Severity::Low => "low",
Severity::Moderate => "moderate",
Severity::High => "high",
Severity::Critical => "critical",
}
}
}
pub fn severity_from_cvss(score: f64) -> Option<Severity> {
match score {
s if s <= 0.0 => None,
s if s < 4.0 => Some(Severity::Low),
s if s < 7.0 => Some(Severity::Moderate),
s if s < 9.0 => Some(Severity::High),
_ => Some(Severity::Critical),
}
}
#[derive(Debug, Clone)]
pub struct Advisory {
pub source: &'static str,
pub id: String,
pub aliases: Vec<String>,
pub package: String,
pub vulnerable_range: String,
pub severity: Option<Severity>,
pub title: String,
pub url: Option<String>,
pub cwe: Vec<String>,
pub cvss_score: Option<f64>,
pub cvss_vector: Option<String>,
pub matched_version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Vulnerabilities {
pub info: u64,
pub low: u64,
pub moderate: u64,
pub high: u64,
pub critical: u64,
pub total: u64,
}
#[derive(Debug, Clone)]
pub struct ComponentAdvisories {
pub component: Component,
pub advisories: Vec<Advisory>,
}
#[derive(Debug, Clone)]
pub struct AuditReport {
pub findings: Vec<ComponentAdvisories>,
pub vulnerabilities: Vulnerabilities,
pub failed_sources: Vec<String>,
pub omissions: Vec<crate::registry::Omission>,
}
impl AuditReport {
pub fn max_severity(&self) -> Option<Severity> {
self.advisories().filter_map(|a| a.severity).max()
}
pub fn is_complete(&self) -> bool {
self.failed_sources.is_empty() && self.omissions.is_empty()
}
pub fn exceeds(&self, level: Severity) -> bool {
self.advisories()
.any(|a| a.severity.is_none_or(|s| s >= level))
}
fn advisories(&self) -> impl Iterator<Item = &Advisory> {
self.findings.iter().flat_map(|f| f.advisories.iter())
}
}
pub trait AdvisorySource {
fn name(&self) -> &'static str;
fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>>;
}
pub fn run_audit(components: &[Component], sources: &[Box<dyn AdvisorySource>]) -> AuditReport {
run_audit_observed(components, sources, |_| {})
}
pub(crate) enum SourceEvent<'a> {
Begin {
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
name: &'a str,
},
Done {
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
advisories: usize,
},
Failed,
}
pub(crate) fn run_audit_observed(
components: &[Component],
sources: &[Box<dyn AdvisorySource>],
mut on_event: impl FnMut(SourceEvent<'_>),
) -> AuditReport {
let mut all = Vec::new();
let mut failed_sources = Vec::new();
for source in sources {
on_event(SourceEvent::Begin {
name: source.name(),
});
match source.query(components) {
Ok(advisories) => {
on_event(SourceEvent::Done {
advisories: advisories.len(),
});
all.extend(advisories);
}
Err(e) => {
on_event(SourceEvent::Failed);
crate::warn::warn(&format!(
"{} advisory source failed: {e}; audit results may be incomplete",
source.name()
));
failed_sources.push(source.name().to_string());
}
}
}
let deduped = dedup_advisories(all);
let mut report = build_report(&deduped, components);
report.failed_sources = failed_sources;
report
}
fn advisory_keys(adv: &Advisory) -> Vec<String> {
std::iter::once(&adv.id)
.chain(adv.aliases.iter())
.map(|s| s.trim().to_ascii_uppercase())
.filter(|k| k.starts_with("GHSA-") || k.starts_with("CVE-"))
.collect()
}
pub fn dedup_advisories(advisories: Vec<Advisory>) -> Vec<Advisory> {
let mut out: Vec<Advisory> = Vec::new();
for adv in advisories {
let keys = advisory_keys(&adv);
let existing = out.iter_mut().find(|e| {
e.package == adv.package && advisory_keys(e).iter().any(|k| keys.contains(k))
});
match existing {
Some(into) => merge_advisory(into, adv),
None => out.push(adv),
}
}
out
}
fn merge_advisory(into: &mut Advisory, from: Advisory) {
let add_alias = |into: &mut Advisory, alias: String| {
let k = alias.trim().to_ascii_uppercase();
if !into
.aliases
.iter()
.any(|x| x.trim().to_ascii_uppercase() == k)
&& into.id != alias
{
into.aliases.push(alias);
}
};
let from_id_upper = from.id.trim().to_ascii_uppercase();
if from_id_upper.starts_with("GHSA-") || from_id_upper.starts_with("CVE-") {
add_alias(into, from.id);
}
for a in from.aliases {
add_alias(into, a);
}
if into.url.is_none() {
into.url = from.url;
}
if into.cwe.is_empty() {
into.cwe = from.cwe;
}
if into.cvss_score.is_none() {
into.cvss_score = from.cvss_score;
}
if into.cvss_vector.is_none() {
into.cvss_vector = from.cvss_vector;
}
if into.severity.is_none() {
into.severity = from.severity;
}
}
pub fn build_report(advisories: &[Advisory], components: &[Component]) -> AuditReport {
let mut by_name: BTreeMap<&str, Vec<&Advisory>> = BTreeMap::new();
for a in advisories {
by_name.entry(a.package.as_str()).or_default().push(a);
}
let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new();
let mut findings = Vec::new();
let mut counts = Vulnerabilities::default();
for c in components {
if !seen.insert((c.name.as_str(), c.version.as_str())) {
continue; }
let Ok(version) = Version::parse(&c.version) else {
continue;
};
let Some(candidates) = by_name.get(c.name.as_str()) else {
continue;
};
let mut hits = Vec::new();
for adv in candidates {
if adv.vulnerable_range.trim().is_empty() {
continue;
}
let Ok(range) = Range::parse(&adv.vulnerable_range) else {
continue;
};
if range.matches(&version) {
let mut hit = (*adv).clone();
hit.matched_version = c.version.clone();
count_one(&mut counts, hit.severity);
hits.push(hit);
}
}
if !hits.is_empty() {
findings.push(ComponentAdvisories {
component: c.clone(),
advisories: hits,
});
}
}
AuditReport {
findings,
vulnerabilities: counts,
failed_sources: Vec::new(),
omissions: Vec::new(),
}
}
fn count_one(v: &mut Vulnerabilities, severity: Option<Severity>) {
match severity {
Some(Severity::Low) => v.low += 1,
Some(Severity::Moderate) => v.moderate += 1,
Some(Severity::High) => v.high += 1,
Some(Severity::Critical) => v.critical += 1,
None => v.info += 1,
}
v.total += 1;
}
fn incomplete_clause(report: &AuditReport) -> Option<String> {
let mut parts = Vec::new();
if !report.failed_sources.is_empty() {
parts.push(format!(
"{} source(s) failed ({})",
report.failed_sources.len(),
report.failed_sources.join(", "),
));
}
if !report.omissions.is_empty() {
let n = report.omissions.len();
parts.push(format!(
"{n} dependenc{} not audited",
if n == 1 { "y" } else { "ies" },
));
}
(!parts.is_empty()).then(|| parts.join(", "))
}
fn omissions_note(report: &AuditReport) -> Option<String> {
if report.omissions.is_empty() {
return None;
}
let n = report.omissions.len();
let items: Vec<String> = report.omissions.iter().map(|o| o.to_string()).collect();
Some(format!(
"note: {n} dependenc{} not audited: {}",
if n == 1 { "y" } else { "ies" },
items.join(", "),
))
}
pub fn render_summary(report: &AuditReport) -> String {
use std::fmt::Write as _;
let v = &report.vulnerabilities;
let mut s = String::new();
if let Some(note) = omissions_note(report) {
let _ = writeln!(s, "{note}");
}
if v.total == 0 {
match incomplete_clause(report) {
None => s.push_str("found 0 vulnerabilities\n"),
Some(clause) => {
let _ = writeln!(s, "found 0 vulnerabilities — AUDIT INCOMPLETE: {clause}");
}
}
return s;
}
let mut info = String::new();
if v.info > 0 {
let _ = write!(info, ", {} info", v.info);
}
let _ = writeln!(
s,
"found {} vulnerabilit{} ({} critical, {} high, {} moderate, {} low{info}) in {} package(s)",
v.total,
if v.total == 1 { "y" } else { "ies" },
v.critical,
v.high,
v.moderate,
v.low,
report.findings.len(),
);
for f in &report.findings {
let _ = write!(s, "\n{}@{}\n", f.component.name, f.component.version);
for a in &f.advisories {
let severity = a.severity.map(Severity::as_str).unwrap_or("unknown");
let _ = writeln!(s, " {:<8} {} {}", severity.to_uppercase(), a.id, a.title);
let mut detail = format!("range {}", a.vulnerable_range);
if !a.cwe.is_empty() {
let _ = write!(detail, " · {}", a.cwe.join(", "));
}
if let Some(url) = &a.url {
let _ = write!(detail, " · {url}");
}
let _ = writeln!(s, " {detail}");
}
}
if let Some(clause) = incomplete_clause(report) {
let _ = writeln!(s, "\nAUDIT INCOMPLETE: {clause} — findings may be missing");
}
s
}
pub fn render_json(report: &AuditReport) -> String {
let mut by_name: BTreeMap<&str, Vec<&ComponentAdvisories>> = BTreeMap::new();
for f in &report.findings {
by_name
.entry(f.component.name.as_str())
.or_default()
.push(f);
}
let mut vulns = Map::new();
for (name, group) in &by_name {
let mut versions: Vec<&str> = group.iter().map(|f| f.component.version.as_str()).collect();
versions.sort_unstable();
versions.dedup();
let max = group
.iter()
.flat_map(|f| f.advisories.iter())
.filter_map(|a| a.severity)
.max();
let via: Vec<Value> = group
.iter()
.flat_map(|f| f.advisories.iter())
.map(advisory_json)
.collect();
vulns.insert(
(*name).to_string(),
json!({
"name": name,
"severity": max.map(Severity::as_str).unwrap_or("unknown"),
"versions": versions,
"via": via,
}),
);
}
let v = &report.vulnerabilities;
let doc = json!({
"vulnerabilities": Value::Object(vulns),
"metadata": {
"vulnerabilities": {
"info": v.info,
"low": v.low,
"moderate": v.moderate,
"high": v.high,
"critical": v.critical,
"total": v.total,
},
},
"incomplete": !report.is_complete(),
"failed_sources": report.failed_sources,
"omissions": report.omissions.iter().map(|o| json!({
"name": o.name,
"spec": o.spec,
"reason": o.reason,
})).collect::<Vec<Value>>(),
});
let mut s = serde_json::to_string_pretty(&doc).expect("serialize audit report");
s.push('\n');
s
}
fn advisory_json(a: &Advisory) -> Value {
let mut m = Map::new();
m.insert("source".into(), json!(a.source));
m.insert("id".into(), json!(a.id));
if !a.aliases.is_empty() {
m.insert("aliases".into(), json!(a.aliases));
}
m.insert("title".into(), json!(a.title));
m.insert("vulnerable_range".into(), json!(a.vulnerable_range));
if !a.matched_version.is_empty() {
m.insert("matched_version".into(), json!(a.matched_version));
}
if let Some(s) = a.severity {
m.insert("severity".into(), json!(s.as_str()));
}
if let Some(u) = &a.url {
m.insert("url".into(), json!(u));
}
if !a.cwe.is_empty() {
m.insert("cwe".into(), json!(a.cwe));
}
if let Some(score) = a.cvss_score {
m.insert("cvss_score".into(), json!(score));
}
if let Some(vector) = &a.cvss_vector {
m.insert("cvss_vector".into(), json!(vector));
}
Value::Object(m)
}
#[cfg(test)]
mod tests {
use super::*;
fn advisory(
source: &'static str,
id: &str,
aliases: &[&str],
range: &str,
sev: Option<Severity>,
) -> Advisory {
Advisory {
source,
id: id.into(),
aliases: aliases.iter().map(|s| s.to_string()).collect(),
package: "lodash".into(),
vulnerable_range: range.into(),
severity: sev,
title: format!("advisory {id}"),
url: None,
cwe: vec![],
cvss_score: None,
cvss_vector: None,
matched_version: String::new(),
}
}
fn component(name: &str, version: &str) -> Component {
Component {
name: name.into(),
version: version.into(),
purl: format!("pkg:npm/{name}@{version}"),
license: None,
resolved: None,
integrity: None,
}
}
struct OkSource(&'static str, usize);
impl AdvisorySource for OkSource {
fn name(&self) -> &'static str {
self.0
}
fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
Ok((0..self.1)
.map(|i| advisory("fake", &format!("GHSA-ok-{i}"), &[], "<9", None))
.collect())
}
}
struct BadSource;
impl AdvisorySource for BadSource {
fn name(&self) -> &'static str {
"bad"
}
fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
Err("simulated outage".into())
}
}
#[test]
fn run_audit_observed_emits_begin_done_failed_in_order() {
let sources: Vec<Box<dyn AdvisorySource>> =
vec![Box::new(OkSource("ok", 2)), Box::new(BadSource)];
let components = [component("lodash", "4.17.20")];
let mut events: Vec<String> = Vec::new();
let observed = run_audit_observed(&components, &sources, |e| {
events.push(match e {
SourceEvent::Begin { name } => format!("begin {name}"),
SourceEvent::Done { advisories } => format!("done {advisories}"),
SourceEvent::Failed => "failed".to_string(),
});
});
assert_eq!(events, ["begin ok", "done 2", "begin bad", "failed"]);
assert_eq!(observed.failed_sources, ["bad".to_string()]);
let plain = run_audit(&components, &sources);
assert_eq!(observed.vulnerabilities.total, plain.vulnerabilities.total);
assert_eq!(observed.failed_sources, plain.failed_sources);
}
#[test]
fn severity_buckets_from_cvss() {
assert_eq!(severity_from_cvss(0.0), None);
assert_eq!(severity_from_cvss(3.9), Some(Severity::Low));
assert_eq!(severity_from_cvss(6.9), Some(Severity::Moderate));
assert_eq!(severity_from_cvss(7.2), Some(Severity::High));
assert_eq!(severity_from_cvss(9.8), Some(Severity::Critical));
assert!(Severity::Low < Severity::Critical);
assert_eq!(
Severity::from_str_loose("CRITICAL"),
Some(Severity::Critical)
);
assert_eq!(Severity::from_str_loose("medium"), Some(Severity::Moderate));
assert_eq!(Severity::from_str_loose("info"), None);
}
#[test]
fn dedup_joins_on_shared_alias_and_enriches() {
let npm = advisory(
"npm",
"GHSA-aaaa-bbbb-cccc",
&["GHSA-aaaa-bbbb-cccc"],
"<2.0.0",
Some(Severity::High),
);
let mut osv = advisory(
"osv",
"GHSA-aaaa-bbbb-cccc",
&["GHSA-aaaa-bbbb-cccc", "CVE-2021-1"],
"<2.0.0",
Some(Severity::High),
);
osv.url = Some("https://osv.dev/x".into());
osv.cwe = vec!["CWE-79".into()];
let out = dedup_advisories(vec![npm, osv]);
assert_eq!(out.len(), 1, "the two sources collapse to one advisory");
assert_eq!(out[0].source, "npm", "first occurrence wins");
assert!(
out[0].aliases.iter().any(|a| a == "CVE-2021-1"),
"CVE alias merged in from osv"
);
assert_eq!(
out[0].url.as_deref(),
Some("https://osv.dev/x"),
"url enriched"
);
assert_eq!(out[0].cwe, vec!["CWE-79"], "cwe enriched");
let a = advisory("npm", "GHSA-1", &["CVE-1"], "<2.0.0", Some(Severity::Low));
let b = advisory("osv", "GHSA-2", &["CVE-2"], "<2.0.0", Some(Severity::Low));
assert_eq!(dedup_advisories(vec![a, b]).len(), 2);
}
#[test]
fn build_report_keeps_only_in_range_installs_and_counts() {
let advisories = vec![
advisory(
"npm",
"GHSA-old",
&["GHSA-old"],
"<4.17.21",
Some(Severity::High),
),
advisory(
"npm",
"GHSA-wide",
&["GHSA-wide"],
"<=4.17.23",
Some(Severity::Moderate),
),
];
let r = build_report(&advisories, &[component("lodash", "4.17.20")]);
assert_eq!(r.vulnerabilities.total, 2);
assert_eq!(r.vulnerabilities.high, 1);
assert_eq!(r.vulnerabilities.moderate, 1);
assert_eq!(r.findings[0].advisories[0].matched_version, "4.17.20");
let r = build_report(&advisories, &[component("lodash", "4.17.22")]);
assert_eq!(r.vulnerabilities.total, 1);
assert!(r.findings[0].advisories.iter().all(|a| a.id == "GHSA-wide"));
assert_eq!(
build_report(&advisories, &[component("left-pad", "1.3.0")])
.vulnerabilities
.total,
0
);
assert_eq!(
build_report(&advisories, &[component("lodash", "not-semver")])
.vulnerabilities
.total,
0
);
}
#[test]
fn build_report_dedups_same_name_version_across_tree_paths() {
let advisories = vec![advisory(
"npm",
"GHSA-x",
&["GHSA-x"],
"<2.0.0",
Some(Severity::Low),
)];
let r = build_report(
&advisories,
&[component("lodash", "1.0.0"), component("lodash", "1.0.0")],
);
assert_eq!(r.vulnerabilities.total, 1);
assert_eq!(r.findings.len(), 1);
}
#[test]
fn exceeds_respects_threshold_and_renders() {
let advisories = vec![advisory(
"npm",
"GHSA-h",
&["GHSA-h"],
"<2.0.0",
Some(Severity::High),
)];
let report = build_report(&advisories, &[component("lodash", "1.0.0")]);
assert!(report.exceeds(Severity::Low));
assert!(report.exceeds(Severity::High));
assert!(!report.exceeds(Severity::Critical));
assert_eq!(report.max_severity(), Some(Severity::High));
assert!(render_summary(&report).contains("found 1 vulnerability"));
assert!(render_summary(&report).contains("lodash@1.0.0"));
let empty = AuditReport {
findings: vec![],
vulnerabilities: Vulnerabilities::default(),
failed_sources: vec![],
omissions: vec![],
};
assert_eq!(render_summary(&empty), "found 0 vulnerabilities\n");
let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
assert_eq!(doc["vulnerabilities"]["lodash"]["severity"], "high");
assert_eq!(doc["metadata"]["vulnerabilities"]["high"], 1);
assert_eq!(doc["metadata"]["vulnerabilities"]["total"], 1);
assert_eq!(doc["incomplete"], false);
assert_eq!(
doc["omissions"],
json!([]),
"always present, empty by default"
);
}
#[test]
fn omissions_qualify_a_clean_summary_and_mark_it_incomplete() {
let report = AuditReport {
findings: vec![],
vulnerabilities: Vulnerabilities::default(),
failed_sources: vec![],
omissions: vec![crate::registry::Omission::new(
"g",
"git+ssh://x/y",
"git dependency",
)],
};
assert!(!report.is_complete());
let s = render_summary(&report);
assert_eq!(
s,
"note: 1 dependency not audited: g (git+ssh://x/y: git dependency)\n\
found 0 vulnerabilities — AUDIT INCOMPLETE: 1 dependency not audited\n"
);
}
#[test]
fn omissions_and_failed_sources_compose_in_summary_and_json() {
let advisories = vec![advisory(
"npm",
"GHSA-h",
&["GHSA-h"],
"<2.0.0",
Some(Severity::High),
)];
let mut report = build_report(&advisories, &[component("lodash", "1.0.0")]);
report.failed_sources = vec!["osv".into()];
report.omissions = vec![
crate::registry::Omission::new("w", "file:../w", "local path"),
crate::registry::Omission::new("workspaces", "", "not traversed"),
];
let s = render_summary(&report);
let note_pos = s.find("note: 2 dependencies not audited:").unwrap();
let found_pos = s.find("found 1 vulnerability").unwrap();
assert!(note_pos < found_pos, "{s}");
assert!(
s.contains(
"AUDIT INCOMPLETE: 1 source(s) failed (osv), 2 dependencies not audited \
— findings may be missing"
),
"{s}"
);
let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
assert_eq!(doc["incomplete"], true);
assert_eq!(doc["omissions"][0]["name"], "w");
assert_eq!(doc["omissions"][0]["reason"], "local path");
assert_eq!(doc["omissions"][1]["spec"], "");
}
}