src/mimir_lens/reachable_vuln.rs
//! **Lens — `Mimir.ReachableVuln`: the b1 reachable-vuln join (baddie-detection).**
//!
//! The flagship supply-chain join (`baddie-detection-design.md` §b1, the
//! "80–90 % false-positive killer"). A CVE/advisory that is merely PRESENT in a
//! repo's resolved components is noise; the danger is a vulnerable crate that the
//! repo's code actually **USES**. This lens escalates:
//!
//! `vuln_findings` × `sbom_components` × the code room
//!
//! 1. `vuln_findings` (modgunn's persisted fleet scan, keyed `(crate, version)`) ⋈
//! `sbom_components` (`repo`, `name`, `version`) → the present-on-disk danger set
//! attributed to the repos that resolve the vulnerable version.
//! 2. **Reachability demotion:** the crate is *reachable* iff the repo's own code
//! has a CALLS / USES_TYPE edge into an item of that crate AT THAT VERSION (the
//! callee as rustc resolved it — [`DepUse`]). Reachable ⇒ **refuse 🔴**;
//! present-but-unreachable ⇒ **hold 🟠** (a future call edge makes it live — the
//! A10 sink-newly-reachable set).
//!
//! **Ownership boundary** (`baddie-detection-design.md`): modgunn OWNS the data
//! (`vuln_findings`, keyed by purl so it joins `sbom_components`) and the verdict
//! *semantics*; THIS lens (nornir) renders the join + the reachability demotion and
//! feeds the red-marked CPG. It never invents an advisory.
//!
//! Anti-silent-empty: an unfed `vuln_findings` reports a MISSING FEED via
//! [`evidence_note`] (run `modgunn warehouse scan --persist`), never a false
//! "no vulnerabilities".
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::sources::{Feed, Sources, evidence_note};
/// A crate name in one form: hyphens fold to underscores, so an sbom component
/// `aho-corasick` matches the room's crate whichever way it is spelled.
pub fn norm_crate(s: &str) -> String {
s.trim().replace('-', "_")
}
/// How much of a dependency a repo's own code uses: CALLS + USES_TYPE sites
/// from the repo's items into the items of one `crate@version`, keyed
/// `(repo, norm_crate(crate), version)`.
pub type DepUse = BTreeMap<(String, String, String), usize>;
/// refuse 🔴 = reachable; hold 🟠 = present-but-unreachable. `act` is never emitted
/// (a node with no finding is simply absent), matching the Trust lens palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Verdict {
Refuse,
Hold,
}
impl Verdict {
pub fn as_str(&self) -> &'static str {
match self {
Verdict::Refuse => "refuse",
Verdict::Hold => "hold",
}
}
pub fn colour(&self) -> &'static str {
match self {
Verdict::Refuse => "🔴",
Verdict::Hold => "🟠",
}
}
}
/// One escalated finding: a vulnerable crate resolved by a repo, with the
/// reachability verdict + the call-site evidence.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReachableVulnFinding {
/// The repo that resolves the vulnerable component.
pub repo: String,
/// The vulnerable crate.
pub krate: String,
/// The exact resolved version modgunn's scan matched.
pub version: String,
/// The advisory id(s) (CVE / RUSTSEC / MAL-* / MODGUNN-YANK-*), as stored.
pub advisory_ids: String,
/// The advisory summary (carries the `[public-exploit]` marker + `upgrade to X`).
pub summary: String,
/// Is the crate actually CALLED from this repo (not merely on disk)?
pub reachable: bool,
/// CALLS + USES_TYPE sites from the repo's own code into the vulnerable crate at
/// this version — the weakest-hop evidence + the rank within a verdict bucket.
pub call_sites: usize,
/// refuse (reachable) or hold (present-but-unreachable).
pub verdict: Verdict,
}
impl ReachableVulnFinding {
pub fn explain(&self) -> String {
if self.reachable {
format!(
"{} `{}@{}` is REACHABLE in {} — {} call site(s) target it ({}) → {} REFUSE",
self.verdict.colour(),
self.krate,
self.version,
self.repo,
self.call_sites,
self.advisory_ids,
self.verdict.as_str(),
)
} else {
format!(
"{} `{}@{}` is present in {} but NO call reaches it ({}) → {} HOLD (present-but-unreachable)",
self.verdict.colour(),
self.krate,
self.version,
self.repo,
self.advisory_ids,
self.verdict.as_str(),
)
}
}
}
/// The whole reading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReachableVulnReport {
/// Distinct `(crate, version)` advisories in `vuln_findings`.
pub findings_seen: usize,
/// `sbom_components` rows examined (latest snapshot per repo).
pub components_seen: usize,
/// Findings escalated to refuse 🔴 (reachable).
pub reachable_refuse: usize,
/// Findings held 🟠 (present-but-unreachable).
pub present_hold: usize,
/// The ranked danger list (refuse before hold, then most call sites).
pub worst: Vec<ReachableVulnFinding>,
pub feeds: Vec<Feed>,
pub evidence_note: String,
pub model_attribution: String,
}
pub const ATTRIBUTION: &str = "Composition of nornir + modgunn primitives: modgunn's \
vuln_findings (persisted fleet scan, keyed crate|version) ⋈ nornir's sbom_components \
(repo|name|version) → present-on-disk danger, then DEMOTED by nornir's code room — a \
crate is REACHABLE only if the repo's own code calls or names an item of it at that \
version (rustc-resolved CALLS / USES_TYPE). Reachable ⇒ refuse, present-but-unreachable ⇒ hold. The reachability \
gate is the 80–90% false-positive killer of on-disk-only supply-chain scanning; no IDE \
or SCA tool joins the advisory plane to the repo's own call graph.";
impl ReachableVulnReport {
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "reachable_vuln",
"findings_seen": self.findings_seen,
"components_seen": self.components_seen,
"reachable_refuse": self.reachable_refuse,
"present_hold": self.present_hold,
"evidence_note": self.evidence_note,
"model_attribution": self.model_attribution,
"feeds": self.feeds,
"worst": self.worst.iter().map(|f| serde_json::json!({
"repo": f.repo,
"crate": f.krate,
"version": f.version,
"advisory_ids": f.advisory_ids,
"summary": f.summary,
"reachable": f.reachable,
"call_sites": f.call_sites,
"verdict": f.verdict.as_str(),
"colour": f.verdict.colour(),
"explain": f.explain(),
})).collect::<Vec<_>>(),
})
}
}
/// **The b1 reachable-vuln fold.** Pure — no IO. `sources` carries
/// `vuln_findings` + `sbom_components`; `uses` is the room's [`DepUse`];
/// `repo_filter` restricts the attributed repos (""=all). A `[public-exploit]`
/// summary marker sorts first within its verdict bucket (KEV/PoC evidence ladder
/// from the design doc §b3).
pub fn analyze(sources: &Sources, uses: &DepUse, feeds: Vec<Feed>, repo_filter: &str, top_n: usize) -> ReachableVulnReport {
// ── the advisory plane: (norm crate, version) → (ids, summary) ────────────────
let vf = sources.get("vuln_findings");
let vc = vf.cursor(&["crate", "version", "ids", "summary"]);
let mut advisories: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
for r in vc.rows() {
let krate = norm_crate(vc.get(r, 0));
let version = vc.get(r, 1).trim().to_string();
if krate.is_empty() {
continue;
}
advisories.insert(
(krate, version),
(vc.get(r, 2).to_string(), vc.get(r, 3).to_string()),
);
}
let findings_seen = advisories.len();
// ── the base join: sbom components that resolve a vulnerable (crate, version) ──
let sbom = sources.get("sbom_components");
let sc = sbom.cursor(&["repo", "name", "version"]);
let mut components_seen = 0usize;
// Dedup (repo, crate, version) — a component can appear once per snapshot row.
let mut candidates: BTreeSet<(String, String, String)> = BTreeSet::new();
for r in sc.rows() {
components_seen += 1;
let repo = sc.get(r, 0);
if !repo_filter.is_empty() && repo != repo_filter {
continue;
}
let name = norm_crate(sc.get(r, 1));
let version = sc.get(r, 2).trim().to_string();
if advisories.contains_key(&(name.clone(), version.clone())) {
candidates.insert((repo.to_string(), name, version));
}
}
// ── escalate each candidate by reachability ───────────────────────────────────
let mut findings: Vec<ReachableVulnFinding> = candidates
.into_iter()
.map(|(repo, krate, version)| {
let (ids, summary) = advisories
.get(&(krate.clone(), version.clone()))
.cloned()
.unwrap_or_default();
let call_sites = uses.get(&(repo.clone(), krate.clone(), version.clone())).copied().unwrap_or(0);
let reachable = call_sites > 0;
ReachableVulnFinding {
repo,
krate,
version,
advisory_ids: ids,
summary,
reachable,
call_sites,
verdict: if reachable {
Verdict::Refuse
} else {
Verdict::Hold
},
}
})
.collect();
let reachable_refuse = findings
.iter()
.filter(|f| f.verdict == Verdict::Refuse)
.count();
let present_hold = findings
.iter()
.filter(|f| f.verdict == Verdict::Hold)
.count();
// Rank: refuse before hold; then a public-exploit / KEV marker; then most call
// sites (widest reachable blast); then repo/crate for a stable order.
let exploit = |f: &ReachableVulnFinding| {
let s = f.summary.to_ascii_lowercase();
s.contains("[public-exploit]") || s.contains("kev")
};
findings.sort_by(|a, b| {
// refuse (0) sorts before hold (1)
let av = if a.verdict == Verdict::Refuse { 0 } else { 1 };
let bv = if b.verdict == Verdict::Refuse { 0 } else { 1 };
av.cmp(&bv)
.then_with(|| (exploit(b) as u8).cmp(&(exploit(a) as u8)))
.then_with(|| b.call_sites.cmp(&a.call_sites))
.then_with(|| a.repo.cmp(&b.repo))
.then_with(|| a.krate.cmp(&b.krate))
});
findings.truncate(top_n);
ReachableVulnReport {
findings_seen,
components_seen,
reachable_refuse,
present_hold,
worst: findings,
evidence_note: evidence_note(&feeds, "ReachableVuln (b1 supply-chain reachability)"),
model_attribution: ATTRIBUTION.to_string(),
feeds,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mimir_lens::rows::Table;
fn mk(cols: &[&str], rows: &[&[&str]]) -> Table {
Table::new(cols.iter().map(|s| s.to_string()).collect(), rows.iter().map(|r| r.iter().map(|s| s.to_string()).collect()).collect())
}
fn sources(vuln: &[&[&str]], sbom: &[&[&str]]) -> Sources {
let mut s = Sources::default();
s.add_root(
"/t",
[
("vuln_findings".to_string(), mk(&["crate", "version", "ids", "summary"], vuln)),
("sbom_components".to_string(), mk(&["repo", "name", "version"], sbom)),
]
.into(),
);
s
}
fn uses(rows: &[(&str, &str, &str, usize)]) -> DepUse {
rows.iter().map(|(r, c, v, n)| ((r.to_string(), norm_crate(c), v.to_string()), *n)).collect()
}
fn run(s: &Sources, u: &DepUse, repo: &str) -> ReachableVulnReport {
analyze(s, u, s.feeds(&["vuln_findings", "sbom_components"]), repo, 50)
}
/// **Red-when-reverted core.** Two repos resolve the same vulnerable crate; only
/// the one that USES it is refused, the other is held. Drop the reachability
/// demotion (make every present finding refuse) and the hold becomes a refuse —
/// the exact 80–90 % false positive the gate exists to kill.
#[test]
fn reachable_is_refused_present_but_unreachable_is_held() {
let s = sources(
&[&["time", "0.1.45", "RUSTSEC-2020-0071", "segfault; upgrade to 0.2"]],
&[
&["appA", "time", "0.1.45"], // appA resolves the vulnerable crate…
&["appB", "time", "0.1.45"], // …so does appB.
],
);
// appA actually CALLS time → reachable; appB only uses another crate
let r = run(&s, &uses(&[("appA", "time", "0.1.45", 1), ("appB", "serde", "1.0.0", 9)]), "");
assert_eq!(r.findings_seen, 1);
assert_eq!(r.reachable_refuse, 1, "appA calls time → refuse");
assert_eq!(r.present_hold, 1, "appB has it on disk but never calls it → hold");
assert_eq!(r.worst[0].repo, "appA", "the reachable refuse ranks above the hold");
assert_eq!(r.worst[0].verdict, Verdict::Refuse);
assert!(r.worst[0].reachable && r.worst[0].call_sites == 1);
assert_eq!(r.worst[1].repo, "appB");
assert_eq!(r.worst[1].verdict, Verdict::Hold);
assert!(!r.worst[1].reachable);
}
/// Hyphen↔underscore: an sbom crate `aho-corasick` is the room's `aho_corasick`
/// — the names must fold or every hyphenated crate is a false HOLD.
#[test]
fn hyphenated_crate_name_matches_underscored_crate() {
let s = sources(&[&["aho-corasick", "0.7.0", "RUSTSEC-X", ""]], &[&["appA", "aho-corasick", "0.7.0"]]);
let r = run(&s, &uses(&[("appA", "aho_corasick", "0.7.0", 2)]), "");
assert_eq!(r.reachable_refuse, 1, "aho-corasick ≡ aho_corasick → reachable");
assert_eq!(r.worst[0].verdict, Verdict::Refuse);
}
/// A version that does NOT match the advisory's exact resolved version is not a
/// finding (modgunn resolved the range→version at scan time; the lens joins exact).
#[test]
fn a_non_matching_version_is_not_a_finding() {
let s = sources(&[&["time", "0.1.45", "RUSTSEC-2020-0071", ""]], &[&["appA", "time", "0.3.0"]]); // patched version
let r = run(&s, &uses(&[("appA", "time", "0.3.0", 4)]), "");
assert_eq!(r.reachable_refuse, 0);
assert_eq!(r.present_hold, 0);
assert!(r.worst.is_empty(), "a patched version is not vulnerable");
}
/// The use is of ONE version: calling the patched 0.3 does not make the
/// vulnerable 0.1.45 that is also in the lock file reachable.
#[test]
fn using_another_version_of_the_crate_does_not_reach_the_vulnerable_one() {
let s = sources(&[&["time", "0.1.45", "RUSTSEC-2020-0071", ""]], &[&["appA", "time", "0.1.45"], &["appA", "time", "0.3.0"]]);
let r = run(&s, &uses(&[("appA", "time", "0.3.0", 4)]), "");
assert_eq!((r.reachable_refuse, r.present_hold), (0, 1));
}
/// Anti-silent-empty: an unfed `vuln_findings` reads as a MISSING FEED, not
/// "no vulnerabilities" — the difference between "scanned clean" and "never scanned".
#[test]
fn an_unfed_vuln_findings_reads_as_missing_not_clean() {
let s = sources(&[], &[&["a", "time", "0.1.45"]]);
let r = run(&s, &DepUse::new(), "");
assert_eq!(r.reachable_refuse, 0);
assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
assert!(r.evidence_note.contains("vuln_findings"), "names the producer: {}", r.evidence_note);
}
}