Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

src/nvd.rs

//! NVD CVE JSON 2.0 import (feature `scan`) — **CVSS + CWE enrichment**.
//!
//! NVD is product-centric (CPE), not package-manager-centric, so it is an
//! *enrichment* source keyed by CVE id — **not** the package-match authority (OSV
//! is). [`parse_nvd`] extracts each CVE's severity / CVSS vector / CWE ids / English
//! description; [`enrich_with_nvd`] folds them onto advisories matched by id OR
//! alias, never changing which versions match. CPE → `(ecosystem, package)`
//! backfill (NVD producing standalone package advisories) is a deferred follow-up
//! — it is lossy and OSV already covers the package surface.

use std::collections::HashMap;

use serde::Deserialize;

use crate::core::Severity;
use crate::scan::Advisory;

/// The enrichment facts NVD knows about one CVE.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NvdEntry {
    pub id: String,
    /// `None` when NVD carries no scored metric for the CVE (rejected/awaiting).
    pub severity: Option<Severity>,
    pub cvss_vector: String,
    pub cwes: Vec<String>,
    pub summary: String,
}

#[derive(Deserialize, Default)]
struct NvdFeed {
    #[serde(default)]
    vulnerabilities: Vec<NvdVuln>,
}

#[derive(Deserialize)]
struct NvdVuln {
    cve: NvdCve,
}

#[derive(Deserialize)]
struct NvdCve {
    id: String,
    #[serde(default)]
    descriptions: Vec<LangValue>,
    #[serde(default)]
    metrics: NvdMetrics,
    #[serde(default)]
    weaknesses: Vec<NvdWeakness>,
    #[serde(default)]
    configurations: Vec<Configuration>,
}

#[derive(Deserialize, Default)]
struct Configuration {
    #[serde(default)]
    nodes: Vec<CpeNode>,
}

#[derive(Deserialize, Default)]
struct CpeNode {
    #[serde(default, rename = "cpeMatch")]
    cpe_match: Vec<CpeMatch>,
}

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CpeMatch {
    #[serde(default)]
    vulnerable: bool,
    #[serde(default)]
    criteria: String,
    #[serde(default)]
    version_start_including: Option<String>,
    #[serde(default)]
    version_start_excluding: Option<String>,
    #[serde(default)]
    version_end_including: Option<String>,
    #[serde(default)]
    version_end_excluding: Option<String>,
}

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct NvdMetrics {
    #[serde(default)]
    cvss_metric_v31: Vec<NvdMetric>,
    #[serde(default)]
    cvss_metric_v30: Vec<NvdMetric>,
    #[serde(default)]
    cvss_metric_v2: Vec<NvdMetric>,
}

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct NvdMetric {
    #[serde(default)]
    cvss_data: CvssData,
    /// CVSS v2 carries the severity label here (v3 puts it inside `cvssData`).
    #[serde(default)]
    base_severity: Option<String>,
}

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CvssData {
    #[serde(default)]
    base_score: Option<f64>,
    #[serde(default)]
    base_severity: Option<String>,
    #[serde(default)]
    vector_string: String,
}

#[derive(Deserialize)]
struct LangValue {
    lang: String,
    value: String,
}

#[derive(Deserialize)]
struct NvdWeakness {
    #[serde(default)]
    description: Vec<LangValue>,
}

fn sev_from_label(s: &str) -> Option<Severity> {
    match s.to_ascii_uppercase().as_str() {
        "CRITICAL" => Some(Severity::Critical),
        "HIGH" => Some(Severity::High),
        "MEDIUM" | "MODERATE" => Some(Severity::Medium),
        "LOW" => Some(Severity::Low),
        _ => None,
    }
}

fn sev_from_score(s: f64) -> Severity {
    match s {
        b if b >= 9.0 => Severity::Critical,
        b if b >= 7.0 => Severity::High,
        b if b >= 4.0 => Severity::Medium,
        b if b > 0.0 => Severity::Low,
        _ => Severity::Info,
    }
}

fn entry_of(cve: &NvdCve) -> NvdEntry {
    // Prefer CVSS v3.1, then v3.0, then v2.
    let metric = cve
        .metrics
        .cvss_metric_v31
        .first()
        .or_else(|| cve.metrics.cvss_metric_v30.first())
        .or_else(|| cve.metrics.cvss_metric_v2.first());
    let (cvss_vector, severity) = match metric {
        Some(m) => {
            let label = m
                .cvss_data
                .base_severity
                .as_deref()
                .or(m.base_severity.as_deref());
            let sev = label
                .and_then(sev_from_label)
                .or_else(|| m.cvss_data.base_score.map(sev_from_score));
            (m.cvss_data.vector_string.clone(), sev)
        }
        None => (String::new(), None),
    };
    let cwes = cve
        .weaknesses
        .iter()
        .flat_map(|w| &w.description)
        .filter(|d| d.lang == "en")
        .map(|d| d.value.clone())
        .filter(|v| v.starts_with("CWE-"))
        .collect();
    let summary = cve
        .descriptions
        .iter()
        .find(|d| d.lang == "en")
        .map(|d| d.value.clone())
        .unwrap_or_default();
    NvdEntry {
        id: cve.id.clone(),
        severity,
        cvss_vector,
        cwes,
        summary,
    }
}

/// Parse an NVD CVE JSON 2.0 feed (`{"vulnerabilities":[{"cve":{…}}]}`) into the
/// per-CVE enrichment entries.
pub fn parse_nvd(json: &str) -> Result<Vec<NvdEntry>, serde_json::Error> {
    let feed: NvdFeed = serde_json::from_str(json)?;
    Ok(feed
        .vulnerabilities
        .iter()
        .map(|v| entry_of(&v.cve))
        .collect())
}

/// The CPE 2.3 product (field 4) of an application (`part = a`) CPE criteria,
/// else `None` — so OS/hardware CPEs don't become package advisories.
fn cpe_app_product(criteria: &str) -> Option<String> {
    let parts: Vec<&str> = criteria.split(':').collect();
    // cpe:2.3:part:vendor:product:version:...
    if parts.len() > 4 && parts.first() == Some(&"cpe") && parts.get(2) == Some(&"a") {
        let product = parts[4];
        if !product.is_empty() && product != "*" && product != "-" {
            return Some(product.to_string());
        }
    }
    None
}

/// The CPE 2.3 **version** (field 5), when it names a concrete version rather than
/// the `*` / `-` (ANY / N/A) wildcards NVD uses for a range-bearing match. This is
/// the *enumerated* form — NVD lists an individual affected version as its own
/// `cpe_match` with the version in field 5 and NO `versionStart/End` attributes.
fn cpe_version(criteria: &str) -> Option<String> {
    let parts: Vec<&str> = criteria.split(':').collect();
    // cpe:2.3:part:vendor:product:version:...
    let version = *parts.get(5)?;
    if !version.is_empty() && version != "*" && version != "-" {
        Some(version.to_string())
    } else {
        None
    }
}

/// **CPE → package backfill** (the lossy path the master-plan flags): emit a
/// standalone [`Advisory`] for each vulnerable application CPE in an NVD record —
/// for CVEs that OSV doesn't cover. The package is the CPE product; version
/// bounds (`versionStart*`/`versionEnd*`) become range events, while the
/// **enumerated** form (a concrete version in the CPE string, no bounds) becomes an
/// exact `affected_versions` entry — never an empty match (which would flag the
/// whole product). Tagged
/// `ecosystem = "cpe"` (a SEPARATE namespace) and `source = "nvd"` so these never
/// false-match an OSV package; a consumer that knows the CPE↔package-manager
/// mapping can resolve them. CVSS/CWE/severity ride along from [`entry_of`].
pub fn parse_nvd_advisories(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let feed: NvdFeed = serde_json::from_str(json)?;
    let mut out = Vec::new();
    for v in &feed.vulnerabilities {
        let cve = &v.cve;
        let e = entry_of(cve);
        for cfg in &cve.configurations {
            for node in &cfg.nodes {
                for m in &node.cpe_match {
                    if !m.vulnerable {
                        continue;
                    }
                    let Some(product) = cpe_app_product(&m.criteria) else {
                        continue;
                    };
                    let mut ranges = Vec::new();
                    if let Some(v) = &m.version_start_including {
                        ranges.push(crate::versions::RangeEvent::Introduced(v.clone()));
                    } else if let Some(v) = &m.version_start_excluding {
                        // `versionStartExcluding` = "affected versions are STRICTLY
                        // GREATER than this" — the bound itself is explicitly NOT
                        // affected. Folding it onto `Introduced` (an inclusive lower
                        // bound) flags the boundary version as vulnerable: a false
                        // positive on the exact version the vendor declared clean.
                        ranges.push(crate::versions::RangeEvent::IntroducedExclusive(v.clone()));
                    }
                    // An upper bound alone (`versionEnd*` with no `versionStart*`) means
                    // "every version below the fix" — the common NVD shape. Anchor it with
                    // an implicit `Introduced("0")` so the range actually opens: without a
                    // leading `Introduced`, `versions::affected_by` never turns the
                    // affected-state on and the advisory silently matches NOTHING (a
                    // false negative). OSV requires ranges to start with `introduced`; the
                    // CPE backfill must uphold the same invariant it builds ranges by hand.
                    let has_end =
                        m.version_end_excluding.is_some() || m.version_end_including.is_some();
                    if has_end && ranges.is_empty() {
                        ranges.push(crate::versions::RangeEvent::Introduced("0".into()));
                    }
                    if let Some(v) = &m.version_end_excluding {
                        ranges.push(crate::versions::RangeEvent::Fixed(v.clone()));
                    } else if let Some(v) = &m.version_end_including {
                        ranges.push(crate::versions::RangeEvent::LastAffected(v.clone()));
                    }
                    // ENUMERATED form: no version bounds at all, but the CPE names a
                    // concrete version in field 5 → this match affects EXACTLY that
                    // version. Without this it would land an advisory with empty
                    // ranges AND empty affected_versions, which `Advisory::matches`
                    // reads as "every version of the product is affected" — a false
                    // positive flagging the whole package when only one version is
                    // vulnerable. A bounded match (field 5 = `*` + versionStart/End)
                    // keeps ranges; a genuinely-all-versions match (`*`, no bounds)
                    // correctly stays empty.
                    let exact = if ranges.is_empty() {
                        cpe_version(&m.criteria).into_iter().collect()
                    } else {
                        Vec::new()
                    };
                    let mut adv = Advisory::basic(
                        cve.id.clone(),
                        "cpe",
                        product,
                        exact,
                        e.severity.unwrap_or(crate::core::Severity::Medium),
                        e.summary.clone(),
                    )
                    .with_source("nvd")
                    .with_ranges(ranges);
                    adv.cvss_vector = e.cvss_vector.clone();
                    adv.cwe = e.cwes.clone();
                    out.push(adv);
                }
            }
        }
    }
    Ok(crate::scan::merge_by_alias(out))
}

/// Fold NVD facts onto `advisories` matched by id OR alias: set the CVSS vector +
/// CWE ids when the advisory lacks them, and lift severity to NVD's when NVD rates
/// it stronger (NVD is the CVSS authority). Never changes which versions match —
/// OSV stays the package-match authority.
pub fn enrich_with_nvd(advisories: &mut [Advisory], entries: &[NvdEntry]) {
    let by_id: HashMap<&str, &NvdEntry> = entries.iter().map(|e| (e.id.as_str(), e)).collect();
    for a in advisories.iter_mut() {
        let hit = std::iter::once(a.id.as_str())
            .chain(a.aliases.iter().map(|s| s.as_str()))
            .find_map(|i| by_id.get(i).copied());
        if let Some(e) = hit {
            if a.cvss_vector.is_empty() {
                a.cvss_vector = e.cvss_vector.clone();
            }
            for c in &e.cwes {
                if !a.cwe.contains(c) {
                    a.cwe.push(c.clone());
                }
            }
            if let Some(s) = e.severity {
                if s > a.severity {
                    a.severity = s;
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = r#"{ "vulnerabilities": [ { "cve": {
        "id": "CVE-2021-44228",
        "descriptions": [ { "lang": "en", "value": "Log4Shell JNDI RCE" } ],
        "metrics": { "cvssMetricV31": [ { "cvssData": {
            "baseScore": 10.0, "baseSeverity": "CRITICAL",
            "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" } } ] },
        "weaknesses": [ { "description": [ { "lang": "en", "value": "CWE-502" } ] } ]
    } } ] }"#;

    #[test]
    fn parses_severity_vector_and_cwe() {
        let entries = parse_nvd(SAMPLE).unwrap();
        assert_eq!(entries.len(), 1);
        let e = &entries[0];
        assert_eq!(e.id, "CVE-2021-44228");
        assert_eq!(e.severity, Some(Severity::Critical));
        assert!(e.cvss_vector.starts_with("CVSS:3.1/"));
        assert_eq!(e.cwes, vec!["CWE-502"]);
        assert!(e.summary.contains("Log4Shell"));
    }

    #[test]
    fn cvss_v2_severity_label_at_metric_level() {
        let json = r#"{ "vulnerabilities": [ { "cve": { "id": "CVE-2014-0001",
            "metrics": { "cvssMetricV2": [
                { "cvssData": { "baseScore": 7.5, "vectorString": "AV:N/AC:L/Au:N/C:P/I:P/A:P" },
                  "baseSeverity": "HIGH" } ] } } } ] }"#;
        let e = &parse_nvd(json).unwrap()[0];
        assert_eq!(e.severity, Some(Severity::High));
        assert_eq!(e.cvss_vector, "AV:N/AC:L/Au:N/C:P/I:P/A:P");
    }

    #[test]
    fn cpe_backfill_emits_ranged_advisory() {
        use crate::core::ArtifactRef;
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-0001",
            "metrics": { "cvssMetricV31": [ { "cvssData": {
                "baseScore": 9.8, "baseSeverity": "CRITICAL", "vectorString": "CVSS:3.1/x" } } ] },
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*",
                  "versionStartIncluding": "2.0", "versionEndExcluding": "2.15.0" },
                { "vulnerable": false, "criteria": "cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*" }
            ] } ] } ]
        } } ] }"#;
        let advs = parse_nvd_advisories(json).unwrap();
        assert_eq!(
            advs.len(),
            1,
            "only the vulnerable APPLICATION cpe (not the OS one)"
        );
        let a = &advs[0];
        assert_eq!(a.id, "CVE-2099-0001");
        assert_eq!(
            a.ecosystem, "cpe",
            "CPE-keyed namespace, not an OSV ecosystem"
        );
        assert_eq!(a.package, "log4j");
        assert_eq!(a.severity, Severity::Critical);
        assert_eq!(a.source, "nvd");
        assert_eq!(a.ranges.len(), 1, "one hand-built range object");
        assert_eq!(a.ranges[0].len(), 2, "an Introduced + a Fixed event");
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "log4j".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(a.matches(&art("2.14.1")), "inside [2.0, 2.15.0)");
        assert!(!a.matches(&art("2.15.0")), "fixed bound exclusive");
    }

    /// RED-when-broken: `versionStartExcluding` is an EXCLUSIVE lower bound —
    /// "affected versions are strictly GREATER than this" — so the boundary version
    /// itself must be reported CLEAN. Mapping it onto `RangeEvent::Introduced` (an
    /// *inclusive* lower bound, the old behaviour) flags the boundary as vulnerable:
    /// a false positive on the exact version the vendor declared unaffected. Under
    /// the old mapping `matches(&art("1.0"))` is `true` and this test fails.
    #[test]
    fn cpe_backfill_version_start_excluding_does_not_flag_the_boundary() {
        use crate::core::ArtifactRef;
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-0003",
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
                  "versionStartExcluding": "1.0", "versionEndExcluding": "2.0" }
            ] } ] } ]
        } } ] }"#;
        let advs = parse_nvd_advisories(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        assert_eq!(
            a.ranges[0][0],
            crate::versions::RangeEvent::IntroducedExclusive("1.0".into()),
            "an exclusive lower bound must NOT degrade to an inclusive `Introduced`"
        );
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        // The boundary itself: NOT affected. This is the false positive being fixed.
        assert!(
            !a.matches(&art("1.0")),
            "versionStartExcluding=1.0 leaves 1.0 itself clean"
        );
        // Just above the boundary: affected.
        assert!(
            a.matches(&art("1.0.1")),
            "strictly above the exclusive lower bound is affected"
        );
        assert!(a.matches(&art("1.9")), "well inside the window is affected");
        // Below the boundary and at/above the fix stay clean.
        assert!(!a.matches(&art("0.9")), "below the lower bound is clean");
        assert!(!a.matches(&art("2.0")), "the fixed bound stays exclusive");
    }

    /// `versionStartExcluding` with no upper bound: affected from just above the
    /// bound to +∞, and still never the bound itself.
    #[test]
    fn cpe_backfill_version_start_excluding_open_ended() {
        use crate::core::ArtifactRef;
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-0004",
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
                  "versionStartExcluding": "3.1" }
            ] } ] } ]
        } } ] }"#;
        let a = &parse_nvd_advisories(json).unwrap()[0];
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(
            !a.matches(&art("3.1")),
            "the exclusive bound itself is clean"
        );
        assert!(a.matches(&art("3.1.1")), "strictly above is affected");
        assert!(a.matches(&art("9.9")), "open-ended above the bound");
        assert!(!a.matches(&art("3.0")), "below the bound is clean");
    }

    /// RED-when-broken: a very common NVD CPE shape — a `versionEndExcluding` upper
    /// bound with NO `versionStart*` lower bound, i.e. "every version before the fix"
    /// — MUST match the versions below the fix. Without an anchoring `Introduced`
    /// event the backfilled range never opens and `versions::affected_by` returns
    /// `false` for every version: the advisory silently matches NOTHING (a
    /// false-negative — the worst failure for a security scanner). This pins that a
    /// lower-bound-less range is affected from 0 up to (exclusive) the fix.
    #[test]
    fn cpe_backfill_unbounded_below_range_is_affected_from_zero() {
        use crate::core::ArtifactRef;
        // Log4Shell as NVD often ships it: a single cpeMatch with only an upper bound.
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2021-44228",
            "metrics": { "cvssMetricV31": [ { "cvssData": {
                "baseScore": 10.0, "baseSeverity": "CRITICAL", "vectorString": "CVSS:3.1/x" } } ] },
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*",
                  "versionEndExcluding": "2.15.0" }
            ] } ] } ]
        } } ] }"#;
        let advs = parse_nvd_advisories(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "log4j".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        // Everything below the fix is affected (the whole point of the advisory).
        assert!(
            a.matches(&art("2.0.0")),
            "an unbounded-below CPE range must match versions below the fix"
        );
        assert!(
            a.matches(&art("2.14.1")),
            "just below the fix is still affected"
        );
        assert!(
            a.matches(&art("0.0.1")),
            "affected from the beginning of time"
        );
        // The exclusive fix and above are clean.
        assert!(!a.matches(&art("2.15.0")), "the fixed bound is exclusive");
        assert!(!a.matches(&art("2.17.0")), "above the fix is clean");
    }

    /// The `versionEndIncluding` variant (inclusive upper bound, no lower bound):
    /// affected from 0 through and including the last-affected version.
    #[test]
    fn cpe_backfill_unbounded_below_last_affected_is_inclusive() {
        use crate::core::ArtifactRef;
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-0002",
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
                  "versionEndIncluding": "3.2.1" }
            ] } ] } ]
        } } ] }"#;
        let advs = parse_nvd_advisories(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(
            a.matches(&art("1.0.0")),
            "below the last-affected bound is affected"
        );
        assert!(
            a.matches(&art("3.2.1")),
            "the last-affected bound is inclusive"
        );
        assert!(
            !a.matches(&art("3.2.2")),
            "above the last-affected bound is clean"
        );
    }

    /// RED-when-broken: the **enumerated** NVD CPE form — a `cpeMatch` whose version
    /// lives in the CPE string (field 5) with NO `versionStart/End` attributes —
    /// affects EXACTLY that version. Before the fix it produced an advisory with
    /// empty ranges AND empty affected_versions, which `Advisory::matches` reads as
    /// "every version of the product is affected": a false positive flagging the
    /// whole package (e.g. all of `log4j`) when only `2.14.1` is listed.
    #[test]
    fn cpe_backfill_enumerated_version_matches_only_that_version() {
        use crate::core::ArtifactRef;
        // Two enumerated affected versions, each its own cpeMatch (the NVD shape),
        // plus one non-vulnerable version — merge_by_alias unions the two.
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-ENUM",
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true,  "criteria": "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*" },
                { "vulnerable": true,  "criteria": "cpe:2.3:a:apache:log4j:2.13.0:*:*:*:*:*:*:*" },
                { "vulnerable": false, "criteria": "cpe:2.3:a:apache:log4j:2.15.0:*:*:*:*:*:*:*" }
            ] } ] } ]
        } } ] }"#;
        let advs = parse_nvd_advisories(json).unwrap();
        assert_eq!(
            advs.len(),
            1,
            "the two enumerated matches merge into one advisory"
        );
        let a = &advs[0];
        assert!(a.ranges.is_empty(), "enumerated form carries no range");
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "log4j".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(
            a.matches(&art("2.14.1")),
            "the enumerated version is affected"
        );
        assert!(
            a.matches(&art("2.13.0")),
            "the other enumerated version is affected"
        );
        assert!(
            !a.matches(&art("2.17.0")),
            "an UNLISTED version must NOT match (no whole-product FP)"
        );
        assert!(
            !a.matches(&art("1.0.0")),
            "an older unlisted version must NOT match either"
        );
    }

    /// A genuinely all-versions CPE (`*` in field 5, NO bounds) correctly stays a
    /// whole-product advisory — the enumerated-version fix must not break the real
    /// "every version affected" case.
    #[test]
    fn cpe_backfill_wildcard_no_bounds_is_still_all_versions() {
        use crate::core::ArtifactRef;
        let json = r#"{ "vulnerabilities": [ { "cve": {
            "id": "CVE-2099-ALL",
            "configurations": [ { "nodes": [ { "cpeMatch": [
                { "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*" }
            ] } ] } ]
        } } ] }"#;
        let a = &parse_nvd_advisories(json).unwrap()[0];
        assert!(
            a.affected_versions.is_empty() && a.ranges.is_empty(),
            "all-versions sentinel"
        );
        let art = |v: &str| ArtifactRef {
            ecosystem: "cpe".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(
            a.matches(&art("1.0.0")) && a.matches(&art("9.9.9")),
            "every version affected"
        );
    }

    #[test]
    fn enrich_matches_by_alias_and_lifts_severity() {
        // A GHSA advisory (Medium, no CVSS) aliasing the CVE NVD scored Critical.
        let mut advs = vec![Advisory::basic(
            "GHSA-jfh8-c2jp-5v3q",
            "maven",
            "log4j-core",
            vec!["2.14.1".into()],
            Severity::Medium,
            "log4shell",
        )
        .with_aliases(["CVE-2021-44228"])];
        let entries = parse_nvd(SAMPLE).unwrap();
        enrich_with_nvd(&mut advs, &entries);
        assert_eq!(
            advs[0].severity,
            Severity::Critical,
            "lifted to NVD severity via alias"
        );
        assert!(
            advs[0].cvss_vector.starts_with("CVSS:3.1/"),
            "CVSS vector folded in"
        );
        assert_eq!(advs[0].cwe, vec!["CWE-502"], "CWE folded in");
    }
}