Gunnar

mikronova/modgunn

src/baddie.rs

//! Baddie-detection **verdict semantics** (feature `scan`) — the b5 trichotomy.
//!
//! `.nornir/baddie-detection-design.md` draws the ownership line: modgunn owns the
//! *data* and *whether a node is a baddie*; nornir/smid's Mímir lens owns *how the
//! baddie is drawn*. Until now the second half of that contract lived only in prose —
//! `vuln_findings` stored a severity and an advisory id, so the lens had to
//! re-derive "is this refuse or hold?" from id prefixes and summary markers. That is
//! exactly the duplicated decision the design forbids. This module is the ONE
//! implementation of the rule; the verdict it returns is persisted alongside every
//! finding so the lens reads it instead of recomputing it.
//!
//! **No new trichotomy enum.** refuse/hold/act is the *same* three-way decision
//! [`Decision`](crate::core::Decision) already carries constellation-wide
//! (`Block`/`Warn`/`Pass` — what holger's gate speaks), so this module reuses it
//! rather than twinning a parallel vocabulary (L5). The rendering mapping is:
//!
//! | [`Decision`] | baddie label | lens colour |
//! |---|---|---|
//! | `Block` | **refuse** | 🔴 |
//! | `Warn`  | **hold**   | 🟠 |
//! | `Pass`  | **act**    | 🟢 |
//!
//! What this module adds on top of `Decision` is **why**: the [`BaddieAxis`] that
//! fired (b1 reachable-vuln, b2 malicious dep, b3 KEV / yank cascade, b4 leak path,
//! b6 IOC) and the [`BaddieVerdict::rank`] position on the *evidence ladder*, so two
//! findings with the same colour still sort by how strong the evidence is.

use crate::core::{Decision, Severity};
use crate::feeds::PUBLIC_EXPLOIT_MARKER;

/// What the A8 call-closure knows about a finding's sink.
///
/// The distinction that matters is `Unknown` vs `Unreachable`: the present-on-disk
/// base join ([`scan_fleet`](crate::warehouse::Warehouse::scan_fleet)) has run NO
/// reachability analysis, so it must not claim the finding is unreachable — that
/// would silently demote a live vulnerability. `Unreachable` is only ever produced
/// by an actual closure walk that looked and did not find the sink.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Reachability {
    /// No call closure was computed for this finding (the base present-on-disk join).
    #[default]
    Unknown,
    /// The CVE'd symbol IS transitively called from a repo entry point.
    Reachable,
    /// The closure was walked and the CVE'd symbol is NOT called (present-but-dead).
    Unreachable,
}

/// Which baddie-detection axis produced a verdict. The `bN` labels are the sections
/// of `.nornir/baddie-detection-design.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaddieAxis {
    /// No finding joins to this node.
    Clean,
    /// The base join: an advisory matches a resolved component, reachability unknown.
    PresentVuln,
    /// b1 — the CVE'd symbol is (or provably is not) called from an entry point.
    ReachableVuln,
    /// b2 — an OpenSSF `MAL-*` malicious-package record on a node in the dep closure.
    MaliciousDep,
    /// b3 — CISA KEV / ENISA EUVD confirmed exploited-in-the-wild.
    KevCascade,
    /// b3 — the registry itself pulled the pinned version back (yank / deprecation /
    /// package removal).
    YankCascade,
    /// b4 — a warehouse-credential read reachable from an untrusted entry point.
    LeakPath,
    /// b6 — an active-malware IOC (abuse.ch URL / C2 IP / known-bad hash) matched.
    Ioc,
}

impl BaddieAxis {
    /// The canonical label — the single source of the axis strings, so the warehouse
    /// column, the CLI and the Mímir lens all name an axis the same way.
    pub fn as_str(self) -> &'static str {
        match self {
            BaddieAxis::Clean => "clean",
            BaddieAxis::PresentVuln => "present-vuln",
            BaddieAxis::ReachableVuln => "reachable-vuln",
            BaddieAxis::MaliciousDep => "malicious-dep",
            BaddieAxis::KevCascade => "kev-cascade",
            BaddieAxis::YankCascade => "yank-cascade",
            BaddieAxis::LeakPath => "leak-path",
            BaddieAxis::Ioc => "ioc",
        }
    }

    /// Inverse of [`as_str`](BaddieAxis::as_str) for reading a persisted row back.
    /// An unknown label is `None` — never a silent default, so a schema drift shows
    /// up as a missing axis rather than a wrong one.
    pub fn from_label(s: &str) -> Option<Self> {
        Some(match s.trim() {
            "clean" => BaddieAxis::Clean,
            "present-vuln" => BaddieAxis::PresentVuln,
            "reachable-vuln" => BaddieAxis::ReachableVuln,
            "malicious-dep" => BaddieAxis::MaliciousDep,
            "kev-cascade" => BaddieAxis::KevCascade,
            "yank-cascade" => BaddieAxis::YankCascade,
            "leak-path" => BaddieAxis::LeakPath,
            "ioc" => BaddieAxis::Ioc,
            _ => return None,
        })
    }
}

impl std::fmt::Display for BaddieAxis {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The b5 verdict for one node: what to do, which axis said so, and where the
/// evidence sits on the ladder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaddieVerdict {
    /// refuse (`Block`) / hold (`Warn`) / act (`Pass`) — see the module table.
    pub decision: Decision,
    pub axis: BaddieAxis,
    /// Evidence strength, `0..=9`; higher sorts first WITHIN a verdict bucket. See
    /// [`rank`](BaddieVerdict::rank) for the ladder.
    pub rank: u8,
    /// One-line human justification (goes straight into the lens tooltip).
    pub reason: String,
}

impl BaddieVerdict {
    /// The baddie label for the decision — `refuse` / `hold` / `act`, the vocabulary
    /// `.nornir/baddie-detection-design.md` and the Mímir lens speak. A *rendering*
    /// of [`Decision`], not a second enum.
    pub fn label(&self) -> &'static str {
        match self.decision {
            Decision::Block => "refuse",
            Decision::Warn => "hold",
            Decision::Pass => "act",
        }
    }

    /// Sort key for ordering a bucket of verdicts strongest-evidence-first:
    /// `(decision desc, rank desc)`.
    pub fn sort_key(&self) -> (std::cmp::Reverse<Decision>, std::cmp::Reverse<u8>) {
        (
            std::cmp::Reverse(self.decision),
            std::cmp::Reverse(self.rank),
        )
    }
}

/// Does this advisory id come from the OpenSSF **Malicious Packages** feed? Those
/// ids are `MAL-<year>-<n>` ([`feeds::parse_malicious`](crate::feeds::parse_malicious)).
/// Anchored on the `MAL-` prefix, so a package *named* `mal-something` inside a
/// normal CVE id can't be mistaken for malware.
fn is_malicious_id(id: &str) -> bool {
    id.starts_with("MAL-")
}

/// Does this advisory id come from a **registry pull-back** — a crates.io/PyPI/Go
/// yank, an npm deprecation, or an ecosyste.ms removed/deprecated package status?
/// All of them are minted by [`registry`](crate::registry) with a `MODGUNN-` prefix
/// and one of these three markers.
fn is_registry_pullback_id(id: &str) -> bool {
    id.starts_with("MODGUNN-YANK-")
        || id.starts_with("MODGUNN-DEPRECATED-")
        || id.starts_with("MODGUNN-ECOSYSTEMS-")
}

/// Classify one finding into the b5 trichotomy.
///
/// The rules, in the order they are checked — each one is an *escalation* the later
/// rules cannot undo:
///
/// 1. **b2 malicious package** (`MAL-*`) ⇒ **refuse**, rank 9, **no reachability
///    gate**: for malware the *install* is the payload, so "the vulnerable function
///    is never called" is irrelevant. Checked first precisely so a `MAL-*` row that
///    is also unreachable is not demoted to hold.
/// 2. **b3 KEV cascade** (`kev`) ⇒ **refuse**. Confirmed exploited in the wild by
///    CISA or ENISA — direct evidence, so it refuses even at `Unknown` reachability
///    (rank 8), and ranks top of the CVE ladder when also `Reachable` (rank 9).
///    An explicitly-walked `Unreachable` KEV still holds at rank 7 rather than
///    refusing: the closure looked and the sink is dead code.
/// 3. **b1 reachable** ⇒ **refuse** (rank 6, or 7 with a public exploit): the CVE'd
///    symbol is transitively called from an entry point.
/// 4. **b1 unreachable** ⇒ **hold** (rank 2): present but provably not called — the
///    80–90%-FP demotion. Still surfaced, because a future call edge makes it live
///    (the A10 sink-newly-reachable tripwire).
/// 5. **b3 yank / deprecation** ⇒ **hold** (rank 1): the registry retracted the
///    pinned version. Advisory-grade on its own; it becomes refuse only via rule 1
///    when a `MAL-*` alias lands on the same coords (a malware takedown).
/// 6. **base present-on-disk** ⇒ **hold** (rank 4 with a public exploit, else 3).
///    `Unknown` reachability never yields refuse: refuse is reserved for
///    evidence-backed danger (reachable / in-the-wild / malware / IOC), which is the
///    entire point of the b1 join. It also never yields *act* — the advisory does
///    match a resolved component; it is a real, un-triaged finding.
///
/// `severity` participates only in the `Info` cut (an `Info`-graded present-vuln with
/// no other signal is act/clean, not a hold) — the trichotomy is evidence-driven, not
/// severity-driven, which is what separates it from the [`Policy`](crate::scan::Policy)
/// gate.
pub fn classify(
    advisory_id: &str,
    severity: Severity,
    kev: bool,
    summary: &str,
    reach: Reachability,
) -> BaddieVerdict {
    let public_exploit = summary.contains(PUBLIC_EXPLOIT_MARKER);
    let v = |decision, axis, rank, reason: String| BaddieVerdict {
        decision,
        axis,
        rank,
        reason,
    };

    // 1 · b2 — malware. No reachability gate; the install IS the payload.
    if is_malicious_id(advisory_id) {
        return v(
            Decision::Block,
            BaddieAxis::MaliciousDep,
            9,
            format!("{advisory_id}: malicious package (OpenSSF) — install is the payload, no reachability gate"),
        );
    }

    // 2 · b3 — KEV / EUVD confirmed exploited in the wild.
    if kev {
        return match reach {
            Reachability::Reachable => v(
                Decision::Block,
                BaddieAxis::KevCascade,
                9,
                format!("{advisory_id}: exploited in the wild AND reachable from an entry point"),
            ),
            Reachability::Unknown => v(
                Decision::Block,
                BaddieAxis::KevCascade,
                8,
                format!("{advisory_id}: exploited in the wild (reachability not computed)"),
            ),
            Reachability::Unreachable => v(
                Decision::Warn,
                BaddieAxis::KevCascade,
                7,
                format!("{advisory_id}: exploited in the wild but the sink is not called — hold, a new call edge makes it live"),
            ),
        };
    }

    // 3/4 · b1 — the call-closure verdict, when one was computed.
    match reach {
        Reachability::Reachable => {
            let (rank, why) = if public_exploit {
                (7, "reachable from an entry point, public exploit available")
            } else {
                (6, "reachable from an entry point")
            };
            return v(
                Decision::Block,
                BaddieAxis::ReachableVuln,
                rank,
                format!("{advisory_id}: {why}"),
            );
        }
        Reachability::Unreachable => {
            return v(
                Decision::Warn,
                BaddieAxis::ReachableVuln,
                2,
                format!("{advisory_id}: present but not reachable from any entry point — demoted"),
            );
        }
        Reachability::Unknown => {}
    }

    // 5 · b3 — the registry pulled the pinned version back.
    if is_registry_pullback_id(advisory_id) {
        return v(
            Decision::Warn,
            BaddieAxis::YankCascade,
            1,
            format!("{advisory_id}: the registry retracted this version — pin forward"),
        );
    }

    // 6 · base — present on disk, un-triaged.
    if severity == Severity::Info {
        return v(
            Decision::Pass,
            BaddieAxis::Clean,
            0,
            format!("{advisory_id}: informational only"),
        );
    }
    let (rank, why) = if public_exploit {
        (
            4,
            "present in a resolved component, public exploit available (reachability not computed)",
        )
    } else {
        (
            3,
            "present in a resolved component (reachability not computed)",
        )
    };
    v(
        Decision::Warn,
        BaddieAxis::PresentVuln,
        rank,
        format!("{advisory_id}: {why}"),
    )
}

/// Grade one advisory by the b1 call-closure's two output sets.
///
/// `reachable` is the advisory-id set the closure proved CALLED from an entry point
/// ([`scip::reachability_gated_vulns`](crate::scip::reachability_gated_vulns)) and
/// `walked` is every advisory the closure had a symbol-level sink for (reachable ∪
/// suppressed). An advisory in NEITHER set was never analysed — the corpus has no
/// symbol-level record for it (only the Go vuln DB ships symbols today) — so it
/// grades [`Reachability::Unknown`], **not** `Unreachable`. Conflating the two is the
/// single most dangerous mistake in this join: it would silently demote every
/// non-Go CVE in the fleet to "present-but-dead" on no evidence at all.
pub fn grade_by_closure(
    advisory_id: &str,
    reachable: &std::collections::HashSet<String>,
    walked: &std::collections::HashSet<String>,
) -> Reachability {
    if reachable.contains(advisory_id) {
        Reachability::Reachable
    } else if walked.contains(advisory_id) {
        Reachability::Unreachable
    } else {
        Reachability::Unknown
    }
}

/// The b6 verdict: an active-malware IOC hit. Unconditional **refuse** at the top of
/// the ladder — a match IS the compromise (a dependency served from a known
/// malware-distribution URL, a build output byte-identical to a known sample, an
/// egress to a botnet C2), so there is no reachability gate and no severity input.
/// Separate from [`classify`] because its input is an [`Ioc`](crate::ioc::Ioc) match,
/// not an advisory row.
pub fn classify_ioc(kind: &str, value: &str, threat: &str, source: &str) -> BaddieVerdict {
    BaddieVerdict {
        decision: Decision::Block,
        axis: BaddieAxis::Ioc,
        rank: 9,
        reason: format!(
            "active-malware IOC {kind} `{value}` ({threat}, {source}) — a match is the compromise"
        ),
    }
}

/// The clean verdict — nothing joins to this node. The 🟢 *act* leg of the
/// trichotomy, so a consumer can colour every node from one function.
pub fn clean() -> BaddieVerdict {
    BaddieVerdict {
        decision: Decision::Pass,
        axis: BaddieAxis::Clean,
        rank: 0,
        reason: "no finding joins to this node".to_string(),
    }
}

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

    /// The one rule the whole b2 axis rests on: malware is NEVER demoted by the
    /// reachability gate. Reverting `is_malicious_id` to fire after the reachability
    /// match (or dropping it) turns the `Unreachable` case into a hold — this test
    /// goes red.
    #[test]
    fn malicious_package_refuses_even_when_provably_unreachable() {
        for reach in [
            Reachability::Unknown,
            Reachability::Reachable,
            Reachability::Unreachable,
        ] {
            let v = classify(
                "MAL-2024-1234",
                Severity::Critical,
                false,
                "malicious package: stealer",
                reach,
            );
            assert_eq!(
                v.decision,
                Decision::Block,
                "malware refuses at reachability {reach:?}"
            );
            assert_eq!(v.axis, BaddieAxis::MaliciousDep);
            assert_eq!(v.label(), "refuse");
            assert_eq!(v.rank, 9, "malware sits at the top of the evidence ladder");
        }
        // A package merely *named* like a MAL id must not be mistaken for malware:
        // the prefix is anchored, so a normal CVE stays on the CVE ladder.
        let normal = classify(
            "CVE-2021-MAL-1",
            Severity::Critical,
            false,
            "",
            Reachability::Unreachable,
        );
        assert_eq!(normal.axis, BaddieAxis::ReachableVuln);
        assert_eq!(normal.decision, Decision::Warn);
    }

    /// The b1 demotion — the 80–90%-FP killer — and the `Unknown` ≠ `Unreachable`
    /// distinction that keeps it honest. Collapsing `Unknown` into `Unreachable`
    /// (the tempting simplification) would silently demote every base-join finding
    /// to the same bucket as a proven-dead one; the rank assertions catch that.
    #[test]
    fn reachability_demotes_only_when_the_closure_actually_looked() {
        let reachable = classify(
            "CVE-2021-44228",
            Severity::High,
            false,
            "rce",
            Reachability::Reachable,
        );
        assert_eq!(
            (reachable.decision, reachable.axis),
            (Decision::Block, BaddieAxis::ReachableVuln)
        );

        let dead = classify(
            "CVE-2021-44228",
            Severity::High,
            false,
            "rce",
            Reachability::Unreachable,
        );
        assert_eq!(
            (dead.decision, dead.axis),
            (Decision::Warn, BaddieAxis::ReachableVuln)
        );

        let unknown = classify(
            "CVE-2021-44228",
            Severity::High,
            false,
            "rce",
            Reachability::Unknown,
        );
        assert_eq!(
            (unknown.decision, unknown.axis),
            (Decision::Warn, BaddieAxis::PresentVuln)
        );
        assert!(
            unknown.rank > dead.rank,
            "an un-analysed finding (rank {}) must outrank a PROVEN-dead one (rank {}) — \
             collapsing Unknown into Unreachable would bury real work under demoted noise",
            unknown.rank,
            dead.rank
        );
        assert!(
            reachable.rank > unknown.rank,
            "proven-reachable outranks un-analysed"
        );
    }

    /// b3 — in-the-wild exploitation is direct evidence, so it refuses without a
    /// closure; a public PoC is weaker and only breaks ties. Reverting the KEV branch
    /// to fall through to the base rule turns the `Unknown` case into a hold.
    #[test]
    fn kev_outranks_public_exploit_which_outranks_version_only() {
        let kev_reach = classify("CVE-1", Severity::High, true, "", Reachability::Reachable);
        let kev_only = classify("CVE-2", Severity::High, true, "", Reachability::Unknown);
        let poc_reach = classify(
            "CVE-3",
            Severity::High,
            false,
            "[public-exploit] rce",
            Reachability::Reachable,
        );
        let plain_reach = classify(
            "CVE-4",
            Severity::High,
            false,
            "rce",
            Reachability::Reachable,
        );
        let poc_present = classify(
            "CVE-5",
            Severity::High,
            false,
            "[public-exploit] rce",
            Reachability::Unknown,
        );
        let plain_present = classify("CVE-6", Severity::High, false, "rce", Reachability::Unknown);

        assert_eq!(
            kev_only.decision,
            Decision::Block,
            "in-the-wild refuses with no closure"
        );
        assert_eq!(kev_only.axis, BaddieAxis::KevCascade);
        // The full ladder: KEV+reachable > KEV > PoC+reachable > reachable > PoC > present.
        let ladder = [
            &kev_reach,
            &kev_only,
            &poc_reach,
            &plain_reach,
            &poc_present,
            &plain_present,
        ];
        for w in ladder.windows(2) {
            assert!(
                w[0].rank > w[1].rank,
                "evidence ladder broken: {} (rank {}) must outrank {} (rank {})",
                w[0].axis,
                w[0].rank,
                w[1].axis,
                w[1].rank
            );
        }
        // A KEV the closure PROVED dead holds rather than refusing — the closure ran.
        let kev_dead = classify("CVE-7", Severity::High, true, "", Reachability::Unreachable);
        assert_eq!(kev_dead.decision, Decision::Warn);
        assert_eq!(
            kev_dead.axis,
            BaddieAxis::KevCascade,
            "still attributed to the KEV axis"
        );
    }

    /// b3 yank cascade — every registry pull-back id shape lands on the yank axis at
    /// hold, and a malware takedown on the same coords (a `MAL-*` row) escalates.
    #[test]
    fn registry_pullbacks_hold_on_the_yank_axis() {
        for id in [
            "MODGUNN-YANK-cargo-openssl-src-300.0.9",
            "MODGUNN-YANK-pypi-requests-2.0.0",
            "MODGUNN-YANK-go-example.com/m-v1.2.3",
            "MODGUNN-DEPRECATED-npm-request-2.88.2",
            "MODGUNN-ECOSYSTEMS-REMOVED-npm-evil-pkg",
            "MODGUNN-ECOSYSTEMS-DEPRECATED-pypi-old",
        ] {
            let v = classify(
                id,
                Severity::Low,
                false,
                "yanked; upgrade to 300.1.0",
                Reachability::Unknown,
            );
            assert_eq!(v.decision, Decision::Warn, "{id} holds");
            assert_eq!(
                v.axis,
                BaddieAxis::YankCascade,
                "{id} is attributed to the yank cascade"
            );
            assert_eq!(v.label(), "hold");
        }
        // The malware-takedown escalation: a MAL row on the same coords refuses.
        let takedown = classify(
            "MAL-2024-9",
            Severity::Critical,
            false,
            "",
            Reachability::Unknown,
        );
        assert_eq!(takedown.decision, Decision::Block);
    }

    /// The b1 grading rule: only an advisory the closure ACTUALLY had a sink for can
    /// be graded `Unreachable`. Reverting `grade_by_closure`'s else-branch to
    /// `Unreachable` (the "if it isn't reachable it must be dead" shortcut) demotes
    /// every non-Go CVE in the fleet on no evidence — this test goes red.
    #[test]
    fn an_advisory_the_closure_never_saw_grades_unknown_not_unreachable() {
        use std::collections::HashSet;
        let reachable: HashSet<String> = ["GO-2021-0113".to_string()].into_iter().collect();
        let walked: HashSet<String> = ["GO-2021-0113".to_string(), "GO-2022-0969".to_string()]
            .into_iter()
            .collect();

        assert_eq!(
            grade_by_closure("GO-2021-0113", &reachable, &walked),
            Reachability::Reachable
        );
        assert_eq!(
            grade_by_closure("GO-2022-0969", &reachable, &walked),
            Reachability::Unreachable
        );
        // No symbol-level record exists for this CVE — never analysed, so UNKNOWN.
        assert_eq!(
            grade_by_closure("CVE-2021-44228", &reachable, &walked),
            Reachability::Unknown
        );
        // …and that difference is load-bearing: the un-analysed one holds as a
        // present-vuln, the proven-dead one holds demoted on the reachable-vuln axis.
        let unseen = classify(
            "CVE-2021-44228",
            Severity::Critical,
            false,
            "rce",
            grade_by_closure("CVE-2021-44228", &reachable, &walked),
        );
        assert_eq!(unseen.axis, BaddieAxis::PresentVuln);
        let dead = classify(
            "GO-2022-0969",
            Severity::Critical,
            false,
            "",
            grade_by_closure("GO-2022-0969", &reachable, &walked),
        );
        assert_eq!(dead.axis, BaddieAxis::ReachableVuln);
        assert!(unseen.rank > dead.rank);
    }

    /// b6 — an IOC hit is unconditional refuse, ranked with malware at the top.
    #[test]
    fn ioc_hit_is_unconditional_refuse() {
        let v = classify_ioc(
            "sha256",
            "ab".repeat(32).as_str(),
            "Emotet",
            "MalwareBazaar",
        );
        assert_eq!(
            (v.decision, v.axis, v.rank),
            (Decision::Block, BaddieAxis::Ioc, 9)
        );
        assert_eq!(v.label(), "refuse");
        assert_eq!(clean().label(), "act");
        assert_eq!(clean().decision, Decision::Pass);
    }

    /// The axis label round-trips through the warehouse column, and an unknown label
    /// is `None` — never a silent wrong axis.
    #[test]
    fn axis_labels_round_trip_and_reject_garbage() {
        for a in [
            BaddieAxis::Clean,
            BaddieAxis::PresentVuln,
            BaddieAxis::ReachableVuln,
            BaddieAxis::MaliciousDep,
            BaddieAxis::KevCascade,
            BaddieAxis::YankCascade,
            BaddieAxis::LeakPath,
            BaddieAxis::Ioc,
        ] {
            assert_eq!(
                BaddieAxis::from_label(a.as_str()),
                Some(a),
                "{a} must round-trip"
            );
        }
        assert_eq!(
            BaddieAxis::from_label("reachable_vuln"),
            None,
            "a near-miss label is not silently accepted"
        );
        assert_eq!(BaddieAxis::from_label(""), None);
    }

    /// Sorting a mixed bucket puts refuse-with-the-strongest-evidence first — the
    /// order the Mímir lens renders in.
    #[test]
    fn sort_key_orders_refuse_first_then_by_evidence() {
        let mut vs = vec![
            classify(
                "MODGUNN-YANK-cargo-a-1.0.0",
                Severity::Low,
                false,
                "",
                Reachability::Unknown,
            ),
            classify(
                "CVE-plain",
                Severity::High,
                false,
                "rce",
                Reachability::Unknown,
            ),
            classify(
                "MAL-2024-1",
                Severity::Critical,
                false,
                "",
                Reachability::Unknown,
            ),
            classify("CVE-kev", Severity::High, true, "", Reachability::Reachable),
            classify(
                "CVE-dead",
                Severity::High,
                false,
                "",
                Reachability::Unreachable,
            ),
        ];
        vs.sort_by_key(|v| v.sort_key());
        let axes: Vec<_> = vs.iter().map(|v| v.axis).collect();
        assert_eq!(
            axes,
            vec![
                BaddieAxis::MaliciousDep,  // refuse, rank 9
                BaddieAxis::KevCascade,    // refuse, rank 9 (stable after malware)
                BaddieAxis::PresentVuln,   // hold, rank 3
                BaddieAxis::ReachableVuln, // hold, rank 2 (proven dead)
                BaddieAxis::YankCascade,   // hold, rank 1
            ],
            "refuse bucket first, then holds by descending evidence"
        );
    }
}