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

mikronova/modgunn

src/scan.rs

//! Engine A — artifact security **verdict** (multi-ecosystem CVE / license /
//! provenance). Lean; holger's policy gate + static-holger quarantine consume the
//! [`crate::core::Verdict`].
//!
//! Extracted + generalized from `nornir/src/security.rs` (cargo-only, network OSV
//! today) into a **multi-ecosystem, offline-evaluable verdict core**: given an
//! [`Artifact`] (identity + declared license + provenance) plus a [`Policy`] and an
//! [`AdvisoryDb`], fold the three checks into one [`Decision`].
//!
//! ## The decision table
//! Each check emits zero or more [`Finding`]s; the gate [`Decision`] is the strongest
//! severity across all findings (see [`decide`]):
//!
//! | finding severity        | decision        |
//! |-------------------------|-----------------|
//! | `Critical` / `High`     | [`Decision::Block`] (deny) |
//! | `Medium` / `Low`        | [`Decision::Warn`]  |
//! | `Info` / none           | [`Decision::Pass`] (allow) |
//!
//! The three checks map their policy outcome onto a severity, so the table above is
//! the single source of truth:
//! - **Known vulnerability** — every [`Advisory`] matching the artifact becomes a
//!   finding at the advisory's own severity (a `Critical`/`High` CVE denies; a
//!   `Low`/`Medium` one warns).
//! - **License policy** — a denied license → `High` (deny); a license that is not on
//!   a non-empty allow-list → `Medium` (warn, "unreviewed"); an unknown/unstated
//!   license → the severity that realizes [`LicensePolicy::on_unknown`].
//! - **Provenance** — a missing signature → the severity realizing
//!   [`ProvenancePolicy`] (`Require` denies, `Warn` warns, `Ignore` is silent).

use crate::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
use std::borrow::Cow;

/// Canonical **ecosystem** label for the advisory↔component equi-join. `pip` — the
/// label the `manifests` PyPI parser emits — and `pypi` — the label
/// [`crate::osv::parse_osv`] normalizes OSV's `PyPI` to — name the SAME registry.
/// Left unequal, `matches` rejects on the ecosystem guard BEFORE it ever compares
/// names, so every PyPI advisory silently misses every pip-manifest-sourced
/// component (a systemic false negative). Folding them at the join keeps both
/// producers free to spell it either way. Every other label passes through verbatim.
pub fn canonical_eco(eco: &str) -> &str {
    match eco {
        "pip" => "pypi",
        other => other,
    }
}

/// Canonicalize a package **name** for the equi-join key.
///
/// PyPI project names compare per **PEP 503**: case-insensitive, with any run of
/// `-`, `_` or `.` folded to a single `-` (`re.sub(r"[-_.]+", "-", name).lower()`).
/// The advisory DBs (OSV / PyPA) store that normalized form (`zope-interface`,
/// `django`); a manifest / SBOM can carry the author's spelling (`zope.interface`,
/// `Django`). An exact compare then MISSES the CVE (false negative). Normalizing
/// **both** sides for `pypi`/`pip` closes it. **NuGet** package IDs are likewise
/// case-INSENSITIVE (but their `.` separators ARE significant — `Foo.Bar` ≠
/// `FooBar`), so those fold on case only. Every other ecosystem keeps its name
/// verbatim (case-sensitive) — collapsing separators elsewhere would corrupt real
/// keys (a Maven `group.artifact`, an npm scope) and manufacture false positives.
pub fn canonical_pkg<'a>(ecosystem: &str, name: &'a str) -> Cow<'a, str> {
    match canonical_eco(ecosystem) {
        "pypi" => {
            let mut out = String::with_capacity(name.len());
            let mut prev_sep = false;
            for ch in name.chars() {
                if matches!(ch, '-' | '_' | '.') {
                    if !prev_sep {
                        out.push('-');
                    }
                    prev_sep = true;
                } else {
                    out.push(ch.to_ascii_lowercase());
                    prev_sep = false;
                }
            }
            Cow::Owned(out)
        }
        // NuGet IDs are case-insensitive-unique (two IDs can't differ by case only),
        // so lowercasing both sides can never manufacture a false positive; it only
        // fixes a latent FN when an advisory and a manifest disagree on casing.
        "nuget" if name.bytes().any(|b| b.is_ascii_uppercase()) => {
            Cow::Owned(name.to_ascii_lowercase())
        }
        _ => Cow::Borrowed(name),
    }
}

/// Provenance / signature presence on an artifact — the "who are you?" Móðguðr
/// demands at the bridge. A real verdict cares whether the bytes are *attested*,
/// not just what they declare.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Provenance {
    /// True when the artifact carries a verified signature / attestation.
    pub signed: bool,
    /// Optional label of the signer / attestation source (for display).
    pub signer: Option<String>,
}

impl Provenance {
    /// A signed artifact with a named signer.
    pub fn signed_by(signer: impl Into<String>) -> Self {
        Self {
            signed: true,
            signer: Some(signer.into()),
        }
    }
    /// An unsigned / unattested artifact.
    pub fn unsigned() -> Self {
        Self {
            signed: false,
            signer: None,
        }
    }
}

/// The thing Engine A scans: content-addressed identity ([`ArtifactRef`]) plus the
/// scannable metadata a verdict needs — the declared license and the provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
    /// Identity the verdict is keyed by (ecosystem, name, version, blob hash).
    pub artifact: ArtifactRef,
    /// SPDX-ish license expression as declared by the ecosystem manifest.
    /// `None` ⇒ unknown / unstated (handled by [`LicensePolicy::on_unknown`]).
    pub license: Option<String>,
    /// Signature / attestation presence.
    pub provenance: Provenance,
}

impl Artifact {
    /// Build from an [`ArtifactRef`] with a declared license and provenance.
    pub fn new(artifact: ArtifactRef, license: Option<String>, provenance: Provenance) -> Self {
        Self {
            artifact,
            license,
            provenance,
        }
    }
}

/// A known-vulnerability advisory, ecosystem-scoped. Multi-ecosystem by design:
/// matches by `(ecosystem, package)` then version. Version matching is **exact**
/// against [`Advisory::affected_versions`] (an empty list ⇒ *all* versions of the
/// package are affected) — kept offline + ecosystem-agnostic rather than tying the
/// verdict core to one ecosystem's range grammar (cargo semver, Maven ranges, npm
/// semver, PEP 440, …). A range-resolving front-end can expand ranges into this
/// exact form before handing the db to the scanner.
#[derive(Debug, Clone, PartialEq)]
pub struct Advisory {
    /// Stable id surfaced in the verdict reason (e.g. `RUSTSEC-2024-0003`, a CVE).
    pub id: String,
    pub ecosystem: String,
    pub package: String,
    /// Exact affected versions. Empty ⇒ every version of the package is affected.
    pub affected_versions: Vec<String>,
    pub severity: Severity,
    pub summary: String,
    /// **EPSS** — FIRST's Exploit-Prediction-Scoring-System probability (`0.0..=1.0`)
    /// that this advisory is exploited in the next 30 days; `None` ⇒ unknown. Drives
    /// [`Policy::epss_block_threshold`] escalation (the field has converged on as the
    /// exploit-likelihood signal alongside CVSS severity).
    pub epss: Option<f64>,
    /// **KEV** — present in CISA's Known-Exploited-Vulnerabilities catalog (confirmed
    /// exploited in the wild). When [`Policy::kev_blocks`] (default on), a KEV hit
    /// escalates to `Critical` regardless of base severity.
    pub kev: bool,
    /// **Affected ranges** — the OSV `affected[].ranges` source of truth, as a
    /// **disjunction**: one inner event list per OSV range object, and the artifact
    /// is affected if ANY of them covers its version
    /// ([`crate::versions::affected_by_any`]). They must stay separate — a range
    /// walk is a monotone on/off sweep, so concatenating two ranges' events cancels
    /// them against each other instead of unioning them.
    ///
    /// When non-empty, [`matches`](Advisory::matches) tests range-native, so a
    /// range-only advisory matches WITHOUT pre-expanding to exact versions against a
    /// registry index. Empty ⇒ fall back to [`affected_versions`](Advisory::affected_versions).
    pub ranges: Vec<Vec<crate::versions::RangeEvent>>,
    /// **Aliases** — every other id this advisory is known by (CVE ↔ GHSA ↔ RUSTSEC).
    /// The dedup/merge key when folding the same vuln across feeds (see
    /// [`AdvisoryDb::merge_by_alias`]).
    pub aliases: Vec<String>,
    /// **Withdrawn** — OSV `withdrawn` set ⇒ the advisory was retracted; it never
    /// matches (filtered out of the verdict).
    pub withdrawn: bool,
    /// **Source feed** the advisory was imported from (`osv|ghsa|nvd|debian|…`) —
    /// provenance for the warehouse row + per-source policy toggles. `""` = unset.
    pub source: String,
    /// **CVSS vector string** (e.g. `CVSS:3.1/AV:N/AC:L/...`), typically from NVD —
    /// the precise score basis behind `severity`. `""` = none.
    pub cvss_vector: String,
    /// **CWE ids** (e.g. `CWE-502`) — the weakness classification, typically from
    /// NVD. Empty ⇒ unclassified.
    pub cwe: Vec<String>,
}

impl Advisory {
    /// A minimal advisory with no EPSS/KEV enrichment — the common case for an
    /// offline RUSTSEC/OSV import carrying only `(coords, severity)`.
    pub fn basic(
        id: impl Into<String>,
        ecosystem: impl Into<String>,
        package: impl Into<String>,
        affected_versions: Vec<String>,
        severity: Severity,
        summary: impl Into<String>,
    ) -> Self {
        Self {
            id: id.into(),
            ecosystem: ecosystem.into(),
            package: package.into(),
            affected_versions,
            severity,
            summary: summary.into(),
            epss: None,
            kev: false,
            ranges: Vec::new(),
            aliases: Vec::new(),
            withdrawn: false,
            source: String::new(),
            cvss_vector: String::new(),
            cwe: Vec::new(),
        }
    }

    /// Builder: set **one** affected range from its event list (the common
    /// hand-built case). Use [`with_range_sets`](Advisory::with_range_sets) for an
    /// advisory that carries several range objects.
    pub fn with_ranges(mut self, ranges: Vec<crate::versions::RangeEvent>) -> Self {
        self.ranges = if ranges.is_empty() {
            Vec::new()
        } else {
            vec![ranges]
        };
        self
    }

    /// Builder: set the full range disjunction — one event list per OSV range
    /// object. Empty inner lists are dropped.
    pub fn with_range_sets(mut self, ranges: Vec<Vec<crate::versions::RangeEvent>>) -> Self {
        self.ranges = ranges.into_iter().filter(|r| !r.is_empty()).collect();
        self
    }

    /// Builder: set the alias ids (the dedup key across feeds).
    pub fn with_aliases<I, S>(mut self, aliases: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.aliases = aliases.into_iter().map(Into::into).collect();
        self
    }

    /// Builder: set the source feed label.
    pub fn with_source(mut self, source: impl Into<String>) -> Self {
        self.source = source.into();
        self
    }

    /// Does this advisory affect `artifact`'s ecosystem/name/version?
    ///
    /// A withdrawn advisory never matches. Otherwise the ecosystem + package must
    /// match, then the version is affected when ANY of:
    /// - it is listed in `affected_versions` (exact); or
    /// - `ranges` is non-empty and [`crate::versions::affected_by_any`] says ANY of
    ///   the range objects covers it (range-native); or
    /// - both `affected_versions` and `ranges` are empty (⇒ every version is affected).
    pub fn matches(&self, artifact: &ArtifactRef) -> bool {
        if self.withdrawn {
            return false;
        }
        // Canonicalize both sides of the join: fold pip↔pypi and apply PEP 503 to
        // PyPI names, so a `pypi` advisory keyed `django` / `zope-interface` matches
        // a component spelled `Django` / `zope.interface` (or labelled `pip`).
        // Non-PyPI ecosystems compare verbatim (case-sensitive), unchanged.
        if canonical_eco(&self.ecosystem) != canonical_eco(&artifact.ecosystem)
            || canonical_pkg(&self.ecosystem, &self.package)
                != canonical_pkg(&artifact.ecosystem, &artifact.name)
        {
            return false;
        }
        let exact_hit = !self.affected_versions.is_empty()
            && self
                .affected_versions
                .iter()
                .any(|v| v == &artifact.version);
        let range_hit = !self.ranges.is_empty()
            && crate::versions::affected_by_any(&self.ecosystem, &self.ranges, &artifact.version);
        let all_versions = self.affected_versions.is_empty() && self.ranges.is_empty();
        exact_hit || range_hit || all_versions
    }
}

/// An offline advisory set with a revision id — the `advisory_db_rev` part of the
/// verdict cache key, so a consumer invalidates a cached verdict when the db moves.
#[derive(Debug, Clone, Default)]
pub struct AdvisoryDb {
    pub rev: String,
    pub advisories: Vec<Advisory>,
}

impl AdvisoryDb {
    pub fn new(rev: impl Into<String>, advisories: Vec<Advisory>) -> Self {
        Self {
            rev: rev.into(),
            advisories,
        }
    }

    /// The advisories affecting `artifact`.
    pub fn query<'a>(&'a self, artifact: &ArtifactRef) -> Vec<&'a Advisory> {
        self.advisories
            .iter()
            .filter(|adv| adv.matches(artifact))
            .collect()
    }
}

/// Fold advisories that describe the **same** vulnerability for the same
/// `(ecosystem, package)` — i.e. that share any id/alias (CVE ↔ GHSA ↔ RUSTSEC) —
/// into one row, unioning ranges/affected-versions/aliases (an all-versions row —
/// empty ranges AND empty affected-versions — absorbs any ranged sibling, since
/// that is the union), taking the max
/// severity, OR-ing `kev`, the max known `epss`, and treating it as withdrawn only
/// if *every* source withdrew it. This is the dedup the ingest pipeline applies
/// when the same CVE arrives from OSV + GHSA + NVD. Order-independent.
pub fn merge_by_alias(advisories: Vec<Advisory>) -> Vec<Advisory> {
    let id_set = |a: &Advisory| -> std::collections::HashSet<String> {
        std::iter::once(a.id.clone())
            .chain(a.aliases.iter().cloned())
            .collect()
    };
    let mut groups: Vec<(std::collections::HashSet<String>, Advisory)> = Vec::new();
    'next: for adv in advisories {
        let ids = id_set(&adv);
        for (g_ids, g) in groups.iter_mut() {
            if g.ecosystem == adv.ecosystem
                && g.package == adv.package
                && ids.iter().any(|i| g_ids.contains(i))
            {
                // Merge `adv` into the existing group.
                g.severity = g.severity.max(adv.severity);
                g.kev |= adv.kev;
                g.epss = match (g.epss, adv.epss) {
                    (Some(x), Some(y)) => Some(x.max(y)),
                    (a, b) => a.or(b),
                };
                g.withdrawn = g.withdrawn && adv.withdrawn;
                // "Every version of this package is affected" is encoded as empty
                // affected_versions AND empty ranges (see `matches`). Its union with
                // ANY window is still every version, so the sentinel has to win —
                // merging a window into it would NARROW the merged row and turn a
                // matching package into a false PASS.
                let all_versions =
                    |a: &Advisory| a.affected_versions.is_empty() && a.ranges.is_empty();
                if all_versions(g) || all_versions(&adv) {
                    g.affected_versions.clear();
                    g.ranges.clear();
                } else {
                    for v in adv.affected_versions {
                        if !g.affected_versions.contains(&v) {
                            g.affected_versions.push(v);
                        }
                    }
                    // Append each range OBJECT whole — never splice one range's
                    // events into another's. `matches` ORs the objects, which is the
                    // union; concatenating the events would instead let the second
                    // feed's `Fixed` cancel the first feed's wider window.
                    for r in adv.ranges {
                        if !g.ranges.contains(&r) {
                            g.ranges.push(r);
                        }
                    }
                }
                if g.source.is_empty() {
                    g.source = adv.source;
                }
                if g.cvss_vector.is_empty() {
                    g.cvss_vector = adv.cvss_vector;
                }
                for c in adv.cwe {
                    if !g.cwe.contains(&c) {
                        g.cwe.push(c);
                    }
                }
                g_ids.extend(ids);
                // Keep every known id (except the surviving primary) as an alias.
                g.aliases = g_ids.iter().filter(|i| **i != g.id).cloned().collect();
                g.aliases.sort();
                continue 'next;
            }
        }
        groups.push((ids, adv));
    }
    groups.into_iter().map(|(_, a)| a).collect()
}

/// How a declared license maps to a gate [`Decision`].
#[derive(Debug, Clone, Default)]
pub struct LicensePolicy {
    /// Licenses explicitly reviewed + allowed. A **non-empty** allow-list makes any
    /// license outside it `Warn` ("unreviewed"); an empty allow-list allows anything
    /// not in [`LicensePolicy::deny`].
    pub allow: Vec<String>,
    /// Licenses that must deny (e.g. strong copyleft for a proprietary serve).
    pub deny: Vec<String>,
    /// What to do when the artifact declares no license at all.
    pub on_unknown: Decision,
}

impl LicensePolicy {
    /// An allow-list policy: only these licenses pass clean; unknown ⇒ `Warn`.
    pub fn allowlist<I, S>(allow: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self {
            allow: allow.into_iter().map(Into::into).collect(),
            deny: Vec::new(),
            on_unknown: Decision::Warn,
        }
    }
}

/// A parsed SPDX license expression: a single id, or an AND/OR of sub-expressions.
/// `WITH <exception>` is folded onto the base id (the exception does not change the
/// base license's allow/deny status for the gate).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SpdxExpr {
    Id(String),
    And(Vec<SpdxExpr>),
    Or(Vec<SpdxExpr>),
}

/// Which predicate [`spdx_eval`] computes over an id set.
#[derive(Clone, Copy)]
pub(crate) enum SpdxMode {
    /// Denied: an `AND` is denied if ANY operand is (all licenses apply), an `OR`
    /// only if ALL are (a clean disjunct can be chosen).
    Denied,
    /// Allowed: an `AND` needs ALL operands allowed, an `OR` needs ANY.
    Allowed,
}

/// Evaluate a parsed SPDX expression against `set` (case-insensitive id membership)
/// under `mode`. The AND/OR combinators flip between the two modes precisely so a
/// denied license inside an `AND` denies the whole, while an allowed license inside
/// an `OR` allows the whole.
pub(crate) fn spdx_eval(expr: &SpdxExpr, set: &[String], mode: SpdxMode) -> bool {
    let member = |id: &str| set.iter().any(|s| s.eq_ignore_ascii_case(id));
    match expr {
        SpdxExpr::Id(id) => member(id),
        SpdxExpr::And(v) => match mode {
            SpdxMode::Denied => v.iter().any(|e| spdx_eval(e, set, mode)),
            SpdxMode::Allowed => v.iter().all(|e| spdx_eval(e, set, mode)),
        },
        SpdxExpr::Or(v) => match mode {
            SpdxMode::Denied => v.iter().all(|e| spdx_eval(e, set, mode)),
            SpdxMode::Allowed => v.iter().any(|e| spdx_eval(e, set, mode)),
        },
    }
}

/// Collect the leaf license ids of a parsed SPDX expression (the `Id` atoms of the
/// AND/OR tree), in left-to-right order. The `crate::spdx` license-list linter walks
/// these to classify each declared id as known / deprecated / unknown — reusing the
/// single expression parser rather than a second tokenizer.
pub(crate) fn spdx_leaf_ids(expr: &SpdxExpr) -> Vec<String> {
    let mut out = Vec::new();
    fn walk(e: &SpdxExpr, out: &mut Vec<String>) {
        match e {
            SpdxExpr::Id(id) => out.push(id.clone()),
            SpdxExpr::And(v) | SpdxExpr::Or(v) => v.iter().for_each(|c| walk(c, out)),
        }
    }
    walk(expr, &mut out);
    out
}

/// Parse an SPDX license expression. Best-effort + total: a malformed/plain string
/// (no operators) becomes a single [`SpdxExpr::Id`], so the single-license gate
/// behaviour is preserved and nothing ever panics. Operator precedence follows SPDX:
/// `WITH` > `AND` > `OR`; parentheses override. Operators are matched
/// case-insensitively.
pub(crate) fn spdx_parse(expr: &str) -> SpdxExpr {
    let toks = spdx_tokens(expr);
    // DoS guard: the license string is attacker-controlled (a scanned project's
    // manifest), and the recursive-descent parser recurses once per paren nesting
    // level — a `(((…)))` bomb would overflow the stack (SIGABRT), violating this
    // module's never-panics contract. Cap the nesting depth; beyond it, fall back to
    // the whole-string atom (the documented behaviour for anything unparseable). A
    // real SPDX expression nests 2–3 deep, never 64.
    const MAX_DEPTH: i32 = 64;
    let (mut depth, mut max_depth) = (0i32, 0i32);
    for t in &toks {
        if t == "(" {
            depth += 1;
            max_depth = max_depth.max(depth);
        } else if t == ")" {
            depth -= 1;
        }
    }
    if max_depth > MAX_DEPTH {
        return SpdxExpr::Id(expr.trim().to_string());
    }
    let mut pos = 0;
    let ast = spdx_parse_or(&toks, &mut pos);
    // If we didn't consume everything (stray tokens / unbalanced parens), fall back
    // to the whole string as one atom — never worse than the pre-expression match.
    if pos == toks.len() && !matches!(&ast, SpdxExpr::Id(s) if s.is_empty()) {
        ast
    } else {
        SpdxExpr::Id(expr.trim().to_string())
    }
}

fn spdx_tokens(expr: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    for ch in expr.chars() {
        match ch {
            '(' | ')' => {
                if !cur.is_empty() {
                    out.push(std::mem::take(&mut cur));
                }
                out.push(ch.to_string());
            }
            c if c.is_whitespace() => {
                if !cur.is_empty() {
                    out.push(std::mem::take(&mut cur));
                }
            }
            c => cur.push(c),
        }
    }
    if !cur.is_empty() {
        out.push(cur);
    }
    out
}

fn is_kw(tok: &str, kw: &str) -> bool {
    tok.eq_ignore_ascii_case(kw)
}

fn spdx_parse_or(toks: &[String], pos: &mut usize) -> SpdxExpr {
    let mut terms = vec![spdx_parse_and(toks, pos)];
    while *pos < toks.len() && is_kw(&toks[*pos], "OR") {
        *pos += 1;
        terms.push(spdx_parse_and(toks, pos));
    }
    if terms.len() == 1 {
        terms.pop().unwrap()
    } else {
        SpdxExpr::Or(terms)
    }
}

fn spdx_parse_and(toks: &[String], pos: &mut usize) -> SpdxExpr {
    let mut factors = vec![spdx_parse_factor(toks, pos)];
    while *pos < toks.len() && is_kw(&toks[*pos], "AND") {
        *pos += 1;
        factors.push(spdx_parse_factor(toks, pos));
    }
    if factors.len() == 1 {
        factors.pop().unwrap()
    } else {
        SpdxExpr::And(factors)
    }
}

fn spdx_parse_factor(toks: &[String], pos: &mut usize) -> SpdxExpr {
    if *pos >= toks.len() {
        return SpdxExpr::Id(String::new());
    }
    if toks[*pos] == "(" {
        *pos += 1;
        let inner = spdx_parse_or(toks, pos);
        if *pos < toks.len() && toks[*pos] == ")" {
            *pos += 1;
        }
        return inner;
    }
    // A license id, optionally `id WITH exception` — the base id carries the gate
    // identity; skip the exception operand.
    let id = toks[*pos].clone();
    *pos += 1;
    if *pos + 1 < toks.len() && is_kw(&toks[*pos], "WITH") {
        *pos += 2; // consume WITH + the exception id
    }
    SpdxExpr::Id(id)
}

/// Provenance requirement: what a missing signature costs.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProvenancePolicy {
    /// Don't care about signatures.
    #[default]
    Ignore,
    /// Missing signature ⇒ `Warn`.
    Warn,
    /// Missing signature ⇒ deny.
    Require,
}

/// Engine A policy: license rules + provenance requirement + exploit-intelligence
/// thresholds + a version label (the `policy_version` part of the verdict cache key).
#[derive(Debug, Clone)]
pub struct Policy {
    pub version: String,
    pub license: LicensePolicy,
    pub provenance: ProvenancePolicy,
    /// **KEV escalation** — a matched advisory in CISA's Known-Exploited catalog
    /// ([`Advisory::kev`]) is bumped to `Critical` (⇒ `Block`). On by default: a
    /// confirmed-exploited vuln is the strongest signal the field recognizes.
    pub kev_blocks: bool,
    /// **EPSS escalation** — when set, a matched advisory whose [`Advisory::epss`]
    /// ≥ this probability is bumped to at least `High` (⇒ `Block`). `None` ⇒ EPSS
    /// is informational only (recorded in the finding, never changes the decision).
    pub epss_block_threshold: Option<f64>,
    /// **VEX `not_affected`** — advisory ids the consumer has asserted do not
    /// apply to this artifact (e.g. the vulnerable path is unreachable). A matched
    /// advisory whose id is listed is **suppressed** (no finding). This is the
    /// VEX-suppression the field uses to kill unreachable-CVE noise.
    pub vex_not_affected: Vec<String>,
}

impl Default for Policy {
    fn default() -> Self {
        Self {
            version: String::new(),
            license: LicensePolicy::default(),
            provenance: ProvenancePolicy::default(),
            // Security-sane defaults: KEV always escalates; EPSS informational
            // until a threshold is set; no suppressions.
            kev_blocks: true,
            epss_block_threshold: None,
            vex_not_affected: Vec::new(),
        }
    }
}

impl Policy {
    /// The license check → zero or one [`Finding`].
    ///
    /// Shared by [`OsvScanner`] (Engine A's convenience per-artifact path) and the
    /// swappable [`crate::engine`] engines, so the license → severity mapping has a
    /// single source of truth regardless of which matching strategy found the vulns.
    pub(crate) fn license_finding(&self, artifact: &Artifact) -> Option<Finding> {
        let lp = &self.license;
        match &artifact.license {
            // A declared license is an SPDX **expression** ("MIT OR Apache-2.0",
            // "GPL-3.0 AND MIT"), not always a single id. Evaluating the expression
            // (see [`spdx_parse`]/[`spdx_eval`]) fixes two bugs a whole-string match
            // has: a DENY fail-open — "MIT AND GPL-3.0" is not byte-equal to
            // "GPL-3.0", so a denied license hidden in an AND slips the gate — and a
            // false-positive Warn on a dual-licensed component whose allowed disjunct
            // is fine. A bare single id parses to one atom, so the (case-insensitive)
            // single-license behaviour is unchanged.
            Some(lic) if spdx_eval(&spdx_parse(lic), &lp.deny, SpdxMode::Denied) => Some(Finding {
                severity: Severity::High,
                id: format!("MODGUNN-LICENSE-DENIED:{lic}"),
                summary: format!("license `{lic}` is denied by policy {}", self.version),
            }),
            Some(lic)
                if !lp.allow.is_empty()
                    && !spdx_eval(&spdx_parse(lic), &lp.allow, SpdxMode::Allowed) =>
            {
                Some(Finding {
                    severity: Severity::Medium,
                    id: format!("MODGUNN-LICENSE-UNREVIEWED:{lic}"),
                    summary: format!("license `{lic}` is not on the policy allow-list"),
                })
            }
            Some(_) => None, // allowed (explicitly, or by an empty allow-list)
            None => severity_for(lp.on_unknown).map(|severity| Finding {
                severity,
                id: "MODGUNN-LICENSE-UNKNOWN".to_string(),
                summary: "artifact declares no license".to_string(),
            }),
        }
    }

    /// One matched advisory → zero or one [`Finding`], applying VEX suppression
    /// and KEV/EPSS escalation. Returns `None` when VEX marks it `not_affected`.
    pub(crate) fn vuln_finding(&self, adv: &Advisory) -> Option<Finding> {
        // VEX: the consumer asserted this advisory does not apply (e.g. the
        // vulnerable path is unreachable) → suppress it entirely.
        if self.vex_not_affected.iter().any(|id| id == &adv.id) {
            return None;
        }
        let mut severity = adv.severity;
        let mut tags = String::new();
        // KEV: confirmed exploited in the wild ⇒ escalate to Critical.
        if adv.kev && self.kev_blocks {
            severity = severity.max(Severity::Critical);
            tags.push_str(" [KEV]");
        }
        // EPSS: high exploit probability ⇒ escalate to at least High (Block).
        if let Some(threshold) = self.epss_block_threshold {
            if adv.epss.is_some_and(|e| e >= threshold) {
                severity = severity.max(Severity::High);
            }
        }
        if let Some(e) = adv.epss {
            tags.push_str(&format!(" EPSS={e:.2}"));
        }
        Some(Finding {
            severity,
            id: adv.id.clone(),
            summary: format!("{}{tags}", adv.summary),
        })
    }

    /// The provenance check → zero or one [`Finding`].
    pub(crate) fn provenance_finding(&self, artifact: &Artifact) -> Option<Finding> {
        if artifact.provenance.signed {
            return None;
        }
        let decision = match self.provenance {
            ProvenancePolicy::Ignore => Decision::Pass,
            ProvenancePolicy::Warn => Decision::Warn,
            ProvenancePolicy::Require => Decision::Block,
        };
        severity_for(decision).map(|severity| Finding {
            severity,
            id: "MODGUNN-PROVENANCE-MISSING".to_string(),
            summary: "artifact has no signature / attestation".to_string(),
        })
    }

    /// Fold an artifact + its already-matched advisories into a cacheable
    /// [`Verdict`], applying the three checks (vuln / license / provenance) and the
    /// module's decision table. This is the engine-agnostic verdict assembly: the
    /// ONLY thing Engine A and Engine B do differently is *find* `matched` — once the
    /// matching advisories are in hand, the verdict is identical. `advisory_db_rev`
    /// is the cache-key rev of the db the matches came from.
    pub fn assemble(
        &self,
        advisory_db_rev: &str,
        artifact: &Artifact,
        matched: &[&Advisory],
    ) -> Verdict {
        let mut findings = Vec::new();
        // 1. Known-vulnerability match — every matching advisory, escalated by
        //    exploit intelligence (KEV/EPSS) and filtered by VEX suppression.
        for adv in matched {
            if let Some(f) = self.vuln_finding(adv) {
                findings.push(f);
            }
        }
        // 2. License policy.
        if let Some(f) = self.license_finding(artifact) {
            findings.push(f);
        }
        // 3. Provenance / signature presence.
        if let Some(f) = self.provenance_finding(artifact) {
            findings.push(f);
        }
        let decision = decide(&findings);
        // Introspection marker — the engine-agnostic verdict-assembly hot path
        // reports which branch it took (allow=Pass/Warn → GREEN, deny=Block → RED)
        // so `nornir test` SEES the policy evaluation itself, not only the
        // `OsvScanner` wrapper (Engine B's `verdict()` path routes through here too,
        // so both engines light the same surface). Feature-gated: a compiled-out
        // `#[inline]` no-op with no nornir dep in the lean `scan` build.
        #[cfg(feature = "testmatrix")]
        crate::functional_status(
            "modgunn/verdict-assembly",
            "policy_decision",
            !matches!(decision, Decision::Block),
            &format!(
                "{} {} → {:?} ({} finding(s) over {} matched advisor{})",
                artifact.artifact.name,
                artifact.artifact.version,
                decision,
                findings.len(),
                matched.len(),
                if matched.len() == 1 { "y" } else { "ies" },
            ),
        );
        Verdict {
            artifact: artifact.artifact.clone(),
            decision,
            findings,
            policy_version: self.version.clone(),
            advisory_db_rev: advisory_db_rev.to_string(),
        }
    }
}

/// The gate [`Decision`] implied by a finding's severity (the table in the module
/// docs). `Critical`/`High` deny; `Medium`/`Low` warn; `Info`/none pass.
pub fn decision_for(severity: Severity) -> Decision {
    match severity {
        Severity::Critical | Severity::High => Decision::Block,
        Severity::Medium | Severity::Low => Decision::Warn,
        Severity::Info => Decision::Pass,
    }
}

/// The overall [`Decision`] for a set of findings: the strongest implied decision
/// (the max severity drives it). No findings ⇒ [`Decision::Pass`].
pub fn decide(findings: &[Finding]) -> Decision {
    findings
        .iter()
        .map(|f| decision_for(f.severity))
        .max()
        .unwrap_or(Decision::Pass)
}

/// The severity that *realizes* a target decision when a check needs to emit a
/// finding to push the verdict to that decision (`Block`→`High`, `Warn`→`Medium`,
/// `Pass`→no finding). Inverse-compatible with [`decision_for`].
fn severity_for(decision: Decision) -> Option<Severity> {
    match decision {
        Decision::Block => Some(Severity::High),
        Decision::Warn => Some(Severity::Medium),
        Decision::Pass => None,
    }
}

// `Decision`'s derived `Ord` follows declaration order Pass < Warn < Block, so
// `.max()` above picks the strictest decision. Pin that with a test rather than a
// comment alone.

/// A pluggable scanner that turns an [`Artifact`] into a cacheable [`Verdict`].
pub trait ScanEngine {
    fn scan(&self, artifact: &Artifact) -> Verdict;
}

/// The default multi-ecosystem scanner: known-vuln match (offline [`AdvisoryDb`],
/// OSV-shaped) + license policy + provenance check, folded into a [`Verdict`] via
/// the module's decision table. Named for the OSV advisory shape it consumes.
#[derive(Debug, Default, Clone)]
pub struct OsvScanner {
    pub policy: Policy,
    pub advisories: AdvisoryDb,
}

impl OsvScanner {
    pub fn new(policy: Policy, advisories: AdvisoryDb) -> Self {
        Self { policy, advisories }
    }
}

impl ScanEngine for OsvScanner {
    fn scan(&self, artifact: &Artifact) -> Verdict {
        // Engine A's per-artifact path: linear [`AdvisoryDb::query`] to find the
        // matching advisories, then the shared engine-agnostic verdict assembly.
        let matched = self.advisories.query(&artifact.artifact);
        let verdict = self
            .policy
            .assemble(&self.advisories.rev, artifact, &matched);
        // Introspection marker — Engine A reports each verdict to the test matrix.
        #[cfg(feature = "testmatrix")]
        crate::functional_status(
            "modgunn/engine-a-osv-scanner",
            "scan_verdict",
            !matches!(verdict.decision, Decision::Block),
            &format!(
                "{} {} → {:?} ({} finding(s))",
                artifact.artifact.name,
                artifact.artifact.version,
                verdict.decision,
                verdict.findings.len()
            ),
        );
        verdict
    }
}

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

    fn aref(ecosystem: &str, name: &str, version: &str) -> ArtifactRef {
        ArtifactRef {
            ecosystem: ecosystem.into(),
            name: name.into(),
            version: version.into(),
            blob_sha256: format!("{name}-{version}-sha"),
        }
    }

    /// PyPI equi-join canonicalization (PEP 503 + pip↔pypi fold). A `pypi` advisory
    /// keyed on the normalized name the OSV/PyPA DBs ship must still match a
    /// component that carries the author's spelling or the `pip` manifest label —
    /// otherwise every PyPI CVE is a silent false negative on real projects. RED if
    /// the join reverts to exact `ecosystem`/`name` string equality.
    #[test]
    fn pypi_join_is_pep503_and_pip_pypi_folded() {
        // Case-fold: advisory `django`, component `Django`.
        let a = Advisory::basic(
            "CVE-DJ",
            "pypi",
            "django",
            vec!["4.2.1".into()],
            Severity::High,
            "x",
        );
        assert!(
            a.matches(&aref("pypi", "Django", "4.2.1")),
            "PyPI names are case-insensitive"
        );
        // Separator collapse: advisory `zope-interface`, component `zope.interface`.
        let b = Advisory::basic(
            "CVE-ZI",
            "pypi",
            "zope-interface",
            vec!["6.0".into()],
            Severity::Low,
            "x",
        );
        assert!(
            b.matches(&aref("pypi", "zope.interface", "6.0")),
            "PEP 503 folds . / _ / - runs"
        );
        assert!(
            b.matches(&aref("pypi", "Zope__Interface", "6.0")),
            "case + underscore run together"
        );
        // Ecosystem fold: the pip manifest parser labels components `pip`, OSV uses `pypi`.
        assert!(
            a.matches(&aref("pip", "django", "4.2.1")),
            "pip and pypi are the same registry"
        );
        assert!(
            a.matches(&aref("pip", "Django", "4.2.1")),
            "fold + case together"
        );
        // Negative — separator collapse must NOT bleed across ecosystems: a cargo
        // advisory keeps case-sensitive, separator-significant exact matching, so no
        // false positive is manufactured for non-PyPI names.
        let c = Advisory::basic(
            "CVE-OSSL",
            "cargo",
            "OpenSSL",
            vec!["1.0.0".into()],
            Severity::High,
            "x",
        );
        assert!(
            !c.matches(&aref("cargo", "openssl", "1.0.0")),
            "cargo stays case-sensitive (no over-normalize)"
        );
        assert!(
            c.matches(&aref("cargo", "OpenSSL", "1.0.0")),
            "exact cargo name still matches"
        );
    }

    /// NuGet IDs are case-insensitive but separator-significant: a casing mismatch
    /// between advisory and manifest must still match, while a `.`-vs-nothing
    /// difference must NOT (those are genuinely different packages).
    #[test]
    fn nuget_join_is_case_insensitive_but_separator_significant() {
        let a = Advisory::basic(
            "CVE-NJ",
            "nuget",
            "Newtonsoft.Json",
            vec!["13.0.1".into()],
            Severity::High,
            "x",
        );
        assert!(
            a.matches(&aref("nuget", "newtonsoft.json", "13.0.1")),
            "NuGet IDs are case-insensitive"
        );
        assert!(
            a.matches(&aref("nuget", "Newtonsoft.Json", "13.0.1")),
            "exact casing still matches"
        );
        assert!(
            !a.matches(&aref("nuget", "NewtonsoftJson", "13.0.1")),
            "the '.' is significant — different pkg"
        );
    }

    // ── P0: range-native matching + withdrawn + alias dedup ──────────────────

    /// An advisory with OSV ranges matches by `affected_by` (range-native) — no
    /// pre-expansion to exact versions needed: `[1.0.0, 1.2.0)` hits 1.0.0/1.1.x,
    /// misses the fixed 1.2.0 and anything below the introduced bound.
    #[test]
    fn range_native_match_uses_affected_by() {
        use crate::versions::RangeEvent::{Fixed, Introduced};
        let adv = Advisory::basic("CVE-R", "cargo", "widget", vec![], Severity::High, "ranged")
            .with_ranges(vec![Introduced("1.0.0".into()), Fixed("1.2.0".into())]);
        assert!(
            adv.matches(&aref("cargo", "widget", "1.0.0")),
            "introduced bound is inclusive"
        );
        assert!(
            adv.matches(&aref("cargo", "widget", "1.1.9")),
            "inside the range"
        );
        assert!(
            !adv.matches(&aref("cargo", "widget", "1.2.0")),
            "fixed bound is exclusive"
        );
        assert!(
            !adv.matches(&aref("cargo", "widget", "0.9.0")),
            "below introduced"
        );
        // A range advisory must NOT match the all-versions fallback for a different pkg.
        assert!(
            !adv.matches(&aref("cargo", "other", "1.1.0")),
            "different package"
        );
    }

    /// A withdrawn advisory never matches, even one that would otherwise hit every
    /// version (empty affected_versions + empty ranges).
    #[test]
    fn withdrawn_advisory_never_matches() {
        let mut adv = Advisory::basic(
            "GHSA-W",
            "npm",
            "leftpad",
            vec![],
            Severity::Low,
            "retracted",
        );
        assert!(
            adv.matches(&aref("npm", "leftpad", "1.0.0")),
            "all-versions hit before withdrawal"
        );
        adv.withdrawn = true;
        assert!(
            !adv.matches(&aref("npm", "leftpad", "1.0.0")),
            "withdrawn ⇒ never matches"
        );
    }

    /// The same vulnerability arriving as a CVE row and a GHSA row (sharing an
    /// alias) for the same package folds into one advisory: union of ranges +
    /// aliases, max severity, OR-ed kev.
    #[test]
    fn merge_by_alias_folds_same_vuln() {
        use crate::versions::RangeEvent::Introduced;
        let cve = Advisory::basic(
            "CVE-1",
            "cargo",
            "openssl",
            vec![],
            Severity::Medium,
            "from NVD",
        )
        .with_aliases(["GHSA-xxxx"])
        .with_ranges(vec![Introduced("0.1.0".into())]);
        let mut ghsa = Advisory::basic(
            "GHSA-xxxx",
            "cargo",
            "openssl",
            vec!["0.2.0".into()],
            Severity::High,
            "from GHSA",
        );
        ghsa.kev = true;
        // An unrelated advisory for a different package must survive untouched.
        let other = Advisory::basic("CVE-2", "npm", "lodash", vec![], Severity::Low, "unrelated");

        let merged = merge_by_alias(vec![cve, ghsa, other]);
        assert_eq!(
            merged.len(),
            2,
            "the two openssl rows fold to one; lodash stays"
        );
        let os = merged.iter().find(|a| a.package == "openssl").unwrap();
        assert_eq!(os.severity, Severity::High, "max severity wins");
        assert!(os.kev, "kev OR-ed in");
        assert!(os.aliases.iter().any(|a| a == "GHSA-xxxx") || os.id == "GHSA-xxxx");
        assert!(
            os.affected_versions.contains(&"0.2.0".to_string()),
            "affected versions unioned"
        );
        assert!(!os.ranges.is_empty(), "ranges unioned");
    }

    /// Two feeds describing the SAME CVE with different windows must union, not
    /// cancel. OSV says `[0, 2.0.0)`, the aliasing GHSA says `[1.5.0, 1.9.0)`;
    /// 1.9.5 is inside the OSV window, so the merged row must still match it.
    /// Splicing both feeds' events into one list makes the GHSA `Fixed(1.9.0)`
    /// switch the walk off and reports the vulnerable 1.9.5 as CLEAN.
    #[test]
    fn merge_by_alias_unions_windows_instead_of_cancelling() {
        use crate::versions::RangeEvent::{Fixed, Introduced};
        let osv = Advisory::basic("CVE-X", "cargo", "foo", vec![], Severity::High, "osv")
            .with_ranges(vec![Introduced("0".into()), Fixed("2.0.0".into())]);
        let ghsa = Advisory::basic("GHSA-Y", "cargo", "foo", vec![], Severity::High, "ghsa")
            .with_aliases(["CVE-X"])
            .with_ranges(vec![Introduced("1.5.0".into()), Fixed("1.9.0".into())]);

        for order in [vec![osv.clone(), ghsa.clone()], vec![ghsa, osv]] {
            let merged = merge_by_alias(order);
            assert_eq!(merged.len(), 1, "aliased rows fold");
            let m = &merged[0];
            assert!(
                m.matches(&aref("cargo", "foo", "1.9.5")),
                "1.9.5 is inside [0, 2.0.0)"
            );
            assert!(
                m.matches(&aref("cargo", "foo", "1.6.0")),
                "inside both windows"
            );
            assert!(
                !m.matches(&aref("cargo", "foo", "2.0.0")),
                "above both windows"
            );
        }
    }

    /// An all-versions row (empty affected_versions AND empty ranges) merged with an
    /// aliased ranged sibling must stay all-versions — the union of "every version"
    /// with a window is every version. Absorbing the window instead NARROWS the row
    /// and turns a previously-matching package into a false PASS.
    #[test]
    fn merge_by_alias_keeps_all_versions_sentinel() {
        use crate::versions::RangeEvent::{Fixed, Introduced};
        let all = Advisory::basic(
            "GHSA-A",
            "npm",
            "foo",
            vec![],
            Severity::Critical,
            "malicious",
        );
        let ranged = Advisory::basic("CVE-B", "npm", "foo", vec![], Severity::High, "ranged")
            .with_aliases(["GHSA-A"])
            .with_ranges(vec![Introduced("1.0.0".into()), Fixed("1.2.0".into())]);
        assert!(
            all.matches(&aref("npm", "foo", "3.0.0")),
            "all-versions matches before the merge"
        );

        for order in [vec![all.clone(), ranged.clone()], vec![ranged, all]] {
            let merged = merge_by_alias(order);
            assert_eq!(merged.len(), 1, "aliased rows fold");
            assert!(
                merged[0].matches(&aref("npm", "foo", "3.0.0")),
                "all-versions must survive the merge"
            );
            assert!(
                merged[0].matches(&aref("npm", "foo", "1.1.0")),
                "and still cover the window"
            );
        }
    }

    /// A policy that allows MIT/Apache-2.0, denies GPL-3.0, warns on unknown
    /// license, and requires provenance. The advisory db carries one High-severity
    /// vuln for `openssl@0.1.0` (cargo) and one Low-severity one for `left-pad`.
    fn scanner() -> OsvScanner {
        let policy = Policy {
            version: "policy-v1".into(),
            license: LicensePolicy {
                allow: vec!["MIT".into(), "Apache-2.0".into()],
                deny: vec!["GPL-3.0".into()],
                on_unknown: Decision::Warn,
            },
            provenance: ProvenancePolicy::Require,
            ..Default::default()
        };
        let advisories = AdvisoryDb::new(
            "db-rev-42",
            vec![
                Advisory::basic(
                    "RUSTSEC-2024-0001",
                    "cargo",
                    "openssl",
                    vec!["0.1.0".into()],
                    Severity::High,
                    "use-after-free in openssl",
                ),
                Advisory::basic(
                    "CVE-2099-LEFTPAD",
                    "npm",
                    "left-pad",
                    vec![], // all versions
                    Severity::Low,
                    "left-pad disappeared",
                ),
            ],
        );
        OsvScanner::new(policy, advisories)
    }

    /// Clean dependency: allowed license, signed, no vuln ⇒ Pass, no findings.
    #[test]
    fn clean_dependency_passes() {
        let art = Artifact::new(
            aref("cargo", "serde", "1.0.0"),
            Some("MIT".into()),
            Provenance::signed_by("nordisk-ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Pass);
        assert!(v.findings.is_empty(), "a clean dep has no findings");
        // Cache key carries the policy + db revs.
        assert_eq!(v.policy_version, "policy-v1");
        assert_eq!(v.advisory_db_rev, "db-rev-42");
        assert_eq!(
            v.cache_key(),
            (
                "serde-1.0.0-sha".into(),
                "policy-v1".into(),
                "db-rev-42".into()
            )
        );
    }

    /// Known High-severity vuln ⇒ Block, and the advisory id + summary are in the
    /// reason. (Signed + allowed license, so the vuln is the *only* reason.)
    #[test]
    fn known_high_vuln_blocks_with_advisory_in_reason() {
        let art = Artifact::new(
            aref("cargo", "openssl", "0.1.0"),
            Some("Apache-2.0".into()),
            Provenance::signed_by("nordisk-ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Block, "a High vuln denies");
        assert_eq!(v.findings.len(), 1, "only the vuln finding");
        let f = &v.findings[0];
        assert_eq!(f.id, "RUSTSEC-2024-0001", "the advisory id is the reason");
        assert_eq!(f.severity, Severity::High);
        assert!(f.summary.contains("use-after-free"));
    }

    /// A different version of the same package is NOT affected by an exact-version
    /// advisory ⇒ Pass.
    #[test]
    fn unaffected_version_is_not_flagged() {
        let art = Artifact::new(
            aref("cargo", "openssl", "0.2.0"),
            Some("Apache-2.0".into()),
            Provenance::signed_by("nordisk-ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Pass);
        assert!(v.findings.is_empty());
    }

    /// An all-versions advisory in a different ecosystem only matches that
    /// ecosystem — a cargo `left-pad` is clean; the npm one is flagged (Low ⇒ Warn).
    #[test]
    fn advisory_is_ecosystem_scoped() {
        let cargo = Artifact::new(
            aref("cargo", "left-pad", "1.0.0"),
            Some("MIT".into()),
            Provenance::signed_by("ci"),
        );
        assert_eq!(
            scanner().scan(&cargo).decision,
            Decision::Pass,
            "no cargo left-pad advisory"
        );

        let npm = Artifact::new(
            aref("npm", "left-pad", "1.0.0"),
            Some("MIT".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&npm);
        assert_eq!(v.decision, Decision::Warn, "a Low npm advisory warns");
        assert_eq!(v.findings[0].id, "CVE-2099-LEFTPAD");
    }

    /// A denied license ⇒ Block, with the license named in the reason.
    #[test]
    fn denied_license_blocks() {
        let art = Artifact::new(
            aref("cargo", "copyleftcrate", "1.0.0"),
            Some("GPL-3.0".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Block);
        assert_eq!(v.findings.len(), 1);
        assert!(v.findings[0].id.contains("LICENSE-DENIED"));
        assert!(v.findings[0].summary.contains("GPL-3.0"));
    }

    /// RED-when-reverted: the DENY gate matches SPDX ids case-insensitively — a
    /// manifest author must not defeat a hard deny by re-casing the license.
    /// `gpl-3.0` against a `GPL-3.0` deny list still Blocks (was a fail-open to Warn).
    #[test]
    fn denied_license_is_case_insensitive() {
        let art = Artifact::new(
            aref("cargo", "sneaky", "1.0.0"),
            Some("gpl-3.0".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(
            v.decision,
            Decision::Block,
            "a re-cased denied license must still Block"
        );
        assert!(
            v.findings[0].id.contains("LICENSE-DENIED"),
            "not the softer UNREVIEWED arm"
        );
    }

    /// RED-when-reverted: SPDX expression evaluation. The deny gate must not fail
    /// open on an `AND` that hides a denied license, and must not warn an `OR` whose
    /// allowed disjunct is fine.
    #[test]
    fn spdx_expression_deny_and_allow() {
        let deny = vec!["GPL-3.0".to_string()];
        let allow = vec!["MIT".to_string(), "Apache-2.0".to_string()];
        // AND: a denied license anywhere in the conjunction denies the whole (the
        // fail-open a whole-string match had — "MIT AND GPL-3.0" != "GPL-3.0").
        assert!(spdx_eval(
            &spdx_parse("MIT AND GPL-3.0"),
            &deny,
            SpdxMode::Denied
        ));
        assert!(spdx_eval(
            &spdx_parse("GPL-3.0 AND MIT"),
            &deny,
            SpdxMode::Denied
        ));
        // OR: can pick the clean disjunct → NOT denied.
        assert!(!spdx_eval(
            &spdx_parse("MIT OR GPL-3.0"),
            &deny,
            SpdxMode::Denied
        ));
        // Allowed: an OR is allowed if ANY disjunct is (no spurious "unreviewed").
        assert!(spdx_eval(
            &spdx_parse("MIT OR Apache-2.0"),
            &allow,
            SpdxMode::Allowed
        ));
        assert!(spdx_eval(
            &spdx_parse("MIT OR SomethingUnknown"),
            &allow,
            SpdxMode::Allowed
        ));
        // Allowed: an AND needs ALL operands allowed.
        assert!(spdx_eval(
            &spdx_parse("MIT AND Apache-2.0"),
            &allow,
            SpdxMode::Allowed
        ));
        assert!(!spdx_eval(
            &spdx_parse("MIT AND BSD-3-Clause"),
            &allow,
            SpdxMode::Allowed
        ));
        // Parens + precedence, and WITH-exception folds onto the base id.
        assert!(spdx_eval(
            &spdx_parse("(MIT OR Apache-2.0) AND MIT"),
            &allow,
            SpdxMode::Allowed
        ));
        assert!(spdx_eval(
            &spdx_parse("Apache-2.0 WITH LLVM-exception"),
            &allow,
            SpdxMode::Allowed
        ));
        // Single id (backward compat) + case-insensitive.
        assert!(spdx_eval(&spdx_parse("gpl-3.0"), &deny, SpdxMode::Denied));
        assert_eq!(spdx_parse("MIT"), SpdxExpr::Id("MIT".into()));
    }

    /// RED-when-reverted: a deeply-nested-parens license (attacker-controlled from a
    /// scanned manifest) must NOT overflow the parser's stack — it falls back to the
    /// whole-string atom. Without the depth guard this aborts the process (SIGABRT).
    #[test]
    fn spdx_deeply_nested_parens_does_not_overflow() {
        let bomb = format!("{}MIT{}", "(".repeat(5000), ")".repeat(5000));
        // Must return (not abort); beyond the depth cap it's the whole-string atom.
        let expr = spdx_parse(&bomb);
        assert!(
            matches!(expr, SpdxExpr::Id(_)),
            "past the depth cap ⇒ whole-string fallback"
        );
        // A normally-shaped nested expression still parses to structure (not fallback).
        assert!(matches!(
            spdx_parse("(MIT OR Apache-2.0) AND BSD-3-Clause"),
            SpdxExpr::And(_)
        ));
    }

    /// End-to-end: a component licensed `MIT AND GPL-3.0` (GPL-3.0 denied) must Block,
    /// not slip to Warn — the fail-open the whole-string match had.
    #[test]
    fn spdx_and_expression_hitting_deny_blocks() {
        let art = Artifact::new(
            aref("cargo", "dual", "1.0.0"),
            Some("MIT AND GPL-3.0".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(
            v.decision,
            Decision::Block,
            "a denied license in an AND must Block"
        );
        assert!(v.findings.iter().any(|f| f.id.contains("LICENSE-DENIED")));
    }

    /// End-to-end: a dual-licensed `MIT OR Apache-2.0` (both allowed) must Pass, not
    /// warn as unreviewed (the false positive the whole-string match had).
    #[test]
    fn spdx_or_expression_with_allowed_disjunct_passes() {
        let art = Artifact::new(
            aref("cargo", "dual2", "1.0.0"),
            Some("MIT OR Apache-2.0".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert!(
            !v.findings.iter().any(|f| f.id.contains("LICENSE")),
            "an allowed disjunct ⇒ no license finding"
        );
    }

    /// The symmetric allow-list case-insensitivity: an allowed license spelled in a
    /// different case is allowed (Pass), not warned as unreviewed.
    #[test]
    fn allowed_license_is_case_insensitive() {
        // `scanner()`'s allow-list carries "MIT"; a lowercased `mit` must still pass.
        let art = Artifact::new(
            aref("cargo", "fine", "1.0.0"),
            Some("mit".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert!(
            !v.findings.iter().any(|f| f.id.contains("LICENSE")),
            "a case-variant of an allowed license is not a license finding"
        );
    }

    /// A license that is neither allowed nor denied, against a non-empty allow-list,
    /// is "unreviewed" ⇒ Warn.
    #[test]
    fn unreviewed_license_warns() {
        let art = Artifact::new(
            aref("cargo", "oddlicense", "1.0.0"),
            Some("BSL-1.1".into()),
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Warn);
        assert!(v.findings[0].id.contains("LICENSE-UNREVIEWED"));
        assert_eq!(v.findings[0].severity, Severity::Medium);
    }

    /// Unknown/unstated license follows `on_unknown` (Warn here).
    #[test]
    fn unknown_license_follows_policy() {
        let art = Artifact::new(
            aref("cargo", "nolicense", "1.0.0"),
            None,
            Provenance::signed_by("ci"),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Warn);
        assert_eq!(v.findings[0].id, "MODGUNN-LICENSE-UNKNOWN");
    }

    /// Provenance required + missing signature ⇒ Block (deny), even with a clean
    /// license and no vuln.
    #[test]
    fn missing_provenance_when_required_blocks() {
        let art = Artifact::new(
            aref("cargo", "serde", "1.0.0"),
            Some("MIT".into()),
            Provenance::unsigned(),
        );
        let v = scanner().scan(&art);
        assert_eq!(v.decision, Decision::Block, "Require + unsigned denies");
        assert_eq!(v.findings.len(), 1);
        assert_eq!(v.findings[0].id, "MODGUNN-PROVENANCE-MISSING");
    }

    /// Provenance only `Warn`: a missing signature warns rather than blocks.
    #[test]
    fn missing_provenance_when_warn_only_warns() {
        let mut s = scanner();
        s.policy.provenance = ProvenancePolicy::Warn;
        let art = Artifact::new(
            aref("cargo", "serde", "1.0.0"),
            Some("MIT".into()),
            Provenance::unsigned(),
        );
        let v = s.scan(&art);
        assert_eq!(v.decision, Decision::Warn);
        assert_eq!(v.findings[0].severity, Severity::Medium);
    }

    /// Provenance `Ignore`: a missing signature is silent (no finding).
    #[test]
    fn missing_provenance_when_ignored_is_silent() {
        let mut s = scanner();
        s.policy.provenance = ProvenancePolicy::Ignore;
        let art = Artifact::new(
            aref("cargo", "serde", "1.0.0"),
            Some("MIT".into()),
            Provenance::unsigned(),
        );
        let v = s.scan(&art);
        assert_eq!(v.decision, Decision::Pass);
        assert!(v.findings.is_empty());
    }

    /// The decision is the STRONGEST across checks: a Low vuln (Warn) plus an
    /// unsigned-when-required artifact (Block) ⇒ Block, and BOTH findings are kept.
    #[test]
    fn strongest_decision_wins_and_all_findings_kept() {
        let art = Artifact::new(
            aref("npm", "left-pad", "1.0.0"), // Low vuln ⇒ Warn
            Some("BSL-1.1".into()),           // unreviewed ⇒ Warn
            Provenance::unsigned(),           // required+missing ⇒ Block
        );
        let v = scanner().scan(&art);
        assert_eq!(
            v.decision,
            Decision::Block,
            "the Block from provenance wins"
        );
        assert_eq!(
            v.findings.len(),
            3,
            "vuln + license + provenance findings all retained"
        );
        let ids: Vec<&str> = v.findings.iter().map(|f| f.id.as_str()).collect();
        assert!(ids.contains(&"CVE-2099-LEFTPAD"));
        assert!(ids.iter().any(|id| id.contains("LICENSE-UNREVIEWED")));
        assert!(ids.contains(&"MODGUNN-PROVENANCE-MISSING"));
    }

    /// A low-severity advisory marked KEV (confirmed exploited) escalates to
    /// Critical ⇒ Block, and the reason is tagged `[KEV]`. (Default `kev_blocks`.)
    #[test]
    fn kev_escalates_low_vuln_to_block() {
        let mut adv = Advisory::basic(
            "CVE-KEV-1",
            "npm",
            "kevpkg",
            vec![],
            Severity::Low,
            "exploited",
        );
        adv.kev = true;
        let s = OsvScanner::new(
            Policy {
                version: "p".into(),
                ..Default::default()
            },
            AdvisoryDb::new("db", vec![adv]),
        );
        let art = Artifact::new(
            aref("npm", "kevpkg", "1.0.0"),
            Some("MIT".into()),
            Provenance::signed_by("ci"),
        );
        let v = s.scan(&art);
        assert_eq!(
            v.decision,
            Decision::Block,
            "a KEV hit blocks even at base Low"
        );
        assert_eq!(v.findings[0].severity, Severity::Critical);
        assert!(v.findings[0].summary.contains("[KEV]"));
    }

    /// EPSS escalation: a Low vuln with EPSS ≥ threshold is bumped to High ⇒ Block;
    /// below threshold it stays Warn but the probability is recorded in the reason.
    #[test]
    fn epss_threshold_escalates_and_is_recorded() {
        let mk = |epss: f64| {
            let mut a = Advisory::basic(
                "CVE-EPSS-1",
                "npm",
                "epsspkg",
                vec![],
                Severity::Low,
                "maybe-exploited",
            );
            a.epss = Some(epss);
            OsvScanner::new(
                Policy {
                    version: "p".into(),
                    epss_block_threshold: Some(0.5),
                    ..Default::default()
                },
                AdvisoryDb::new("db", vec![a]),
            )
        };
        let art = Artifact::new(
            aref("npm", "epsspkg", "1.0.0"),
            Some("MIT".into()),
            Provenance::signed_by("ci"),
        );
        let hot = mk(0.97).scan(&art);
        assert_eq!(
            hot.decision,
            Decision::Block,
            "EPSS 0.97 ≥ 0.5 ⇒ High ⇒ Block"
        );
        assert!(hot.findings[0].summary.contains("EPSS=0.97"));
        let cold = mk(0.10).scan(&art);
        assert_eq!(
            cold.decision,
            Decision::Warn,
            "EPSS 0.10 < 0.5 ⇒ stays Low ⇒ Warn"
        );
        assert!(cold.findings[0].summary.contains("EPSS=0.10"));
    }

    /// VEX `not_affected`: a matched advisory whose id is suppressed yields NO
    /// finding (the unreachable-CVE-noise killer), so an otherwise-clean dep passes.
    #[test]
    fn vex_not_affected_suppresses_the_finding() {
        let s = OsvScanner::new(
            Policy {
                version: "p".into(),
                vex_not_affected: vec!["RUSTSEC-2024-0001".into()],
                ..Default::default()
            },
            AdvisoryDb::new(
                "db",
                vec![Advisory::basic(
                    "RUSTSEC-2024-0001",
                    "cargo",
                    "openssl",
                    vec!["0.1.0".into()],
                    Severity::High,
                    "use-after-free",
                )],
            ),
        );
        let art = Artifact::new(
            aref("cargo", "openssl", "0.1.0"),
            Some("MIT".into()),
            Provenance::signed_by("ci"),
        );
        let v = s.scan(&art);
        assert_eq!(
            v.decision,
            Decision::Pass,
            "VEX not_affected suppresses the vuln"
        );
        assert!(
            v.findings.is_empty(),
            "no finding for a VEX-suppressed advisory"
        );
    }

    /// Pin the decision table + that `Decision`'s `Ord` ranks Pass < Warn < Block.
    #[test]
    fn decision_table_is_severity_ordered() {
        assert_eq!(decision_for(Severity::Critical), Decision::Block);
        assert_eq!(decision_for(Severity::High), Decision::Block);
        assert_eq!(decision_for(Severity::Medium), Decision::Warn);
        assert_eq!(decision_for(Severity::Low), Decision::Warn);
        assert_eq!(decision_for(Severity::Info), Decision::Pass);
        assert!(Decision::Block > Decision::Warn && Decision::Warn > Decision::Pass);
        assert_eq!(decide(&[]), Decision::Pass);
    }
}