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

nordisk/nornir

crates/nornir-introspect/src/gate.rs

//! `gate` — the **release binary-GATE** (task #65 slice 3): a queryable pass/fail
//! verdict over an [`ArtifactDiff`](crate::diff::ArtifactDiff) (slice 2). Pure,
//! agent-callable: it turns the diff (plus any CVE findings from the binary-derived
//! SBOM path) into a structured [`GateVerdict`] that FAILS on
//!
//!   (a) a **total-size regression** beyond a configurable threshold,
//!   (b) an **ABI-surface removal** — an exported symbol dropped (the diff's
//!       `abi.removed`, the binary counterpart of a semver-major break),
//!   (c) a **known CVE** in the shipped bytes (from the advisory/security path,
//!       when available), and
//!   (d) a **hardening regression** — a checksec protection present in the prior
//!       release lost in the candidate (PIE→no-PIE, RELRO full→partial→none,
//!       NX / stack-canary / FORTIFY dropped), design §7.1 rule 2.
//!
//! The verdict is anchored to `release_lineage` on the nornir side (the base is the
//! prior *released* artifact); this leaf only owns the pure decision so it stays
//! dep-light and unit-testable. Wiring the verdict into the dwarves release
//! engine's Gate is a deliberate follow-up (design §7.1) — NOT built here.
//!
//! ROOT-LAW #0: the inputs are tiny (one headline delta, a handful of dropped
//! exports, a few advisories), so evaluation is serial — no fan-out to justify.

use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::binscan::{analyze, BinaryReport};
use crate::diff::{diff_reports, ArtifactDiff};
use crate::hardening::Hardening;

/// One CVE match against the shipped bytes, supplied by the caller (the nornir
/// side derives these from the binary SBOM via `security::query_vulns`). Kept a
/// plain leaf type so the gate never links the CVE engine.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct CveFinding {
    /// Advisory id, e.g. `RUSTSEC-2024-0001` / `CVE-2024-12345`.
    pub id: String,
    /// The affected package / crate name.
    pub package: String,
    /// The affected version present in the artifact.
    pub version: String,
    /// One-line advisory summary.
    pub summary: String,
}

/// The configurable gate policy. A `None` threshold disables that size check; the
/// ABI + CVE rules default ON (a dropped export or a shipped CVE is a hard fail).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GateThresholds {
    /// Fail when the total-size growth (bytes) exceeds this absolute budget.
    pub max_total_growth_bytes: Option<i64>,
    /// Fail when the total-size growth exceeds this fraction of the base size
    /// (e.g. `0.05` = 5%). Applied against `base_total_bytes`.
    pub max_total_growth_frac: Option<f64>,
    /// Fail when the diff dropped an exported symbol (`abi.removed`).
    pub fail_on_abi_removed: bool,
    /// Fail when a CVE is present in the shipped bytes.
    pub fail_on_cve: bool,
    /// Fail when a checksec protection present in the base release is lost in the
    /// candidate (PIE / RELRO level / NX / stack-canary / FORTIFY). Design §7.1
    /// rule 2. No-op when no base/new hardening pair is supplied.
    pub fail_on_hardening_regression: bool,
}

impl Default for GateThresholds {
    /// House default: a 5% size-growth budget; ABI-removal, CVE, and a hardening
    /// downgrade all fatal.
    fn default() -> Self {
        GateThresholds {
            max_total_growth_bytes: None,
            max_total_growth_frac: Some(0.05),
            fail_on_abi_removed: true,
            fail_on_cve: true,
            fail_on_hardening_regression: true,
        }
    }
}

/// Pass / fail — the headline verdict. Serializes as `"pass"` / `"fail"`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GateStatus {
    Pass,
    Fail,
}

/// One reason the gate fired.
/// `rule ∈ size_regression / abi_removed / cve / hardening_regression`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GateFinding {
    /// The rule that produced this finding.
    pub rule: String,
    /// Severity label (all current rules are `high`).
    pub severity: String,
    /// The subject: `total_bytes`, a demangled symbol, or `crate@version`.
    pub subject: String,
    /// Human-readable explanation.
    pub message: String,
    /// The before value (base size, 0 for a CVE).
    pub before: i64,
    /// The after value (new size, 0 for a removed export / CVE).
    pub after: i64,
}

/// The structured gate verdict — a pass/fail plus every triggering finding and the
/// headline size numbers. Agent-callable: serialize to JSON and read `status`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GateVerdict {
    /// The headline pass/fail.
    pub status: GateStatus,
    /// Every reason the gate fired (empty on pass).
    pub findings: Vec<GateFinding>,
    /// Base artifact total size, bytes.
    pub base_total_bytes: u64,
    /// New artifact total size, bytes.
    pub new_total_bytes: u64,
    /// `new - base` — the headline size delta.
    pub total_delta_bytes: i64,
    /// The policy the verdict was rendered under (for reproducibility).
    pub thresholds: GateThresholds,
}

impl GateVerdict {
    /// True when the gate passed (no findings).
    pub fn passed(&self) -> bool {
        matches!(self.status, GateStatus::Pass)
    }

    /// A process exit code: 0 on pass, else the (capped) finding count — nonzero so
    /// a CI / agent caller sees the failure without parsing JSON.
    pub fn exit_code(&self) -> i32 {
        if self.passed() {
            0
        } else {
            self.findings.len().min(120).max(1) as i32
        }
    }
}

/// Evaluate the gate over a diff + CVE findings (+ an optional base/new hardening
/// pair) under `thresholds`. Pure: no I/O, deterministic order (size → ABI → CVE
/// → hardening). A FAIL carries one finding per broken rule; a clean bump returns
/// `Pass` with no findings. `hardening` is `Some((base, new))` when the checksec
/// posture of both artifacts is known; `None` skips the hardening rule.
pub fn evaluate_gate(
    diff: &ArtifactDiff,
    cves: &[CveFinding],
    hardening: Option<(&Hardening, &Hardening)>,
    thresholds: &GateThresholds,
) -> GateVerdict {
    let mut findings = Vec::new();

    // (a) Total-size regression beyond the configured budget.
    let delta = diff.total_delta_bytes;
    if delta > 0 {
        let over_abs = thresholds
            .max_total_growth_bytes
            .is_some_and(|budget| delta > budget);
        let over_frac = thresholds.max_total_growth_frac.is_some_and(|frac| {
            let allowed = (diff.base_total_bytes as f64 * frac).round() as i64;
            delta > allowed
        });
        if over_abs || over_frac {
            let mut limits = Vec::new();
            if let Some(b) = thresholds.max_total_growth_bytes {
                limits.push(format!("{b} bytes"));
            }
            if let Some(f) = thresholds.max_total_growth_frac {
                limits.push(format!("{:.1}%", f * 100.0));
            }
            findings.push(GateFinding {
                rule: "size_regression".to_string(),
                severity: "high".to_string(),
                subject: "total_bytes".to_string(),
                message: format!(
                    "total size grew {delta} bytes ({} → {}), over budget ({})",
                    diff.base_total_bytes,
                    diff.new_total_bytes,
                    limits.join(" / "),
                ),
                before: diff.base_total_bytes as i64,
                after: diff.new_total_bytes as i64,
            });
        }
    }

    // (b) ABI-surface removal — a dropped export is a binary ABI break.
    if thresholds.fail_on_abi_removed {
        for d in &diff.abi.removed {
            findings.push(GateFinding {
                rule: "abi_removed".to_string(),
                severity: "high".to_string(),
                subject: if d.name_demangled.is_empty() {
                    d.name_mangled.clone()
                } else {
                    d.name_demangled.clone()
                },
                message: format!(
                    "exported symbol removed from ABI surface (was {} bytes in {})",
                    d.base_size,
                    if d.crate_name.is_empty() {
                        "?"
                    } else {
                        &d.crate_name
                    },
                ),
                before: d.base_size as i64,
                after: 0,
            });
        }
    }

    // (c) Known CVE in the shipped bytes (binary-derived SBOM, when available).
    if thresholds.fail_on_cve {
        for c in cves {
            findings.push(GateFinding {
                rule: "cve".to_string(),
                severity: "high".to_string(),
                subject: format!("{}@{}", c.package, c.version),
                message: format!("{}: {}", c.id, c.summary),
                before: 0,
                after: 0,
            });
        }
    }

    // (d) Hardening regression — a checksec protection present in the base release
    //     lost in the candidate (design §7.1 rule 2). Serial: a handful of flags.
    if thresholds.fail_on_hardening_regression {
        if let Some((base, new)) = hardening {
            findings.extend(hardening_findings(base, new));
        }
    }

    let status = if findings.is_empty() {
        GateStatus::Pass
    } else {
        GateStatus::Fail
    };
    GateVerdict {
        status,
        findings,
        base_total_bytes: diff.base_total_bytes,
        new_total_bytes: diff.new_total_bytes,
        total_delta_bytes: delta,
        thresholds: thresholds.clone(),
    }
}

/// RELRO strength ordering: `full` (2) > `partial` (1) > `none`/other (0). A
/// numeric rank so a downgrade is a simple `<` comparison.
fn relro_rank(level: &str) -> u8 {
    match level {
        "full" => 2,
        "partial" => 1,
        _ => 0,
    }
}

/// Every hardening protection the candidate (`new`) *lost* relative to the prior
/// release (`base`). A protection newly *gained* is never a finding — the gate
/// only fails on regressions. One `hardening_regression` finding per lost
/// protection, deterministic order (PIE → RELRO → NX → canary → FORTIFY). The
/// `before`/`after` fields carry `1`/`0` (present/absent), except RELRO which
/// carries its numeric rank so the downgrade magnitude survives.
fn hardening_findings(base: &Hardening, new: &Hardening) -> Vec<GateFinding> {
    let mut out = Vec::new();
    let mk = |subject: &str, message: String, before: i64, after: i64| GateFinding {
        rule: "hardening_regression".to_string(),
        severity: "high".to_string(),
        subject: subject.to_string(),
        message,
        before,
        after,
    };

    // Hardening flags only make sense within the same format; a format change
    // (e.g. ELF → wasm) is out of scope for the checksec downgrade check.
    if base.format != new.format {
        return out;
    }

    if base.pie && !new.pie {
        out.push(mk(
            "pie",
            "position-independent-executable lost (PIE → no-PIE) vs the last release".to_string(),
            1,
            0,
        ));
    }
    if relro_rank(&new.relro) < relro_rank(&base.relro) {
        out.push(mk(
            "relro",
            format!(
                "RELRO downgraded {} → {} vs the last release",
                base.relro, new.relro
            ),
            relro_rank(&base.relro) as i64,
            relro_rank(&new.relro) as i64,
        ));
    }
    if base.nx && !new.nx {
        out.push(mk(
            "nx",
            "non-executable-stack (NX) lost vs the last release".to_string(),
            1,
            0,
        ));
    }
    if base.stack_canary && !new.stack_canary {
        out.push(mk(
            "stack_canary",
            "stack-smashing protector (canary) lost vs the last release".to_string(),
            1,
            0,
        ));
    }
    if base.fortify && !new.fortify {
        out.push(mk(
            "fortify",
            "_FORTIFY_SOURCE lost vs the last release".to_string(),
            1,
            0,
        ));
    }
    out
}

/// **The release binary gate over two artifacts on disk** — analyse `base` (the
/// prior released artifact) and `new` (the candidate), diff them, hand the gate the
/// checksec posture of both, and evaluate under `thresholds`. This is the ONE
/// pairing of [`analyze`] + [`diff_reports`] + [`evaluate_gate`], so nornir's CLI
/// (`nornir bin gate`), its warehouse-backed `binscan::gate_artifacts` and dwarves'
/// release doctor all ask the same question the same way — a consumer links this
/// leaf instead of shelling the `nornir` binary and parsing its JSON.
///
/// `cves` is the CVE lookup the caller CAN do over the NEW artifact's report (nornir:
/// the warehouse vuln match over the binary-derived SBOM). It is only consulted when
/// `thresholds.fail_on_cve` is set; a caller with no CVE source passes
/// `|_| Vec::new()` and the verdict honestly carries no CVE rule — never a
/// fabricated clean bill. Any other rule (size / ABI / hardening) is decided here.
pub fn gate_artifacts(
    base: &Path,
    new: &Path,
    source_root: Option<&Path>,
    thresholds: &GateThresholds,
    cves: impl FnOnce(&BinaryReport) -> Vec<CveFinding>,
) -> Result<GateVerdict> {
    let base_report =
        analyze(base, source_root).with_context(|| format!("analyze base {}", base.display()))?;
    let new_report =
        analyze(new, source_root).with_context(|| format!("analyze new {}", new.display()))?;
    let diff = diff_reports(&base_report, &new_report);
    let cves = if thresholds.fail_on_cve { cves(&new_report) } else { Vec::new() };
    // Hand the gate the checksec posture of both artifacts so it can fail a
    // hardening downgrade (PIE/RELRO/NX/canary/FORTIFY lost) vs the prior release.
    let hardening = Some((&base_report.header, &new_report.header));
    Ok(evaluate_gate(&diff, &cves, hardening, thresholds))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diff::{AbiDelta, ArtifactDiff, SymbolDelta};

    /// The on-disk pairing, end to end on a REAL binary (the test executable itself,
    /// the self-exe pattern): the same artifact on both sides is a clean PASS with
    /// zero findings and a zero delta; the caller's CVE lookup is honoured (one
    /// injected CVE ⇒ a `cve` finding and FAIL), and is NOT consulted when the
    /// threshold turns the rule off (the closure panics if asked); a base that is not
    /// on disk is an `Err` naming it — never a verdict.
    #[test]
    fn gate_artifacts_pairs_analyze_diff_and_evaluate_over_real_files() {
        let me = std::env::current_exe().expect("test exe");
        let plain = GateThresholds { fail_on_cve: false, ..GateThresholds::default() };
        let v = gate_artifacts(&me, &me, None, &plain, |_| panic!("cve lookup off ⇒ never asked"))
            .expect("self vs self");
        assert_eq!(v.status, GateStatus::Pass, "{:?}", v.findings);
        assert!(v.findings.is_empty());
        assert_eq!(v.total_delta_bytes, 0);
        assert!(v.base_total_bytes > 0, "a real artifact was analysed");

        let cve_on = GateThresholds { fail_on_cve: true, ..GateThresholds::default() };
        let v = gate_artifacts(&me, &me, None, &cve_on, |report| {
            assert!(!report.sections.is_empty(), "the lookup sees the NEW report");
            vec![CveFinding {
                id: "RUSTSEC-0000-0000".into(),
                package: "x".into(),
                version: "1.0.0".into(),
                summary: "injected".into(),
            }]
        })
        .expect("self vs self with a cve");
        assert_eq!(v.status, GateStatus::Fail);
        assert!(v.findings.iter().any(|f| f.rule == "cve" && f.subject.contains("x@1.0.0")), "{:?}", v.findings);

        let err = gate_artifacts(Path::new("/no/such/prior"), &me, None, &plain, |_| Vec::new())
            .expect_err("a missing base is an error, not a verdict");
        assert!(format!("{err:#}").contains("/no/such/prior"), "{err:#}");
    }
    use crate::hardening::Hardening;

    fn exported(name: &str, size: u64) -> SymbolDelta {
        SymbolDelta {
            name_mangled: format!("_ZN{name}"),
            name_demangled: name.to_string(),
            crate_name: "libcore".to_string(),
            base_size: size,
            new_size: 0,
            delta_bytes: -(size as i64),
            binding: "global".to_string(),
            exported: true,
        }
    }

    /// A fully-hardened ELF header fixture of a given size: PIE, full RELRO, NX,
    /// stack-canary, FORTIFY all ON — the "good" release baseline.
    fn hardened(total: u64) -> Hardening {
        Hardening {
            format: "elf".into(),
            arch: "X86_64".into(),
            class: "exe".into(),
            total_bytes: total,
            stripped: true,
            has_debug_info: false,
            pie: true,
            relro: "full".into(),
            nx: true,
            stack_canary: true,
            fortify: true,
            rpath: false,
            runpath: false,
            build_id: None,
            n_sections: 1,
            n_sym_defined: 0,
            n_sym_undefined: 0,
            n_dyn_deps: 0,
        }
    }

    /// An empty diff shell of a given (base,new) total, no churn.
    fn diff_shell(base: u64, new: u64) -> ArtifactDiff {
        ArtifactDiff {
            base_total_bytes: base,
            new_total_bytes: new,
            total_delta_bytes: new as i64 - base as i64,
            sections: vec![],
            crates: vec![],
            symbols_added: vec![],
            symbols_removed: vec![],
            symbols_grown: vec![],
            symbols_shrunk: vec![],
            abi: AbiDelta::default(),
        }
    }

    /// RED-WHEN-BROKEN: a candidate that BOTH grows past the size budget AND drops
    /// an exported symbol (plus a shipped CVE) must FAIL, with one finding per rule
    /// and a nonzero exit code. Any regression that lets a bloat blow-up, an ABI
    /// break, or a CVE slip through turns this red.
    #[test]
    fn gate_fails_on_regression_abi_drop_and_cve() {
        // Base 1000 bytes, new 2000 bytes → +100% growth, way over the 5% budget.
        let mut diff = diff_shell(1000, 2000);
        diff.abi = AbiDelta {
            added: vec![],
            removed: vec![exported("8core3api", 300)],
        };
        let cves = vec![CveFinding {
            id: "RUSTSEC-2024-0001".into(),
            package: "vulnerable".into(),
            version: "1.2.3".into(),
            summary: "use-after-free".into(),
        }];

        let v = evaluate_gate(&diff, &cves, None, &GateThresholds::default());
        assert!(!v.passed(), "gate must FAIL: {v:?}");
        assert_eq!(v.status, GateStatus::Fail);
        assert!(v.exit_code() > 0, "nonzero exit on fail");

        // Exactly the three rules fired.
        assert!(
            v.findings.iter().any(|f| f.rule == "size_regression"),
            "size rule"
        );
        assert!(
            v.findings
                .iter()
                .any(|f| f.rule == "abi_removed" && f.subject == "8core3api"),
            "abi rule names the dropped export"
        );
        assert!(
            v.findings
                .iter()
                .any(|f| f.rule == "cve" && f.subject == "vulnerable@1.2.3"),
            "cve rule names the package@version"
        );
    }

    /// RED-WHEN-BROKEN: a clean bump — a small size growth within budget, no export
    /// dropped, no CVE — must PASS with no findings and a zero exit code.
    #[test]
    fn gate_passes_on_clean_bump() {
        // +2% growth, under the 5% budget; no ABI removal; no CVE.
        let diff = diff_shell(100_000, 102_000);
        let v = evaluate_gate(&diff, &[], None, &GateThresholds::default());
        assert!(v.passed(), "clean bump must pass: {v:?}");
        assert_eq!(v.status, GateStatus::Pass);
        assert!(v.findings.is_empty(), "no findings on a clean bump");
        assert_eq!(v.exit_code(), 0, "zero exit on pass");
    }

    /// A dropped export alone fails even when the size shrinks — the ABI rule is
    /// independent of the size budget.
    #[test]
    fn gate_fails_on_abi_drop_even_when_smaller() {
        let mut diff = diff_shell(2000, 1500); // shrank
        diff.abi = AbiDelta {
            added: vec![],
            removed: vec![exported("3lib3fooE", 40)],
        };
        let v = evaluate_gate(&diff, &[], None, &GateThresholds::default());
        assert!(!v.passed(), "ABI drop fails regardless of size: {v:?}");
        assert!(
            v.findings.iter().all(|f| f.rule == "abi_removed"),
            "only the ABI rule fired"
        );
    }

    /// The size budget is configurable: the same growth passes a loose byte budget
    /// and fails a tight one.
    #[test]
    fn gate_size_budget_is_configurable() {
        let diff = diff_shell(1000, 1100); // +100 bytes
        let loose = GateThresholds {
            max_total_growth_bytes: Some(200),
            max_total_growth_frac: None,
            fail_on_abi_removed: true,
            fail_on_cve: true,
            fail_on_hardening_regression: true,
        };
        let tight = GateThresholds {
            max_total_growth_bytes: Some(50),
            max_total_growth_frac: None,
            fail_on_abi_removed: true,
            fail_on_cve: true,
            fail_on_hardening_regression: true,
        };
        assert!(
            evaluate_gate(&diff, &[], None, &loose).passed(),
            "+100 under a 200-byte budget"
        );
        assert!(
            !evaluate_gate(&diff, &[], None, &tight).passed(),
            "+100 over a 50-byte budget"
        );
    }

    /// RED-WHEN-BROKEN: a candidate that drops PIE and downgrades RELRO
    /// full → partial vs the prior release must FAIL with `hardening_regression`
    /// findings naming each lost protection — even when size and ABI are clean.
    #[test]
    fn gate_fails_on_hardening_regression() {
        let diff = diff_shell(1000, 1000); // no size change, no churn
        let base = hardened(1000);
        let mut new = hardened(1000);
        new.pie = false; // PIE lost
        new.relro = "partial".into(); // RELRO downgraded full → partial

        let v = evaluate_gate(&diff, &[], Some((&base, &new)), &GateThresholds::default());
        assert!(!v.passed(), "hardening downgrade must FAIL: {v:?}");
        assert!(v.exit_code() > 0, "nonzero exit on fail");
        assert!(
            v.findings
                .iter()
                .any(|f| f.rule == "hardening_regression" && f.subject == "pie"),
            "PIE-loss finding present"
        );
        assert!(
            v.findings
                .iter()
                .any(|f| f.rule == "hardening_regression" && f.subject == "relro"),
            "RELRO-downgrade finding present"
        );
        // Every finding here is the hardening rule (size + ABI are clean).
        assert!(
            v.findings.iter().all(|f| f.rule == "hardening_regression"),
            "only the hardening rule fired"
        );
    }

    /// RED-WHEN-BROKEN: two equally-hardened releases (identical checksec posture)
    /// pass the hardening rule; a protection *gained* is never a failure.
    #[test]
    fn gate_passes_when_hardening_held_or_improved() {
        let diff = diff_shell(1000, 1000);
        let base = hardened(1000);
        // Same posture → pass.
        assert!(
            evaluate_gate(&diff, &[], Some((&base, &base)), &GateThresholds::default()).passed(),
            "identical hardening passes"
        );
        // Candidate GAINS FORTIFY the base lacked → still a pass (not a regression).
        let mut weaker_base = hardened(1000);
        weaker_base.fortify = false;
        let stronger_new = hardened(1000); // fortify = true
        let v = evaluate_gate(
            &diff,
            &[],
            Some((&weaker_base, &stronger_new)),
            &GateThresholds::default(),
        );
        assert!(
            v.passed(),
            "a hardening improvement never fails the gate: {v:?}"
        );
    }

    /// The hardening rule is opt-out: `fail_on_hardening_regression = false` lets a
    /// downgrade through (parity with the ABI / CVE toggles).
    #[test]
    fn gate_hardening_rule_is_configurable() {
        let diff = diff_shell(1000, 1000);
        let base = hardened(1000);
        let mut new = hardened(1000);
        new.nx = false; // NX lost
        let th = GateThresholds {
            fail_on_hardening_regression: false,
            ..Default::default()
        };
        assert!(
            evaluate_gate(&diff, &[], Some((&base, &new)), &th).passed(),
            "hardening rule disabled → downgrade tolerated"
        );
    }
}