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