//! 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"
);
}
}