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

nordisk/nornir

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);
    }
}