Skip to main content

npm_utils/audit/
mod.rs

1//! Vulnerability auditing for a parsed `package-lock.json` — `npm audit`, pure Rust.
2//!
3//! The packages a lockfile pins ([`crate::sbom::components`]) are checked against one or more
4//! **advisory sources** behind a small [`AdvisorySource`] trait, and the hits are distilled into an
5//! [`AuditReport`]: which installed components are vulnerable, to which advisories, at what severity.
6//!
7//! Two sources ship in v1, both keyless and queried by default:
8//! - [`npm::NpmRegistrySource`] — npm's native bulk-advisory endpoint (also honours a custom mirror).
9//! - [`osv::OsvSource`] — the OSV (osv.dev) database, with structured affected-version ranges.
10//!
11//! The seam is deliberately open: a new source is one `impl AdvisorySource` plus one line where the
12//! active sources are assembled (e.g. a future, feature-gated, API-key'd Snyk source).
13//!
14//! Everything except the sources' network calls is pure and unit-tested: [`dedup_advisories`]
15//! collapses the same vulnerability reported by several sources, and [`build_report`] keeps only the
16//! advisories whose vulnerable range actually contains the *installed* version — the guard against
17//! the npm endpoint's range over-broadening — then groups and counts them.
18//!
19//! ```no_run
20//! use npm_utils::{audit, package_json::lock::Lockfile, sbom};
21//! # fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
22//! let lock = Lockfile::parse(&std::fs::read_to_string("package-lock.json")?)?;
23//! let components = sbom::components(&lock);
24//! let sources: Vec<Box<dyn audit::AdvisorySource>> =
25//!     vec![Box::new(audit::npm::NpmRegistrySource::new("https://registry.npmjs.org"))];
26//! let report = audit::run_audit(&components, &sources);
27//! print!("{}", audit::render_summary(&report));
28//! # Ok(()) }
29//! ```
30
31use std::collections::{BTreeMap, BTreeSet};
32
33use semver::Version;
34use serde_json::{json, Map, Value};
35
36use crate::package_json::spec::Range;
37use crate::sbom::Component;
38
39pub mod npm;
40pub mod osv;
41
42/// A normalized advisory severity. Ordered `Low < Moderate < High < Critical` so a `--audit-level`
43/// threshold is a simple comparison. npm's vocabulary (`moderate`, not `medium`).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
45pub enum Severity {
46    Low,
47    Moderate,
48    High,
49    Critical,
50}
51
52impl Severity {
53    /// Parse a severity word, case-insensitively, from either source's vocabulary: npm's
54    /// `low`/`moderate`/`high`/`critical` and OSV's uppercase `CRITICAL` etc.; `medium` maps to
55    /// [`Severity::Moderate`]. Anything else (`info`, `none`, empty, unknown) is `None` — below the
56    /// lowest actionable bucket.
57    pub fn from_str_loose(s: &str) -> Option<Severity> {
58        match s.trim().to_ascii_lowercase().as_str() {
59            "low" => Some(Severity::Low),
60            "moderate" | "medium" => Some(Severity::Moderate),
61            "high" => Some(Severity::High),
62            "critical" => Some(Severity::Critical),
63            _ => None,
64        }
65    }
66
67    /// The lowercase wire/display word for this severity.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Severity::Low => "low",
71            Severity::Moderate => "moderate",
72            Severity::High => "high",
73            Severity::Critical => "critical",
74        }
75    }
76}
77
78/// Map a CVSS base score to a severity bucket (the CVSS v3 qualitative ranges): `<= 0` is `None`,
79/// `< 4` Low, `< 7` Moderate, `< 9` High, else Critical. Used when a source gives a numeric score
80/// but no severity word.
81pub fn severity_from_cvss(score: f64) -> Option<Severity> {
82    match score {
83        s if s <= 0.0 => None,
84        s if s < 4.0 => Some(Severity::Low),
85        s if s < 7.0 => Some(Severity::Moderate),
86        s if s < 9.0 => Some(Severity::High),
87        _ => Some(Severity::Critical),
88    }
89}
90
91/// One vulnerability advisory, normalized across sources into a single shape.
92#[derive(Debug, Clone)]
93pub struct Advisory {
94    /// The reporting source's name (`"npm"`, `"osv"`, …) — matches [`AdvisorySource::name`].
95    pub source: &'static str,
96    /// The source-native id — a `GHSA-…` for both v1 sources (npm's numeric id is folded away when a
97    /// GHSA is recoverable).
98    pub id: String,
99    /// Cross-reference ids (`GHSA-…`, `CVE-…`) used to recognize the same vulnerability across
100    /// sources — the key [`dedup_advisories`] joins on.
101    pub aliases: Vec<String>,
102    /// The package name this advisory concerns.
103    pub package: String,
104    /// The affected version range, as a string the npm/[`Range`] grammar accepts — npm's raw
105    /// `vulnerable_versions`, or a `>=`/`<` range synthesized from OSV's structured events.
106    pub vulnerable_range: String,
107    /// Normalized severity, when the source provides one.
108    pub severity: Option<Severity>,
109    /// Short human title / summary.
110    pub title: String,
111    /// A canonical advisory URL, when known.
112    pub url: Option<String>,
113    /// CWE identifiers (`CWE-79`, …).
114    pub cwe: Vec<String>,
115    /// CVSS base score, when the source provides a numeric one (npm does; OSV usually doesn't).
116    pub cvss_score: Option<f64>,
117    /// CVSS vector string, when present.
118    pub cvss_vector: Option<String>,
119    /// The installed version this advisory was matched against — set by [`build_report`]; empty
120    /// until then.
121    pub matched_version: String,
122}
123
124/// Per-severity advisory counts — the shape of npm's `metadata.vulnerabilities`.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct Vulnerabilities {
127    pub info: u64,
128    pub low: u64,
129    pub moderate: u64,
130    pub high: u64,
131    pub critical: u64,
132    pub total: u64,
133}
134
135/// One vulnerable installed component and the advisories that hit its exact version.
136#[derive(Debug, Clone)]
137pub struct ComponentAdvisories {
138    pub component: Component,
139    pub advisories: Vec<Advisory>,
140}
141
142/// The result of an audit: the vulnerable components (sorted by name then version), the per-severity
143/// totals across them, the names of any advisory sources that could not be reached, and the
144/// dependencies the component loading could not audit — so a caller can tell an incomplete run
145/// from a genuinely clean one.
146#[derive(Debug, Clone)]
147pub struct AuditReport {
148    pub findings: Vec<ComponentAdvisories>,
149    pub vulnerabilities: Vulnerabilities,
150    /// Advisory sources whose lookup failed; empty when every selected source answered.
151    pub failed_sources: Vec<String>,
152    /// Dependencies the audited component set does **not** cover (manifest/spec sources only —
153    /// non-registry specs, workspaces, failed optional deps; always empty for a lockfile audit).
154    /// Distinct from [`failed_sources`](Self::failed_sources): these are inputs never checked,
155    /// not advisory databases that failed. [`run_audit`] leaves this empty; the caller that
156    /// loaded the components fills it in.
157    pub omissions: Vec<crate::registry::Omission>,
158}
159
160impl AuditReport {
161    /// The highest severity present across all findings, if any.
162    pub fn max_severity(&self) -> Option<Severity> {
163        self.advisories().filter_map(|a| a.severity).max()
164    }
165
166    /// Whether the audit reached every selected source *and* covered every dependency. `false`
167    /// means the report may be missing advisories (see
168    /// [`failed_sources`](Self::failed_sources) and [`omissions`](Self::omissions)).
169    pub fn is_complete(&self) -> bool {
170        self.failed_sources.is_empty() && self.omissions.is_empty()
171    }
172
173    /// Whether any finding is at or above `level` — the `--audit-level` exit test. A confirmed
174    /// finding whose severity could not be determined (`None`) counts as exceeding *any* level: the
175    /// vulnerability is confirmed to affect the installed version, so an unknown severity keeps it
176    /// actionable rather than letting it pass silently.
177    pub fn exceeds(&self, level: Severity) -> bool {
178        self.advisories()
179            .any(|a| a.severity.is_none_or(|s| s >= level))
180    }
181
182    fn advisories(&self) -> impl Iterator<Item = &Advisory> {
183        self.findings.iter().flat_map(|f| f.advisories.iter())
184    }
185}
186
187/// An advisory database that can be queried for the vulnerabilities affecting a set of components.
188///
189/// An implementation returns `Ok(advisories)` when the source answered (an empty vector means "no
190/// advisories"), and `Err` when the source could **not** be reached — an unreachable endpoint, a
191/// network error, or an unusable response. [`run_audit`] records those failures in
192/// [`AuditReport::failed_sources`] so a caller can distinguish an incomplete audit from a clean one;
193/// a single failing source still never sinks the run.
194pub trait AdvisorySource {
195    /// A short, stable name (`"npm"`, `"osv"`, …) used in [`Advisory::source`] and diagnostics.
196    fn name(&self) -> &'static str;
197    /// Query advisories affecting `components`. `Err` means the source was unreachable, not that
198    /// there were no advisories.
199    fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>>;
200}
201
202/// Query every source, then dedup across sources and keep only advisories that actually apply to an
203/// installed version ([`build_report`]). A source that fails is reported to stderr, recorded in the
204/// report's [`failed_sources`](AuditReport::failed_sources), and skipped — a single failing source
205/// never sinks the run, but the report is marked incomplete so the caller need not present it as clean.
206/// This is `run_audit_observed` (the crate-internal eventful variant) with a no-op observer.
207pub fn run_audit(components: &[Component], sources: &[Box<dyn AdvisorySource>]) -> AuditReport {
208    run_audit_observed(components, sources, |_| {})
209}
210
211/// A progress event from [`run_audit_observed`]'s per-source loop, in emission order: `Begin`
212/// fires immediately before a source is queried (naming it), then exactly one of `Done` (with
213/// the number of advisories that source returned, before cross-source dedup and
214/// installed-version filtering) or `Failed` — so the source a `Done`/`Failed` concerns is the
215/// one the preceding `Begin` named. The CLI renders these as stderr status lines; [`run_audit`]
216/// discards them. The data fields are read only by that feature-gated renderer, hence the
217/// library-only `allow(dead_code)`.
218pub(crate) enum SourceEvent<'a> {
219    Begin {
220        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
221        name: &'a str,
222    },
223    Done {
224        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
225        advisories: usize,
226    },
227    Failed,
228}
229
230/// [`run_audit`] with a progress observer — the same querying, failure recording, dedup, and
231/// report building; `on_event` additionally sees one [`SourceEvent::Begin`] and one `Done`/`Failed`
232/// per source, in order. `Failed` fires before the stderr cause line, so a status renderer can
233/// close its line first. The returned [`AuditReport`] is identical to [`run_audit`]'s.
234pub(crate) fn run_audit_observed(
235    components: &[Component],
236    sources: &[Box<dyn AdvisorySource>],
237    mut on_event: impl FnMut(SourceEvent<'_>),
238) -> AuditReport {
239    let mut all = Vec::new();
240    let mut failed_sources = Vec::new();
241    for source in sources {
242        on_event(SourceEvent::Begin {
243            name: source.name(),
244        });
245        match source.query(components) {
246            Ok(advisories) => {
247                on_event(SourceEvent::Done {
248                    advisories: advisories.len(),
249                });
250                all.extend(advisories);
251            }
252            Err(e) => {
253                on_event(SourceEvent::Failed);
254                crate::warn::warn(&format!(
255                    "{} advisory source failed: {e}; audit results may be incomplete",
256                    source.name()
257                ));
258                failed_sources.push(source.name().to_string());
259            }
260        }
261    }
262    let deduped = dedup_advisories(all);
263    let mut report = build_report(&deduped, components);
264    report.failed_sources = failed_sources;
265    report
266}
267
268/// The normalized GHSA-/CVE-shaped identity tokens of an advisory (its `id` plus `aliases`),
269/// uppercased. A purely numeric npm id is not a stable cross-source key, so it is excluded.
270fn advisory_keys(adv: &Advisory) -> Vec<String> {
271    std::iter::once(&adv.id)
272        .chain(adv.aliases.iter())
273        .map(|s| s.trim().to_ascii_uppercase())
274        .filter(|k| k.starts_with("GHSA-") || k.starts_with("CVE-"))
275        .collect()
276}
277
278/// Collapse advisories that describe the same vulnerability for the same package across sources,
279/// joined by any shared `GHSA-`/`CVE-` token. The first occurrence wins (sources are concatenated in
280/// selection order, npm before osv), and a later duplicate's aliases / url / cwe / cvss / severity
281/// enrich the survivor where it lacks them — so an OSV record's CVE alias decorates the npm GHSA.
282pub fn dedup_advisories(advisories: Vec<Advisory>) -> Vec<Advisory> {
283    let mut out: Vec<Advisory> = Vec::new();
284    for adv in advisories {
285        let keys = advisory_keys(&adv);
286        let existing = out.iter_mut().find(|e| {
287            e.package == adv.package && advisory_keys(e).iter().any(|k| keys.contains(k))
288        });
289        match existing {
290            Some(into) => merge_advisory(into, adv),
291            None => out.push(adv),
292        }
293    }
294    out
295}
296
297/// Fold `from` into `into`: union the alias sets (adding `from`'s native id when it is a GHSA/CVE),
298/// and fill any field `into` is missing.
299fn merge_advisory(into: &mut Advisory, from: Advisory) {
300    let add_alias = |into: &mut Advisory, alias: String| {
301        let k = alias.trim().to_ascii_uppercase();
302        if !into
303            .aliases
304            .iter()
305            .any(|x| x.trim().to_ascii_uppercase() == k)
306            && into.id != alias
307        {
308            into.aliases.push(alias);
309        }
310    };
311    let from_id_upper = from.id.trim().to_ascii_uppercase();
312    if from_id_upper.starts_with("GHSA-") || from_id_upper.starts_with("CVE-") {
313        add_alias(into, from.id);
314    }
315    for a in from.aliases {
316        add_alias(into, a);
317    }
318    if into.url.is_none() {
319        into.url = from.url;
320    }
321    if into.cwe.is_empty() {
322        into.cwe = from.cwe;
323    }
324    if into.cvss_score.is_none() {
325        into.cvss_score = from.cvss_score;
326    }
327    if into.cvss_vector.is_none() {
328        into.cvss_vector = from.cvss_vector;
329    }
330    if into.severity.is_none() {
331        into.severity = from.severity;
332    }
333}
334
335/// Match deduped advisories against the installed components and group the hits into an
336/// [`AuditReport`].
337///
338/// A component is a finding when an advisory for its name has a `vulnerable_range` that contains its
339/// exact installed version (`Range::matches`) — the guard against the npm endpoint over-broadening
340/// ranges and against OSV's multi-package records. Components are de-duplicated by `name@version`
341/// first (a lockfile can pin one version at several tree paths), and each stored advisory records
342/// the `matched_version` it applied to. A component whose version or an advisory's range won't parse
343/// is skipped rather than reported.
344pub fn build_report(advisories: &[Advisory], components: &[Component]) -> AuditReport {
345    let mut by_name: BTreeMap<&str, Vec<&Advisory>> = BTreeMap::new();
346    for a in advisories {
347        by_name.entry(a.package.as_str()).or_default().push(a);
348    }
349
350    let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new();
351    let mut findings = Vec::new();
352    let mut counts = Vulnerabilities::default();
353    for c in components {
354        if !seen.insert((c.name.as_str(), c.version.as_str())) {
355            continue; // same name@version already considered (duplicate tree path)
356        }
357        let Ok(version) = Version::parse(&c.version) else {
358            continue;
359        };
360        let Some(candidates) = by_name.get(c.name.as_str()) else {
361            continue;
362        };
363        let mut hits = Vec::new();
364        for adv in candidates {
365            // An empty range parses as "any" and would match every installed version; never
366            // over-report on a missing range — skip the advisory instead.
367            if adv.vulnerable_range.trim().is_empty() {
368                continue;
369            }
370            let Ok(range) = Range::parse(&adv.vulnerable_range) else {
371                continue;
372            };
373            if range.matches(&version) {
374                let mut hit = (*adv).clone();
375                hit.matched_version = c.version.clone();
376                count_one(&mut counts, hit.severity);
377                hits.push(hit);
378            }
379        }
380        if !hits.is_empty() {
381            findings.push(ComponentAdvisories {
382                component: c.clone(),
383                advisories: hits,
384            });
385        }
386    }
387    AuditReport {
388        findings,
389        vulnerabilities: counts,
390        failed_sources: Vec::new(),
391        omissions: Vec::new(),
392    }
393}
394
395fn count_one(v: &mut Vulnerabilities, severity: Option<Severity>) {
396    match severity {
397        Some(Severity::Low) => v.low += 1,
398        Some(Severity::Moderate) => v.moderate += 1,
399        Some(Severity::High) => v.high += 1,
400        Some(Severity::Critical) => v.critical += 1,
401        None => v.info += 1,
402    }
403    v.total += 1;
404}
405
406/// The reason an audit is incomplete, for `AUDIT INCOMPLETE` renderings: failed advisory
407/// sources, unaudited dependencies, or both. `None` when the audit covered everything.
408fn incomplete_clause(report: &AuditReport) -> Option<String> {
409    let mut parts = Vec::new();
410    if !report.failed_sources.is_empty() {
411        parts.push(format!(
412            "{} source(s) failed ({})",
413            report.failed_sources.len(),
414            report.failed_sources.join(", "),
415        ));
416    }
417    if !report.omissions.is_empty() {
418        let n = report.omissions.len();
419        parts.push(format!(
420            "{n} dependenc{} not audited",
421            if n == 1 { "y" } else { "ies" },
422        ));
423    }
424    (!parts.is_empty()).then(|| parts.join(", "))
425}
426
427/// The itemized unaudited-dependency note printed above the count header, so a clean count is
428/// never unqualified when the component set has gaps.
429fn omissions_note(report: &AuditReport) -> Option<String> {
430    if report.omissions.is_empty() {
431        return None;
432    }
433    let n = report.omissions.len();
434    let items: Vec<String> = report.omissions.iter().map(|o| o.to_string()).collect();
435    Some(format!(
436        "note: {n} dependenc{} not audited: {}",
437        if n == 1 { "y" } else { "ies" },
438        items.join(", "),
439    ))
440}
441
442/// A plain-text audit summary: a one-line count header, then each vulnerable `name@version` with its
443/// advisories. `found 0 vulnerabilities` when clean (mirrors `npm audit`). Unaudited dependencies
444/// are itemized in a `note:` line above the header and flag the audit `INCOMPLETE`.
445pub fn render_summary(report: &AuditReport) -> String {
446    use std::fmt::Write as _;
447    let v = &report.vulnerabilities;
448    let mut s = String::new();
449    if let Some(note) = omissions_note(report) {
450        let _ = writeln!(s, "{note}");
451    }
452    if v.total == 0 {
453        match incomplete_clause(report) {
454            None => s.push_str("found 0 vulnerabilities\n"),
455            Some(clause) => {
456                let _ = writeln!(s, "found 0 vulnerabilities — AUDIT INCOMPLETE: {clause}");
457            }
458        }
459        return s;
460    }
461    let mut info = String::new();
462    if v.info > 0 {
463        let _ = write!(info, ", {} info", v.info);
464    }
465    let _ = writeln!(
466        s,
467        "found {} vulnerabilit{} ({} critical, {} high, {} moderate, {} low{info}) in {} package(s)",
468        v.total,
469        if v.total == 1 { "y" } else { "ies" },
470        v.critical,
471        v.high,
472        v.moderate,
473        v.low,
474        report.findings.len(),
475    );
476    for f in &report.findings {
477        let _ = write!(s, "\n{}@{}\n", f.component.name, f.component.version);
478        for a in &f.advisories {
479            let severity = a.severity.map(Severity::as_str).unwrap_or("unknown");
480            let _ = writeln!(s, "  {:<8} {}  {}", severity.to_uppercase(), a.id, a.title);
481            let mut detail = format!("range {}", a.vulnerable_range);
482            if !a.cwe.is_empty() {
483                let _ = write!(detail, " · {}", a.cwe.join(", "));
484            }
485            if let Some(url) = &a.url {
486                let _ = write!(detail, " · {url}");
487            }
488            let _ = writeln!(s, "    {detail}");
489        }
490    }
491    if let Some(clause) = incomplete_clause(report) {
492        let _ = writeln!(s, "\nAUDIT INCOMPLETE: {clause} — findings may be missing");
493    }
494    s
495}
496
497/// An `npm audit --json`-shaped report: a `vulnerabilities` object keyed by package name (each with
498/// its affected installed `versions`, the max `severity`, and the advisories under `via`) plus a
499/// `metadata.vulnerabilities` per-severity count block.
500pub fn render_json(report: &AuditReport) -> String {
501    // Group component findings by name — a name can be installed at several versions.
502    let mut by_name: BTreeMap<&str, Vec<&ComponentAdvisories>> = BTreeMap::new();
503    for f in &report.findings {
504        by_name
505            .entry(f.component.name.as_str())
506            .or_default()
507            .push(f);
508    }
509
510    let mut vulns = Map::new();
511    for (name, group) in &by_name {
512        let mut versions: Vec<&str> = group.iter().map(|f| f.component.version.as_str()).collect();
513        versions.sort_unstable();
514        versions.dedup();
515        let max = group
516            .iter()
517            .flat_map(|f| f.advisories.iter())
518            .filter_map(|a| a.severity)
519            .max();
520        let via: Vec<Value> = group
521            .iter()
522            .flat_map(|f| f.advisories.iter())
523            .map(advisory_json)
524            .collect();
525        vulns.insert(
526            (*name).to_string(),
527            json!({
528                "name": name,
529                "severity": max.map(Severity::as_str).unwrap_or("unknown"),
530                "versions": versions,
531                "via": via,
532            }),
533        );
534    }
535
536    let v = &report.vulnerabilities;
537    let doc = json!({
538        "vulnerabilities": Value::Object(vulns),
539        "metadata": {
540            "vulnerabilities": {
541                "info": v.info,
542                "low": v.low,
543                "moderate": v.moderate,
544                "high": v.high,
545                "critical": v.critical,
546                "total": v.total,
547            },
548        },
549        // Extensions beyond `npm audit --json` so a machine can distinguish an incomplete audit
550        // from a clean one: `incomplete` is true when any source failed or any dependency went
551        // unaudited; `failed_sources` and `omissions` say which.
552        "incomplete": !report.is_complete(),
553        "failed_sources": report.failed_sources,
554        "omissions": report.omissions.iter().map(|o| json!({
555            "name": o.name,
556            "spec": o.spec,
557            "reason": o.reason,
558        })).collect::<Vec<Value>>(),
559    });
560    let mut s = serde_json::to_string_pretty(&doc).expect("serialize audit report");
561    s.push('\n');
562    s
563}
564
565fn advisory_json(a: &Advisory) -> Value {
566    let mut m = Map::new();
567    m.insert("source".into(), json!(a.source));
568    m.insert("id".into(), json!(a.id));
569    if !a.aliases.is_empty() {
570        m.insert("aliases".into(), json!(a.aliases));
571    }
572    m.insert("title".into(), json!(a.title));
573    m.insert("vulnerable_range".into(), json!(a.vulnerable_range));
574    if !a.matched_version.is_empty() {
575        m.insert("matched_version".into(), json!(a.matched_version));
576    }
577    if let Some(s) = a.severity {
578        m.insert("severity".into(), json!(s.as_str()));
579    }
580    if let Some(u) = &a.url {
581        m.insert("url".into(), json!(u));
582    }
583    if !a.cwe.is_empty() {
584        m.insert("cwe".into(), json!(a.cwe));
585    }
586    if let Some(score) = a.cvss_score {
587        m.insert("cvss_score".into(), json!(score));
588    }
589    if let Some(vector) = &a.cvss_vector {
590        m.insert("cvss_vector".into(), json!(vector));
591    }
592    Value::Object(m)
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn advisory(
600        source: &'static str,
601        id: &str,
602        aliases: &[&str],
603        range: &str,
604        sev: Option<Severity>,
605    ) -> Advisory {
606        Advisory {
607            source,
608            id: id.into(),
609            aliases: aliases.iter().map(|s| s.to_string()).collect(),
610            package: "lodash".into(),
611            vulnerable_range: range.into(),
612            severity: sev,
613            title: format!("advisory {id}"),
614            url: None,
615            cwe: vec![],
616            cvss_score: None,
617            cvss_vector: None,
618            matched_version: String::new(),
619        }
620    }
621
622    fn component(name: &str, version: &str) -> Component {
623        Component {
624            name: name.into(),
625            version: version.into(),
626            purl: format!("pkg:npm/{name}@{version}"),
627            license: None,
628            resolved: None,
629            integrity: None,
630        }
631    }
632
633    /// Fake sources for the observed-loop test: one succeeds with `usize` advisories, one fails.
634    struct OkSource(&'static str, usize);
635    impl AdvisorySource for OkSource {
636        fn name(&self) -> &'static str {
637            self.0
638        }
639        fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
640            Ok((0..self.1)
641                .map(|i| advisory("fake", &format!("GHSA-ok-{i}"), &[], "<9", None))
642                .collect())
643        }
644    }
645    struct BadSource;
646    impl AdvisorySource for BadSource {
647        fn name(&self) -> &'static str {
648            "bad"
649        }
650        fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
651            Err("simulated outage".into())
652        }
653    }
654
655    #[test]
656    fn run_audit_observed_emits_begin_done_failed_in_order() {
657        let sources: Vec<Box<dyn AdvisorySource>> =
658            vec![Box::new(OkSource("ok", 2)), Box::new(BadSource)];
659        let components = [component("lodash", "4.17.20")];
660
661        let mut events: Vec<String> = Vec::new();
662        let observed = run_audit_observed(&components, &sources, |e| {
663            events.push(match e {
664                SourceEvent::Begin { name } => format!("begin {name}"),
665                SourceEvent::Done { advisories } => format!("done {advisories}"),
666                SourceEvent::Failed => "failed".to_string(),
667            });
668        });
669
670        // A `Done`/`Failed` concerns the source the preceding `Begin` named.
671        assert_eq!(events, ["begin ok", "done 2", "begin bad", "failed"]);
672        assert_eq!(observed.failed_sources, ["bad".to_string()]);
673
674        // The observer changes nothing about the report itself.
675        let plain = run_audit(&components, &sources);
676        assert_eq!(observed.vulnerabilities.total, plain.vulnerabilities.total);
677        assert_eq!(observed.failed_sources, plain.failed_sources);
678    }
679
680    #[test]
681    fn severity_buckets_from_cvss() {
682        assert_eq!(severity_from_cvss(0.0), None);
683        assert_eq!(severity_from_cvss(3.9), Some(Severity::Low));
684        assert_eq!(severity_from_cvss(6.9), Some(Severity::Moderate));
685        assert_eq!(severity_from_cvss(7.2), Some(Severity::High));
686        assert_eq!(severity_from_cvss(9.8), Some(Severity::Critical));
687        assert!(Severity::Low < Severity::Critical);
688        assert_eq!(
689            Severity::from_str_loose("CRITICAL"),
690            Some(Severity::Critical)
691        );
692        assert_eq!(Severity::from_str_loose("medium"), Some(Severity::Moderate));
693        assert_eq!(Severity::from_str_loose("info"), None);
694    }
695
696    #[test]
697    fn dedup_joins_on_shared_alias_and_enriches() {
698        let npm = advisory(
699            "npm",
700            "GHSA-aaaa-bbbb-cccc",
701            &["GHSA-aaaa-bbbb-cccc"],
702            "<2.0.0",
703            Some(Severity::High),
704        );
705        let mut osv = advisory(
706            "osv",
707            "GHSA-aaaa-bbbb-cccc",
708            &["GHSA-aaaa-bbbb-cccc", "CVE-2021-1"],
709            "<2.0.0",
710            Some(Severity::High),
711        );
712        osv.url = Some("https://osv.dev/x".into());
713        osv.cwe = vec!["CWE-79".into()];
714
715        let out = dedup_advisories(vec![npm, osv]);
716        assert_eq!(out.len(), 1, "the two sources collapse to one advisory");
717        assert_eq!(out[0].source, "npm", "first occurrence wins");
718        assert!(
719            out[0].aliases.iter().any(|a| a == "CVE-2021-1"),
720            "CVE alias merged in from osv"
721        );
722        assert_eq!(
723            out[0].url.as_deref(),
724            Some("https://osv.dev/x"),
725            "url enriched"
726        );
727        assert_eq!(out[0].cwe, vec!["CWE-79"], "cwe enriched");
728
729        // A different CVE for the same package does NOT collapse.
730        let a = advisory("npm", "GHSA-1", &["CVE-1"], "<2.0.0", Some(Severity::Low));
731        let b = advisory("osv", "GHSA-2", &["CVE-2"], "<2.0.0", Some(Severity::Low));
732        assert_eq!(dedup_advisories(vec![a, b]).len(), 2);
733    }
734
735    #[test]
736    fn build_report_keeps_only_in_range_installs_and_counts() {
737        let advisories = vec![
738            advisory(
739                "npm",
740                "GHSA-old",
741                &["GHSA-old"],
742                "<4.17.21",
743                Some(Severity::High),
744            ),
745            advisory(
746                "npm",
747                "GHSA-wide",
748                &["GHSA-wide"],
749                "<=4.17.23",
750                Some(Severity::Moderate),
751            ),
752        ];
753        // 4.17.20 is below both ceilings → two findings, counted.
754        let r = build_report(&advisories, &[component("lodash", "4.17.20")]);
755        assert_eq!(r.vulnerabilities.total, 2);
756        assert_eq!(r.vulnerabilities.high, 1);
757        assert_eq!(r.vulnerabilities.moderate, 1);
758        assert_eq!(r.findings[0].advisories[0].matched_version, "4.17.20");
759
760        // 4.17.22 clears the <4.17.21 one but is still <=4.17.23.
761        let r = build_report(&advisories, &[component("lodash", "4.17.22")]);
762        assert_eq!(r.vulnerabilities.total, 1);
763        assert!(r.findings[0].advisories.iter().all(|a| a.id == "GHSA-wide"));
764
765        // A package the advisories don't mention is clean; an unparsable version is skipped.
766        assert_eq!(
767            build_report(&advisories, &[component("left-pad", "1.3.0")])
768                .vulnerabilities
769                .total,
770            0
771        );
772        assert_eq!(
773            build_report(&advisories, &[component("lodash", "not-semver")])
774                .vulnerabilities
775                .total,
776            0
777        );
778    }
779
780    #[test]
781    fn build_report_dedups_same_name_version_across_tree_paths() {
782        let advisories = vec![advisory(
783            "npm",
784            "GHSA-x",
785            &["GHSA-x"],
786            "<2.0.0",
787            Some(Severity::Low),
788        )];
789        // The same lodash@1.0.0 pinned at two paths must not double-count.
790        let r = build_report(
791            &advisories,
792            &[component("lodash", "1.0.0"), component("lodash", "1.0.0")],
793        );
794        assert_eq!(r.vulnerabilities.total, 1);
795        assert_eq!(r.findings.len(), 1);
796    }
797
798    #[test]
799    fn exceeds_respects_threshold_and_renders() {
800        let advisories = vec![advisory(
801            "npm",
802            "GHSA-h",
803            &["GHSA-h"],
804            "<2.0.0",
805            Some(Severity::High),
806        )];
807        let report = build_report(&advisories, &[component("lodash", "1.0.0")]);
808        assert!(report.exceeds(Severity::Low));
809        assert!(report.exceeds(Severity::High));
810        assert!(!report.exceeds(Severity::Critical));
811        assert_eq!(report.max_severity(), Some(Severity::High));
812
813        assert!(render_summary(&report).contains("found 1 vulnerability"));
814        assert!(render_summary(&report).contains("lodash@1.0.0"));
815        let empty = AuditReport {
816            findings: vec![],
817            vulnerabilities: Vulnerabilities::default(),
818            failed_sources: vec![],
819            omissions: vec![],
820        };
821        assert_eq!(render_summary(&empty), "found 0 vulnerabilities\n");
822
823        // JSON shape: keyed by name, with a metadata count block.
824        let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
825        assert_eq!(doc["vulnerabilities"]["lodash"]["severity"], "high");
826        assert_eq!(doc["metadata"]["vulnerabilities"]["high"], 1);
827        assert_eq!(doc["metadata"]["vulnerabilities"]["total"], 1);
828        assert_eq!(doc["incomplete"], false);
829        assert_eq!(
830            doc["omissions"],
831            json!([]),
832            "always present, empty by default"
833        );
834    }
835
836    #[test]
837    fn omissions_qualify_a_clean_summary_and_mark_it_incomplete() {
838        let report = AuditReport {
839            findings: vec![],
840            vulnerabilities: Vulnerabilities::default(),
841            failed_sources: vec![],
842            omissions: vec![crate::registry::Omission::new(
843                "g",
844                "git+ssh://x/y",
845                "git dependency",
846            )],
847        };
848        assert!(!report.is_complete());
849        let s = render_summary(&report);
850        assert_eq!(
851            s,
852            "note: 1 dependency not audited: g (git+ssh://x/y: git dependency)\n\
853             found 0 vulnerabilities — AUDIT INCOMPLETE: 1 dependency not audited\n"
854        );
855    }
856
857    #[test]
858    fn omissions_and_failed_sources_compose_in_summary_and_json() {
859        let advisories = vec![advisory(
860            "npm",
861            "GHSA-h",
862            &["GHSA-h"],
863            "<2.0.0",
864            Some(Severity::High),
865        )];
866        let mut report = build_report(&advisories, &[component("lodash", "1.0.0")]);
867        report.failed_sources = vec!["osv".into()];
868        report.omissions = vec![
869            crate::registry::Omission::new("w", "file:../w", "local path"),
870            crate::registry::Omission::new("workspaces", "", "not traversed"),
871        ];
872
873        let s = render_summary(&report);
874        // The note leads, the count header follows intact, the trailing block names both gaps.
875        let note_pos = s.find("note: 2 dependencies not audited:").unwrap();
876        let found_pos = s.find("found 1 vulnerability").unwrap();
877        assert!(note_pos < found_pos, "{s}");
878        assert!(
879            s.contains(
880                "AUDIT INCOMPLETE: 1 source(s) failed (osv), 2 dependencies not audited \
881                 — findings may be missing"
882            ),
883            "{s}"
884        );
885
886        let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
887        assert_eq!(doc["incomplete"], true);
888        assert_eq!(doc["omissions"][0]["name"], "w");
889        assert_eq!(doc["omissions"][0]["reason"], "local path");
890        assert_eq!(doc["omissions"][1]["spec"], "");
891    }
892}