server/lib/src/promote/gate.rs
//! # The promotion gate seam
//!
//! ## What was here before, measured at `542e296`
//!
//! Policy was a concrete struct, `PolicyGate { scanner: OsvScanner, enforcement }`
//! (`promote/mod.rs:66`), with four properties that each cost something:
//!
//! 1. **Configured from environment variables** (`PolicyGate::from_env`,
//! `promote/mod.rs:88`): `HOLGER_PROMOTE_ENFORCE`,
//! `HOLGER_PROMOTE_ADVISORY_DB`, `HOLGER_PROMOTE_DENY_LICENSES`. holger ships
//! as an appliance with no OS and no shell, so an environment variable is a
//! configuration surface the operator cannot reach.
//! 2. **Unset means pass-through.** `from_env` returned `Ok(None)` and
//! `gate_check` then returned `Ok(())` (`:522`). A promotion with no gate and
//! a promotion that passed every gate were indistinguishable in the log.
//! 3. **No bytes.** `PolicyGate::evaluate` (`:122`) called
//! `security::artifact_from_coords(ecosystem, name, version, false)`, the
//! coordinate-only seam, which builds an `ArtifactRef` with
//! `blob_sha256: String::new()`. modgunn's `Verdict::cache_key()` is
//! `(blob_sha256, policy_version, advisory_db_rev)` and is content-addressed
//! BY DESIGN, so every artifact in every promotion collapsed onto
//! `("", policy, db)`. The digest-carrying seam
//! `security::artifact_from_coords_with_digest` was added by `fa9f5e6`
//! (`security.rs:207`) and had ZERO callers.
//! 4. **One artifact at a time.** There was no call that saw the promoted set,
//! although `.nornir/SUITES.md` requires analysis "per package AND over the
//! promoted set as a whole".
//!
//! There was also no way to say "this rung must be gated" and have the absence
//! of a working gate refuse the hop.
//!
//! ## What is here now
//!
//! A [`Gate`] trait, a [`PromotionContext`] that carries the principal, the hop
//! and the whole promoted set, and a [`PromotedArtifact`] whose `digest` and
//! `bytes` are REQUIRED fields rather than optional ones. The shape is gunnar's
//! (`gunnar/crates/gunnar-policy/src/lib.rs:173`), and the verdict vocabulary is
//! literally gunnar's, through `nornir_gate` in edda — so gunnar and holger
//! share the types and neither depends on the other.
//!
//! ### HOL-GUN-007
//!
//! gunnar's GUN-007 says an unreadable policy configuration refuses everything.
//! It was written because, until 2026-09-11, an unreadable policy fell back to
//! `PolicyConfig::default()` and silently turned branch protection off
//! (`gunnar/crates/gunnar-transport/src/transport.rs:2570`).
//!
//! holger's form: **a rung that names a gate this build does not carry refuses
//! the hop.** [`GateCatalog::resolve`] returns an error naming the gate and
//! listing what the build does carry, and [`Holger::promotion_engine`] turns
//! that error into a boot failure — the same rule `ron_handler` already applies
//! to repository handlers. If a gate is resolved but returns `Err` at run time,
//! `nornir_gate::evaluate` records it as `undecided: true` and the hop is
//! refused.
//!
//! The refusal costs promotions and nothing else. Reads, fetches and serving are
//! untouched, which is what makes failing closed affordable.
//!
//! ### The plugin seam
//!
//! [`Gate`] is object-safe and takes `&self`, so a gate can hold any handle:
//! `struct WasmGate(Arc<dyn MeterRuntime>)` with `impl Gate for WasmGate` is the
//! whole join needed to enforce a quota cap from a loaded plugin. Nothing here
//! forecloses it and nothing here requires it.
//!
//! ### The two questions this module used to leave open — ANSWERED 2026-09-21
//!
//! Both are closed by W17 (plan §4 "W17 — a signature that means something").
//! The original text is kept because it records what was true and for how long.
//!
//! * ~~*"modgunn's `provenance-verify` is on by default while holger pins
//! `features = ["scan"]`, and holger's own `attest.rs` is behind a non-default
//! `sign` feature — so in a shipped holger neither provenance gate runs. This
//! module adds a NAMED gate (`provenance`) whose absence from the catalog is
//! reported by name instead of being silent, which is the smallest honest
//! thing to do without ruling the question."*~~ — Two corrections. `sign` is
//! pinned on all five server rows and `attest.rs` was always compiled (?10).
//! And this module did NOT add a named `provenance` gate: it added a named
//! REFUSAL for one, and no build carried the gate itself — the unit test below
//! USED `"provenance"` as its example of a name nothing carries, which is how
//! you can tell. [`ProvenanceGate`] is now registered on every row by
//! [`catalog_from_config`], steered by [`ProvenancePolicy`]; that test now
//! probes a name that will never be a gate, and a sibling asserts the real
//! catalog carries `provenance`.
//! * ~~*"`nornir --seal` signs with `ZnippySealer::dev()`, which mints an
//! in-memory CA and discards the root. Nothing here signs anything, so nothing
//! here depends on that being fixed."*~~ — ?9 fixed the producer. The
//! CONSUMER's half was [`crate::attest::AttestTrust::from_der_root_files`],
//! which pushed each declared root's bytes without checking they were a
//! certificate: an estate could verify against fewer roots than it declared
//! and nothing said so. `holger-attest-10` now refuses the whole declaration.
//! This gate depends on that being fixed, because "filed in the evidence
//! plane" is only worth anything if the roots it was verified against are real.
use std::collections::BTreeMap;
use std::sync::Arc;
use holger_errcode as ec;
use nornir_gate::{Decision, Verdict};
use traits::ArtifactId;
use crate::attest::{ArtifactDigest, Statement};
#[cfg(feature = "scanners")]
use crate::scanner::license::{LicenseDecision, LicensePolicy, LicenseScanner};
#[cfg(feature = "scanners")]
use crate::security::{self, OsvScanner, ScanEngine};
use super::stages::Hop;
use super::Enforcement;
/// One member of a promotion, with the evidence a gate is entitled to see.
///
/// `digest` and `bytes` are required. That is the whole difference from the
/// struct this replaces: a gate that is handed a name and a version cannot tell
/// two uploads of the same coordinate apart, and modgunn's verdict cache is
/// keyed on the digest, so a coordinate-only call made the cache a single
/// bucket.
pub struct PromotedArtifact<'a> {
/// The coordinate.
pub id: &'a ArtifactId,
/// The ecosystem string the scanner scores against
/// (`security::ecosystem_for_format`).
pub ecosystem: &'a str,
/// The content digest of `bytes`. Required.
pub digest: ArtifactDigest,
/// The bytes being promoted. Required — a gate CAN look at them.
pub bytes: &'a [u8],
/// What the evidence plane already holds for this digest. Empty when the
/// attestation store is not configured; a gate that requires evidence must
/// say so in its refusal rather than assume.
pub attestations: &'a [Statement],
}
impl PromotedArtifact<'_> {
/// `namespace/name@version`, the spelling a refusal names.
pub fn label(&self) -> String {
super::coord_key(self.id)
}
}
/// Everything a gate is allowed to know about one promotion.
pub struct PromotionContext<'a> {
/// The authenticated principal performing the promotion, or `"anonymous"`.
pub principal: &'a str,
/// Source repository.
pub from_repo: &'a str,
/// Destination repository.
pub to_repo: &'a str,
/// The resolved rung-to-rung hop, when a ladder is configured.
pub hop: Option<&'a Hop>,
/// **Every** member of this promotion. A per-artifact gate iterates inside;
/// a set-scope gate looks at the whole slice.
pub set: &'a [PromotedArtifact<'a>],
}
impl<'a> PromotionContext<'a> {
/// The context for an **INGEST** — a PUT, judged before the bytes are
/// handed to the backend.
///
/// Promotion is not the first boundary an artifact crosses; upload is. A
/// gate that only runs on promotion means a vulnerable artifact is already
/// in the DEV repository and already being served from it, and the gate's
/// job becomes stopping it from moving rather than stopping it from
/// arriving.
///
/// `from_repo` is empty, because there is no source repository: the bytes
/// came off the wire. `hop` is `None`: there is no rung. Everything a gate
/// actually reads — the principal, the destination, the digest, the bytes —
/// is present and identical to the promotion case, which is why this is a
/// constructor and not a second context type.
pub fn ingest(
principal: &'a str,
to_repo: &'a str,
set: &'a [PromotedArtifact<'a>],
) -> PromotionContext<'a> {
PromotionContext {
principal,
from_repo: "",
to_repo,
hop: None,
set,
}
}
/// Whether this context is an ingest rather than a promotion. A gate that
/// must behave differently at the two boundaries can ask; most do not care.
pub fn is_ingest(&self) -> bool {
self.from_repo.is_empty() && self.hop.is_none()
}
}
/// One check that runs at the promotion boundary, before anything is sealed.
///
/// A gate cannot write, cannot seal, and cannot decide half a promotion.
/// Returning `Err` means *the gate could not decide*, which
/// [`GateSet::evaluate`] treats as a refusal and never as an allow.
pub trait Gate: Send + Sync {
/// A stable identifier. It appears in refusals, in the audit detail, and in
/// [`GateSet::names`], which exists so that a server whose gate
/// configuration failed to load does not look like one that has no gates.
fn name(&self) -> &str;
/// Decide.
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict>;
}
impl<T: Gate + ?Sized> Gate for Arc<T> {
fn name(&self) -> &str {
(**self).name()
}
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
(**self).check(ctx)
}
}
/// The CVE / licence / provenance scan, as a gate.
///
/// This is the old `PolicyGate`'s scanner with two corrections: it is reached
/// through the trait, and it calls the DIGEST-carrying seam, so
/// `modgunn::Verdict::cache_key()` distinguishes two artifacts and can be joined
/// with the evidence in [`crate::attest::AttestStore`], which is keyed by the
/// same digest.
///
/// The licence half is the licence SCANNER's ([`LicenseScanner::judge`]), the
/// same code the intake's licence slot runs, so the gate and the evidence can
/// never disagree about a package. modgunn judges the advisories only.
#[cfg(feature = "scanners")]
pub struct OsvGate {
/// The modgunn scanner with its advisories indexed once (T10): the same
/// verdicts as `OsvScanner::scan`, without a linear scan per lookup.
scanner: crate::advisory_index::IndexedOsvScanner,
license: LicenseScanner,
/// S7: when set, the advisories come from the local intel database at check
/// time (the scanner's own DB is unused), with its staleness folded in.
intel: Option<Arc<crate::intel::scan::IntelSource>>,
/// W5: the intake chain's scan pipeline, once it is built. With it, a FRESH
/// stored vulnerability record about the artifact's bytes decides the
/// advisory verdict and the advisories are not scanned again.
evidence: Option<EvidenceCell>,
/// SANDBOX-SCAN: the scan worker the licence is read in, once the chain is
/// built. Without one, a release build refuses to read the licence here.
worker: Option<WorkerCell>,
}
/// The intake scan pipeline a gate reads stored evidence through, filled once
/// the chain is built (the gate catalog is built first).
pub type EvidenceCell = Arc<std::sync::OnceLock<Arc<crate::scanner::ScanPipeline>>>;
/// The scan worker a gate reads a package's licence through (SANDBOX-SCAN),
/// filled once the chain is built.
#[cfg(feature = "scanners")]
pub type WorkerCell = Arc<std::sync::OnceLock<Arc<crate::sandbox::ScanWorker>>>;
#[cfg(feature = "scanners")]
impl OsvGate {
/// The name a rung writes in its `gates` list.
pub const NAME: &'static str = "osv";
/// The gate over a modgunn scanner. The licence policy the scanner states
/// (allow, deny, `on_unknown`) moves to the licence scanner, and modgunn's
/// own licence check is left neutral, so there is one licence code path.
pub fn new(mut scanner: OsvScanner) -> Self {
let license = LicenseScanner::new(LicensePolicy::from_modgunn(&scanner.policy.license));
scanner.policy.license = security::LicensePolicy {
allow: Vec::new(),
deny: Vec::new(),
on_unknown: security::Decision::Pass,
};
let scanner = crate::advisory_index::IndexedOsvScanner::new(scanner);
Self {
scanner,
license,
intel: None,
evidence: None,
worker: None,
}
}
/// The licence policy this gate applies.
pub fn license_policy(&self) -> &LicensePolicy {
self.license.policy()
}
/// Read the stored scan evidence first (W5). A fresh vulnerability record
/// about the same bytes is reused, and the reuse is audited; a missing or
/// stale one means the advisories are scanned as before.
pub fn with_evidence(mut self, cell: EvidenceCell) -> Self {
self.evidence = Some(cell);
self
}
/// Read the package's licence in this scan worker (SANDBOX-SCAN).
pub fn with_worker(mut self, cell: WorkerCell) -> Self {
self.worker = Some(cell);
self
}
/// **The package's licence, judged**: read by the scan worker (the
/// package is unpacked there, never here); in-process only in a test
/// build; refused by name otherwise.
fn license_of(
&self,
art: &PromotedArtifact<'_>,
) -> Result<crate::scanner::license::LicenseJudgement, String> {
if let Some(w) = self.worker.as_ref().and_then(|c| c.get()) {
let file = art.id.name.rsplit('/').next().unwrap_or(&art.id.name);
let mut subject = crate::scanner::ScanSubject::new(file, art.ecosystem, art.bytes);
if !art.id.version.is_empty() {
subject = subject.with_coords(&art.id.name, &art.id.version);
}
return w
.report(&subject)
.map(|r| {
crate::scanner::license::judge_declared(
self.license.policy(),
r.license.clone(),
)
})
.map_err(|e| e.to_string());
}
if crate::sandbox::IN_PROCESS_ALLOWED {
return Ok(self.license.judge(art.ecosystem, art.bytes));
}
Err(holger_sandbox::Refusal::NoEngine(
"the osv gate has no scan sandbox to read the licence in".into(),
)
.to_string())
}
/// The fresh stored evidence about `digest`, when the gate reads it and the
/// record holds a real vulnerability scan.
fn fresh_evidence(
&self,
sha256_hex: &str,
) -> Option<(
Arc<crate::scanner::ScanPipeline>,
String,
crate::scanner::Evidence,
)> {
use crate::scanner::{ScannerKind, Stored};
let pipeline = self.evidence.as_ref()?.get()?.clone();
match pipeline.stored(sha256_hex) {
Ok(Stored::Fresh { id, evidence })
if evidence
.records_of(ScannerKind::Vulnerability)
.any(|r| !r.is_not_configured()) =>
{
Some((pipeline, id, evidence))
}
Ok(Stored::Stale { why, .. }) => {
log::info!(
"osv gate: stored evidence for sha256 {sha256_hex} is stale ({why}); scanning"
);
None
}
Ok(_) => None,
Err(e) => {
log::warn!("osv gate: the stored evidence for sha256 {sha256_hex} could not be read ({}); scanning", e.tagged());
None
}
}
}
/// Read the advisories from the local intel database instead of the
/// scanner's fixed set. The scanner's policy still decides.
pub fn with_intel(mut self, source: Arc<crate::intel::scan::IntelSource>) -> Self {
self.intel = Some(source);
self
}
fn verdict(&self, subject: &security::Artifact) -> anyhow::Result<security::Verdict> {
match &self.intel {
None => Ok(self.scanner.scan(subject)),
Some(src) => {
let (db, evidence) = src.current()?;
if evidence.finding().is_some() {
log::warn!("intel: {}", evidence.describe());
}
Ok(holger_intel::scan_against(
&self.scanner.scanner().policy,
&db,
&evidence,
subject,
))
}
}
}
}
#[cfg(feature = "scanners")]
impl Gate for OsvGate {
fn name(&self) -> &str {
Self::NAME
}
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
for art in ctx.set {
// B2: the licence comes from the package's own metadata, so the
// licence policy judges what was uploaded and not `None`. SANDBOX-SCAN:
// the metadata is read in the scan worker.
let license = match self.license_of(art) {
Ok(l) => l,
Err(why) => {
return Ok(Verdict::reject(
art.label(),
format!("the licence could not be read ({}): {why}", art.digest),
))
}
};
let mut subject = security::artifact_from_coords_with_digest(
art.ecosystem,
&art.id.name,
&art.id.version,
art.digest.hex(),
false,
);
subject.license = license.declared.as_ref().and_then(|d| d.expression.clone());
let verdict = match self.fresh_evidence(art.digest.hex()) {
Some((pipeline, id, evidence)) => {
use crate::scanner::{ScanVerdict, ScannerKind};
pipeline
.store()
.record_reuse(ctx.principal, ctx.to_repo, &id, &evidence, Self::NAME)
.map_err(|e| anyhow::anyhow!(e.tagged()))?;
let blocking: Vec<String> = evidence
.records_of(ScannerKind::Vulnerability)
.filter(|r| {
r.verdict == ScanVerdict::Block
|| (r.verdict == ScanVerdict::Error && r.required)
})
.flat_map(|r| {
let f: Vec<String> = r
.findings
.iter()
.map(|f| format!("{} [{:?}] {}", f.id, f.severity, f.summary))
.collect();
if f.is_empty() {
vec![r.detail.clone()]
} else {
f
}
})
.collect();
if !blocking.is_empty() {
return Ok(Verdict::reject(
art.label(),
format!(
"blocked by the stored scan evidence {id} ({}): {}",
art.digest,
blocking.join("; ")
),
));
}
// The advisories are decided; the licence policy still reads
// the package itself, with no advisory scan.
OsvScanner::new(
self.scanner.scanner().policy.clone(),
security::AdvisoryDb::default(),
)
.scan(&subject)
}
None => self.verdict(&subject)?,
};
if license.decision == LicenseDecision::Block {
return Ok(Verdict::reject(
art.label(),
format!(
"blocked by the licence policy ({}): {}",
art.digest, license.detail
),
));
}
if verdict.decision == security::Decision::Block {
let reasons: Vec<String> = verdict
.findings
.iter()
.map(|f| format!("{} [{:?}] {}", f.id, f.severity, f.summary))
.collect();
return Ok(Verdict::reject(
art.label(),
format!(
"blocked by the advisory/licence scan ({}{}): {}",
art.digest,
// S7: the advisories' own revision — for the intel database
// its bundle sequence and date, so a stale DB is visible.
if self.intel.is_some() {
format!("; advisories {}", verdict.advisory_db_rev)
} else {
String::new()
},
if reasons.is_empty() {
"no finding detail".to_string()
} else {
reasons.join("; ")
}
),
));
}
// An unknown licence under `UnknownLicense::Hold` is a hold. A hold
// is not a pass here: the package is held until someone reviews it.
if license.decision == LicenseDecision::Hold {
return Ok(Verdict::reject(
art.label(),
format!(
"held for review ({}): {}. An unknown licence never passes silently. \
Review it, or state `promotion.unknown_license: Pass` to let unknown \
licences through",
art.digest, license.detail
),
));
}
}
Ok(Verdict::Allow)
}
}
/// Require that every member of the promotion already carries at least one
/// accepted attestation of `predicate_type` in the evidence plane.
///
/// This is the gate that makes `attest.rs` load-bearing rather than decorative:
/// with it on a rung, an artifact cannot reach that rung unless a DSSE statement
/// about ITS digest was verified and filed first.
pub struct AttestedGate {
name: String,
predicate_type: String,
}
impl AttestedGate {
/// The name a rung writes for the built-in instance.
pub const NAME: &'static str = "attested";
pub fn new(name: impl Into<String>, predicate_type: impl Into<String>) -> Self {
Self {
name: name.into(),
predicate_type: predicate_type.into(),
}
}
}
impl Gate for AttestedGate {
fn name(&self) -> &str {
&self.name
}
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
for art in ctx.set {
let has = art
.attestations
.iter()
.any(|s| s.predicate_type == self.predicate_type && s.attests(&art.digest));
if !has {
return Ok(Verdict::reject(
art.label(),
format!(
"no accepted '{}' attestation for {} in the evidence plane. The evidence \
plane is keyed by DIGEST, so this says nothing about other builds of the \
same coordinate — it says these BYTES have no such statement filed.",
self.predicate_type, art.digest
),
));
}
}
Ok(Verdict::Allow)
}
}
// ── The provenance gate (plan §3 W17) ────────────────────────────────────────
/// **What this estate demands of provenance at the promotion boundary.**
///
/// Declared once, in the RON `promotion:` block, as `provenance: Require`,
/// `provenance: Warn` or `provenance: Off`.
///
/// # Why the default is `Warn`, and why it is not the other two
///
/// MEASURED 2026-09-21: no shipped holger row carried a gate named `provenance`
/// at all. `catalog_from_config` registered `osv`, and `attested` only when
/// `promotion.require_attestation` named a predicate type — so a config that
/// wrote `gates: ["provenance"]` was a BOOT FAILURE on every one of the five
/// server rows (HOL-GUN-007, and the unit test that proves it literally used
/// `"provenance"` as its example of a name no build carries).
///
/// * **`Require` cannot be the default.** Every existing estate has filed no
/// evidence and most declare no `attestation:` block. Defaulting to `Require`
/// would mean a box that upgraded its binary, and changed not one byte of its
/// config, came up refusing every promotion. Every other absent key in this
/// file means what the file always meant — `role:` absent is `Primary`,
/// `mirror:` absent is no mirror — and this one must too.
/// * **`Off` cannot be the default either**, and this is the load-bearing half.
/// `Off` is a sentence an operator WROTE: *I have decided not to check.* An
/// absent key is an omission. If they printed the same, an estate that never
/// got round to signing anything would be indistinguishable from one that
/// considered the question and declined — which is the defect this whole module
/// was written against: *"a promotion with no gate looks exactly like one that
/// passed every gate."*
/// * **`Warn` is what is left, and it is honest.** It changes no promotion
/// outcome on any existing estate, and it makes the absence LOUD: every hop
/// logs, by name and by frozen code, either `holger-attest-11` (this server
/// declares no trust root, so it verified nothing) or `holger-promote-10`
/// (these bytes have no provenance under the root it does declare). An operator
/// reading their log learns the truth without a promotion being refused for a
/// policy they never chose.
///
/// Moving to `Require` is then one word in the config, and the log has already
/// told them exactly which artifacts will stop.
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProvenancePolicy {
/// An artifact with no verified provenance statement about its own digest is
/// REFUSED, by name. With no declared trust root, every hop is refused with
/// `holger-attest-11` — a `Require` that cannot verify is never a `Require`
/// that passes.
Require,
/// The same finding, recorded and logged with the same frozen code, and the
/// hop proceeds. **The default.** See the type docs for why.
#[default]
Warn,
/// The operator has declared that this estate does not check provenance. The
/// gate allows and says in its posture line that it was TOLD to.
Off,
}
impl std::fmt::Display for ProvenancePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ProvenancePolicy::Require => "Require",
ProvenancePolicy::Warn => "Warn",
ProvenancePolicy::Off => "Off",
})
}
}
/// ★ **The gate that makes the declared trust root load-bearing.**
///
/// It asks one question of every member of a promotion: *is there, filed on this
/// server, an accepted provenance statement whose subject digest is THESE bytes?*
///
/// Everything in [`crate::attest::AttestStore`] got there through
/// [`crate::attest::AttestStore::accept`], which verifies the DSSE signature
/// against the operator-declared roots (`attestation.trust_roots`) and against
/// the enrolled signer before a byte is written. So "filed" already means
/// "chained to the declared root" — this gate does not re-run the cryptography,
/// it enforces the CONSEQUENCE, which is the half that had no caller.
///
/// # The case that matters most is the empty one
///
/// `declared_roots == 0` — no `attestation:` block, or a block with no roots —
/// means nothing could ever have been filed, so *every* artifact would fail the
/// question for a reason that has nothing to do with the artifact. Reporting that
/// as `holger-promote-10` per artifact would blame the wrong thing, so it is its
/// own code, `holger-attest-11`, raised once for the whole hop and naming the
/// config key to set. **It is never an allow under `Require`.**
pub struct ProvenanceGate {
policy: ProvenancePolicy,
declared_roots: usize,
predicate_types: Vec<String>,
}
impl ProvenanceGate {
/// The name a rung writes in its `gates` list.
pub const NAME: &'static str = "provenance";
/// The predicate types that count as provenance when the operator names none.
/// SLSA v1 is what `nornir-build-thing` produces and what `attest.rs` already
/// accepts by default, so this set and the store's default policy agree.
pub fn default_predicate_types() -> Vec<String> {
vec![crate::attest::SLSA_PROVENANCE_V1.to_string()]
}
pub fn new(policy: ProvenancePolicy, declared_roots: usize) -> Self {
Self {
policy,
declared_roots,
predicate_types: Self::default_predicate_types(),
}
}
/// Override the predicate types this gate will accept as provenance.
pub fn with_predicate_types(mut self, pts: Vec<String>) -> Self {
if !pts.is_empty() {
self.predicate_types = pts;
}
self
}
pub fn policy(&self) -> ProvenancePolicy {
self.policy
}
pub fn declared_roots(&self) -> usize {
self.declared_roots
}
/// **One sentence saying what this gate is actually doing on this box**, for
/// the boot log and for the `Warn` path.
///
/// A gate that is standing and verifying nothing must not print the same
/// sentence as a gate that is standing and verifying. This is the sentence
/// that tells them apart, and it carries the frozen code.
pub fn posture(&self) -> String {
match (self.policy, self.declared_roots) {
(ProvenancePolicy::Off, _) => {
"provenance gate: Off — this estate has DECLARED that it does not check \
provenance at the promotion boundary. This is a decision on the record, not \
an omission."
.to_string()
}
(p, 0) => format!(
"{} provenance gate '{}' is standing and this server declares NO attestation \
trust root (`attestation.trust_roots` is absent or empty), so no statement \
can ever have been verified and the evidence plane is empty by construction. \
Policy is {p}: {}",
ec::ATTEST_NO_TRUST_ROOT_DECLARED.bracketed(),
Self::NAME,
match p {
ProvenancePolicy::Require =>
"every promotion is REFUSED until a root is declared.",
_ => "promotions proceed and this line is the whole of the checking.",
}
),
(p, n) => format!(
"provenance gate '{}' is standing against {n} declared trust root(s), policy \
{p}; accepted predicate type(s): {}",
Self::NAME,
self.predicate_types.join(", ")
),
}
}
/// The refusals this gate WOULD raise for `ctx`, whatever the policy.
///
/// Split out from [`Gate::check`] so that `Warn` records exactly what
/// `Require` would have refused — the same sentences, in the same order, with
/// the same codes. A warning that is not the refusal it stands in for is a
/// warning nobody can act on.
pub fn findings(&self, ctx: &PromotionContext<'_>) -> Vec<String> {
if self.policy == ProvenancePolicy::Off {
return Vec::new();
}
if self.declared_roots == 0 {
return vec![format!(
"{} {}",
ec::ATTEST_NO_TRUST_ROOT_DECLARED.bracketed(),
self.posture()
)];
}
let mut out = Vec::new();
for art in ctx.set {
let has = art.attestations.iter().any(|s| {
self.predicate_types.contains(&s.predicate_type) && s.attests(&art.digest)
});
if !has {
out.push(format!(
"{} {}: no verified provenance statement about {} is filed on this \
server. The evidence plane is keyed by DIGEST and everything in it \
chained to a declared trust root before it was written, so this says \
these BYTES have no provenance here — not that the coordinate is \
unknown. Accepted predicate type(s): {}",
ec::PROMOTE_PROVENANCE_UNVERIFIED.bracketed(),
art.label(),
art.digest,
self.predicate_types.join(", ")
));
}
}
out
}
}
impl Gate for ProvenanceGate {
fn name(&self) -> &str {
Self::NAME
}
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
let findings = self.findings(ctx);
if findings.is_empty() {
return Ok(Verdict::Allow);
}
match self.policy {
ProvenancePolicy::Off => Ok(Verdict::Allow),
ProvenancePolicy::Warn => {
// The hop proceeds. It does NOT proceed quietly: every sentence
// `Require` would have refused with is on the record, by code.
for f in &findings {
log::warn!("provenance (Warn, not refusing): {f}");
}
Ok(Verdict::Allow)
}
ProvenancePolicy::Require => {
if self.declared_roots == 0 {
// Not attributable to any one artifact: the server itself is
// unable to verify. Blaming an artifact here would send the
// operator to re-sign a build when the fix is one config key.
Ok(Verdict::reject_all(findings.join(" | ")))
} else {
// Name the FIRST offending subject so a per-item report puts
// the reason on the right line; the rest ride in the reason.
let subject = ctx
.set
.iter()
.find(|a| {
!a.attestations.iter().any(|s| {
self.predicate_types.contains(&s.predicate_type)
&& s.attests(&a.digest)
})
})
.map(|a| a.label())
.unwrap_or_else(|| ctx.to_repo.to_string());
Ok(Verdict::reject(subject, findings.join(" | ")))
}
}
}
}
}
/// The gates THIS BUILD carries, by name.
///
/// A catalog is built once from the configuration and the compiled features. It
/// is what makes an unknown gate name a boot error that can say what was
/// available, rather than a silent no-op.
#[derive(Default, Clone)]
pub struct GateCatalog {
gates: BTreeMap<String, Arc<dyn Gate>>,
}
impl GateCatalog {
pub fn new() -> Self {
Self::default()
}
/// Register a gate under its own name.
pub fn with(mut self, gate: Arc<dyn Gate>) -> Self {
self.gates.insert(gate.name().to_string(), gate);
self
}
/// Every gate name this build carries, sorted.
pub fn available(&self) -> Vec<&str> {
self.gates.keys().map(String::as_str).collect()
}
/// Resolve a configured list of gate names.
///
/// **HOL-GUN-007.** An unknown name is an error, never a skip. The message
/// names the gate and lists what this build carries, because the operator's
/// next question is always "then what could I have written".
pub fn resolve(&self, names: &[String]) -> Result<GateSet, GateConfigError> {
let mut gates = Vec::with_capacity(names.len());
for n in names {
match self.gates.get(n) {
Some(g) => gates.push(g.clone()),
None => {
return Err(GateConfigError {
named: n.clone(),
available: self.available().iter().map(|s| s.to_string()).collect(),
})
}
}
}
Ok(GateSet {
gates,
enforcement: Enforcement::Block,
})
}
}
impl std::fmt::Debug for GateCatalog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GateCatalog")
.field("available", &self.available())
.finish()
}
}
/// A gate name the configuration asked for and this build does not carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateConfigError {
pub named: String,
pub available: Vec<String>,
}
impl std::fmt::Display for GateConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"promotion gate '{}' is named by the configuration and this build carries no gate by \
that name. A rung that names a gate the build does not have would otherwise run with \
NO gate, and a promotion with no gate looks exactly like one that passed every gate. \
This build carries: {}",
self.named,
if self.available.is_empty() {
"no gates at all".to_string()
} else {
self.available.join(", ")
}
)
}
}
impl std::error::Error for GateConfigError {}
/// The resolved gates for one rung, and how hard they bite.
#[derive(Clone)]
pub struct GateSet {
gates: Vec<Arc<dyn Gate>>,
enforcement: Enforcement,
}
impl Default for GateSet {
fn default() -> Self {
Self {
gates: Vec::new(),
enforcement: Enforcement::Block,
}
}
}
impl GateSet {
/// No gates. Distinct from "gates that all allowed", and
/// [`Decision::summary`] says so.
pub fn empty() -> Self {
Self::default()
}
/// **Add one gate that was not named in the configuration.**
///
/// The one caller is the meter plane: a meter guest is not a gate an
/// operator spells in a `gates:` list, it is a module they installed and
/// granted, and that installation IS the decision. A loaded meter that is
/// never consulted is a meter that is not standing — so it is appended here
/// rather than waiting to be named.
///
/// Appended, never substituted: the configured gates keep their order and
/// their meaning, and `names()` shows the meter beside them.
pub fn with(mut self, gate: Arc<dyn Gate>) -> Self {
self.gates.push(gate);
self
}
/// `Warn` records the decision and permits the hop; `Block` refuses.
pub fn with_enforcement(mut self, e: Enforcement) -> Self {
self.enforcement = e;
self
}
pub fn enforcement(&self) -> Enforcement {
self.enforcement
}
pub fn is_empty(&self) -> bool {
self.gates.is_empty()
}
/// The gate names, in order. Exposed so a surface can answer "what is
/// guarding this rung" — a server whose gates failed to load must not look
/// like one that has none.
pub fn names(&self) -> Vec<&str> {
self.gates.iter().map(|g| g.name()).collect()
}
/// Run every gate over the whole promoted set and collect one [`Decision`].
///
/// Every gate runs even after one refuses, and an `Err` from a gate becomes
/// a refusal marked `undecided`. Both rules live in `nornir_gate::evaluate`,
/// shared with gunnar.
pub fn evaluate(&self, ctx: &PromotionContext<'_>) -> Decision {
nornir_gate::evaluate(self.gates.iter().map(|g| {
(
g.name().to_string(),
g.check(ctx).map_err(|e| format!("{e:#}")),
)
}))
}
}
impl std::fmt::Debug for GateSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GateSet")
.field("gates", &self.names())
.field("enforcement", &self.enforcement)
.finish()
}
}
/// The modgunn scanner an [`OsvGate`] is built over, stating the whole licence
/// policy (the gate hands it on to the licence scanner) and ignoring
/// provenance (its own gate).
#[cfg(feature = "scanners")]
pub fn osv_scanner_for(license: LicensePolicy, advisory_db: security::AdvisoryDb) -> OsvScanner {
let mut policy = security::promotion_policy_for(
license.deny,
license.unknown,
security::ProvenancePolicy::Ignore,
);
policy.license.allow = license.allow;
OsvScanner::new(policy, advisory_db)
}
/// Build the catalog from a promotion configuration.
///
/// `deny_licenses` and `advisory_db` are what the old three environment
/// variables carried; they now arrive from the RON `promotion` block, which is a
/// surface an appliance operator can actually reach.
/// ★ `provenance` is registered **unconditionally**, on every row, whatever the
/// policy. That is the point of W17. Registering it only when it was configured
/// would reproduce the defect it exists to close: a rung that named `provenance`
/// would be a boot failure on a build that could have carried it, and an operator
/// reading `available()` would be told the gate does not exist rather than that
/// it is standing and set to `Warn`.
#[cfg(feature = "scanners")]
pub fn catalog_from_config(
advisory_db: security::AdvisoryDb,
license: LicensePolicy,
attested_predicate: Option<String>,
provenance: ProvenancePolicy,
declared_trust_roots: usize,
) -> GateCatalog {
let scanner = osv_scanner_for(license, advisory_db);
let mut cat = GateCatalog::new()
.with(Arc::new(OsvGate::new(scanner)))
.with(Arc::new(ProvenanceGate::new(
provenance,
declared_trust_roots,
)));
if let Some(pt) = attested_predicate {
cat = cat.with(Arc::new(AttestedGate::new(AttestedGate::NAME, pt)));
}
cat
}
/// The catalog of a build WITHOUT scanners (the downstream, H3): no `osv` gate,
/// since nothing here matches advisories; the provenance gate and the attested
/// gate stand as on every row.
#[cfg(not(feature = "scanners"))]
pub fn catalog_from_config(
attested_predicate: Option<String>,
provenance: ProvenancePolicy,
declared_trust_roots: usize,
) -> GateCatalog {
let mut cat = GateCatalog::new().with(Arc::new(ProvenanceGate::new(
provenance,
declared_trust_roots,
)));
if let Some(pt) = attested_predicate {
cat = cat.with(Arc::new(AttestedGate::new(AttestedGate::NAME, pt)));
}
cat
}
/// The scan worker cell of a build without scanners: never filled.
#[cfg(not(feature = "scanners"))]
pub type WorkerCell = Arc<std::sync::OnceLock<()>>;
#[cfg(all(test, feature = "scanners"))]
mod tests {
use super::*;
fn id() -> ArtifactId {
ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
}
}
struct Always(&'static str, bool);
impl Gate for Always {
fn name(&self) -> &str {
self.0
}
fn check(&self, _: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
if self.1 {
Ok(Verdict::Allow)
} else {
Ok(Verdict::reject("x", "no"))
}
}
}
struct Broken;
impl Gate for Broken {
fn name(&self) -> &str {
"broken"
}
fn check(&self, _: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
anyhow::bail!("the advisory database could not be read")
}
}
fn ctx<'a>(id: &'a ArtifactId, set: &'a [PromotedArtifact<'a>]) -> PromotionContext<'a> {
let _ = id;
PromotionContext {
principal: "tester",
from_repo: "dev",
to_repo: "prod",
hop: None,
set,
}
}
fn one<'a>(id: &'a ArtifactId, bytes: &'a [u8], att: &'a [Statement]) -> PromotedArtifact<'a> {
PromotedArtifact {
id,
ecosystem: "crates.io",
digest: ArtifactDigest::of_sha256(bytes),
bytes,
attestations: att,
}
}
/// HOL-GUN-007: a rung naming a gate the build does not carry is an error,
/// and the error says what the build DOES carry.
#[test]
fn an_unknown_gate_name_is_refused_and_lists_what_exists() {
let cat = GateCatalog::new().with(Arc::new(Always("osv", true)));
// The probe used to be "provenance", back when no build carried such a
// gate. It does now, so the example moved to a name that is not a gate
// and is not going to become one — the assertion is about the REFUSAL,
// not about which particular name is missing.
let err = cat.resolve(&["sorcery".to_string()]).unwrap_err();
assert_eq!(err.named, "sorcery");
let msg = err.to_string();
assert!(msg.contains("osv"), "{msg}");
assert!(msg.contains("looks exactly like one that passed"), "{msg}");
}
/// ★ The other direction, and the one W17 is about: the catalog a real
/// configuration produces CARRIES `provenance`, so a rung may name it on any
/// shipped row. Before 2026-09-21 this assertion was false on all of them.
#[test]
fn the_configured_catalog_carries_the_provenance_gate() {
let cat = catalog_from_config(
security::AdvisoryDb::default(),
LicensePolicy::default(),
None,
ProvenancePolicy::default(),
0,
);
assert!(
cat.available().contains(&ProvenanceGate::NAME),
"catalog_from_config must register the provenance gate unconditionally; it \
carries {:?}",
cat.available()
);
// And it resolves, which is the thing an operator's `gates:` list does.
let set = cat
.resolve(&[ProvenanceGate::NAME.to_string()])
.expect("a rung may name `provenance` on any build");
assert_eq!(set.names(), vec![ProvenanceGate::NAME]);
// Registered even with NO attestation block and NO predicate configured:
// the gate's presence is a property of the build, its verdict a property
// of the configuration. Those are two different questions.
assert!(!cat.available().contains(&AttestedGate::NAME));
}
/// A gate that cannot decide refuses, and is marked undecided so an operator
/// can tell a broken server from a violated rule.
#[test]
fn a_gate_that_errors_refuses_and_is_undecided() {
let cat = GateCatalog::new().with(Arc::new(Broken));
let set = cat.resolve(&["broken".to_string()]).unwrap();
let a = id();
let arts = [one(&a, b"bytes", &[])];
let d = set.evaluate(&ctx(&a, &arts));
assert!(!d.is_allowed());
assert!(d.undecided());
}
/// Every gate runs; one refusal does not hide the next.
#[test]
fn every_gate_runs_and_the_names_are_recorded() {
let cat = GateCatalog::new()
.with(Arc::new(Always("a", false)))
.with(Arc::new(Always("b", false)));
let set = cat.resolve(&["a".to_string(), "b".to_string()]).unwrap();
let a = id();
let arts = [one(&a, b"bytes", &[])];
let d = set.evaluate(&ctx(&a, &arts));
assert_eq!(d.rejections.len(), 2);
assert_eq!(d.gates_run, vec!["a", "b"]);
assert_eq!(set.names(), vec!["a", "b"]);
}
/// A gate set judges an INGEST with the same context and the same rules:
/// the bytes are present, the digest is present, and there is no rung.
#[test]
fn a_gate_runs_on_ingest_as_well_as_on_promotion() {
let cat = GateCatalog::new().with(Arc::new(Always("a", false)));
let set = cat.resolve(&["a".to_string()]).unwrap();
let a = id();
let arts = [one(&a, b"bytes", &[])];
let ctx = PromotionContext::ingest("uploader", "rust-dev", &arts);
assert!(ctx.is_ingest());
let d = set.evaluate(&ctx);
assert!(
!d.is_allowed(),
"an ingest gate must be able to refuse an upload"
);
}
/// No gates is reported as no gates, not as a pass.
#[test]
fn an_empty_gate_set_says_none_configured() {
let set = GateSet::empty();
let a = id();
let arts = [one(&a, b"bytes", &[])];
let d = set.evaluate(&ctx(&a, &arts));
assert!(d.is_allowed());
assert!(d.summary().contains("none configured"), "{}", d.summary());
}
/// The digest reaches the scan seam. Two different byte strings under the
/// SAME coordinate must produce two different subjects, which is exactly
/// what the coordinate-only call could not do.
#[test]
fn two_uploads_of_one_coordinate_are_two_subjects() {
let a = id();
let x = one(&a, b"one", &[]);
let y = one(&a, b"two", &[]);
assert_ne!(x.digest.hex(), y.digest.hex());
let sx = security::artifact_from_coords_with_digest(
x.ecosystem,
&a.name,
&a.version,
x.digest.hex(),
false,
);
let sy = security::artifact_from_coords_with_digest(
y.ecosystem,
&a.name,
&a.version,
y.digest.hex(),
false,
);
assert_ne!(sx.artifact.blob_sha256, sy.artifact.blob_sha256);
assert!(!sx.artifact.blob_sha256.is_empty());
}
/// The evidence gate refuses when the evidence plane holds nothing for THESE
/// bytes, and says that is what it means.
#[test]
fn the_attested_gate_refuses_an_artifact_with_no_evidence() {
let cat = GateCatalog::new().with(Arc::new(AttestedGate::new(
"attested",
"https://slsa.dev/provenance/v1",
)));
let set = cat.resolve(&["attested".to_string()]).unwrap();
let a = id();
let arts = [one(&a, b"bytes", &[])];
let d = set.evaluate(&ctx(&a, &arts));
assert!(!d.is_allowed());
assert!(d.summary().contains("evidence plane"), "{}", d.summary());
}
// ── B2: the licence the PACKAGE declares is what the policy judges ───────
//
// Before the fix the gate built its subject with `license: None` for every
// artifact, and the promotion policy passed an unknown licence. So
// `deny_licenses` could never block a real package: a GPL-3.0 wheel and a
// GPL-3.0 nupkg both promoted clean.
fn wheel(license_expression: Option<&str>) -> Vec<u8> {
let mut meta = String::from("Metadata-Version: 2.4\nName: thing\nVersion: 1.0.0\n");
if let Some(l) = license_expression {
meta.push_str(&format!("License-Expression: {l}\n"));
}
meta.push_str("\nThe long description, which is not a header.\nLicense-Expression: MIT\n");
zip_entry::fixture::zip(&[
("thing/__init__.py", b"".as_slice()),
("thing-1.0.0.dist-info/METADATA", meta.as_bytes()),
(
"thing-1.0.0.dist-info/WHEEL",
b"Wheel-Version: 1.0\n".as_slice(),
),
])
}
fn nupkg(license_expression: &str) -> Vec<u8> {
let xml = format!(
"<?xml version=\"1.0\"?><package><metadata><id>Thing</id><version>1.0.0</version>\
<authors>a</authors><description>d</description>\
<license type=\"expression\">{license_expression}</license></metadata></package>"
);
zip_entry::fixture::zip(&[
("Thing.nuspec", xml.as_bytes()),
("lib/net8.0/Thing.dll", b"MZ".as_slice()),
])
}
fn osv_denying(deny: &[&str]) -> GateSet {
let scanner = OsvScanner::new(
security::promotion_policy(deny.iter().map(|s| s.to_string()).collect()),
security::AdvisoryDb::default(),
);
GateCatalog::new()
.with(Arc::new(OsvGate::new(scanner)))
.resolve(&[OsvGate::NAME.to_string()])
.unwrap()
}
fn judge(set: &GateSet, ecosystem: &str, bytes: &[u8]) -> nornir_gate::Decision {
let a = id();
let arts = [PromotedArtifact {
id: &a,
ecosystem,
digest: ArtifactDigest::of_sha256(bytes),
bytes,
attestations: &[],
}];
set.evaluate(&ctx(&a, &arts))
}
fn reasons(d: &nornir_gate::Decision) -> String {
format!("{d:?}")
}
#[test]
fn a_denied_licence_in_wheel_metadata_blocks_the_promotion() {
let d = judge(&osv_denying(&["GPL-3.0"]), "pip", &wheel(Some("GPL-3.0")));
assert!(
!d.is_allowed(),
"a GPL-3.0 wheel promoted past deny_licenses: {d:?}"
);
assert!(
reasons(&d).contains("GPL-3.0"),
"the refusal must name the licence: {d:?}"
);
}
#[test]
fn a_denied_licence_hidden_in_an_and_expression_blocks_too() {
let d = judge(
&osv_denying(&["GPL-3.0"]),
"pip",
&wheel(Some("MIT AND GPL-3.0")),
);
assert!(!d.is_allowed(), "{d:?}");
}
#[test]
fn a_denied_licence_in_a_nuspec_blocks_the_promotion() {
let d = judge(&osv_denying(&["GPL-3.0"]), "nuget", &nupkg("GPL-3.0"));
assert!(
!d.is_allowed(),
"a GPL-3.0 nupkg promoted past deny_licenses: {d:?}"
);
assert!(reasons(&d).contains("GPL-3.0"), "{d:?}");
}
#[test]
fn an_allowed_declared_licence_promotes() {
let set = osv_denying(&["GPL-3.0"]);
assert!(judge(&set, "pip", &wheel(Some("MIT"))).is_allowed());
assert!(judge(&set, "nuget", &nupkg("Apache-2.0")).is_allowed());
}
/// S6: the gate applies the licence SCANNER's policy, allow list included,
/// so the configured catalog judges a package exactly as the licence slot
/// does.
#[test]
fn the_configured_gate_applies_the_allow_and_deny_lists() {
let set = catalog_from_config(
security::AdvisoryDb::default(),
LicensePolicy::new(
vec!["MIT".into()],
vec!["GPL-3.0".into()],
security::UnknownLicense::default(),
),
None,
ProvenancePolicy::default(),
0,
)
.resolve(&[OsvGate::NAME.to_string()])
.unwrap();
assert!(
judge(&set, "pip", &wheel(Some("MIT OR GPL-3.0"))).is_allowed(),
"an allowed OR branch passes"
);
let d = judge(&set, "pip", &wheel(Some("MIT AND GPL-3.0")));
assert!(!d.is_allowed() && reasons(&d).contains("GPL-3.0"), "{d:?}");
let d = judge(&set, "pip", &wheel(Some("Apache-2.0")));
assert!(
!d.is_allowed() && reasons(&d).contains("allow list"),
"outside the allow list: {d:?}"
);
let d = judge(&set, "pip", &wheel(Some("Not An SPDX Id")));
assert!(
!d.is_allowed() && reasons(&d).contains("held for review"),
"{d:?}"
);
}
/// The default for a package that declares no licence is to HOLD it for
/// review: the hop is refused and the refusal says why. It never passes
/// silently, with or without a deny list.
#[test]
fn an_unknown_licence_is_held_for_review_by_default() {
for deny in [&["GPL-3.0"][..], &[][..]] {
let set = osv_denying(deny);
for (eco, bytes) in [
("pip", wheel(None)),
("cargo", b"not an archive at all".to_vec()),
] {
let d = judge(&set, eco, &bytes);
assert!(
!d.is_allowed(),
"{eco}: an unknown licence passed silently: {d:?}"
);
let r = reasons(&d);
assert!(
r.contains("held for review") && r.contains("licen"),
"{eco}: {r}"
);
}
}
}
// ── W5: the `osv` gate reads the stored evidence back ──
/// A vulnerability scanner with a fixed verdict that counts its calls.
struct Counted(ScanVerdictFixed, Arc<std::sync::atomic::AtomicUsize>);
#[derive(Clone, Copy)]
struct ScanVerdictFixed(crate::scanner::ScanVerdict);
impl crate::scanner::Scanner for Counted {
fn id(&self) -> &str {
"counted"
}
fn version(&self) -> String {
"1".into()
}
fn kind(&self) -> crate::scanner::ScannerKind {
crate::scanner::ScannerKind::Vulnerability
}
fn scan(
&self,
_: &crate::scanner::ScanSubject<'_>,
) -> anyhow::Result<crate::scanner::ScanOutcome> {
use crate::scanner::{FindingSeverity, ScanFinding, ScanOutcome, ScanVerdict};
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(match self.0 .0 {
ScanVerdict::Block => ScanOutcome::block(
vec![ScanFinding::new(
"RUSTSEC-0000-0001",
FindingSeverity::Critical,
"stored finding",
)],
"blocked",
),
_ => ScanOutcome::pass("clean"),
})
}
}
#[test]
fn the_osv_gate_reuses_fresh_stored_evidence_and_audits_it() {
use crate::scanner::{EvidenceStore, ScanPipeline, ScanSubject, ScanVerdict, ScannerSet};
let tmp = tempfile::tempdir().unwrap();
let audit = Arc::new(crate::audit::MemoryAuditLog::new());
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let set = ScannerSet::new().with(Arc::new(Counted(
ScanVerdictFixed(ScanVerdict::Block),
calls.clone(),
)));
let pipeline = Arc::new(ScanPipeline::new(
set,
EvidenceStore::new(tmp.path(), audit.clone()),
));
let bad = b"bytes a scanner blocked".to_vec();
pipeline
.scan(
"importer",
"incoming",
&ScanSubject::new("serde-1.0.0.crate", "crates.io", &bad),
)
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
let cell: EvidenceCell = Arc::new(std::sync::OnceLock::new());
let _ = cell.set(pipeline);
// The gate's own advisory database is EMPTY: only the stored record can block.
let gate = OsvGate::new(OsvScanner::new(
security::promotion_policy_for(
Vec::new(),
security::UnknownLicense::Pass,
security::ProvenancePolicy::Ignore,
),
security::AdvisoryDb::default(),
))
.with_evidence(cell);
let id = id();
let set = [one(&id, &bad, &[])];
let v = gate.check(&ctx(&id, &set)).unwrap();
let reason = format!("{v:?}");
assert!(
!matches!(v, Verdict::Allow),
"the stored block decides: {reason}"
);
assert!(
reason.contains("stored scan evidence") && reason.contains("RUSTSEC-0000-0001"),
"{reason}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"no scanner ran for the reuse"
);
let reused: Vec<_> = audit
.events()
.into_iter()
.filter(|e| e.detail.contains("reused=true"))
.collect();
assert_eq!(reused.len(), 1, "the reuse is on the trail");
assert!(reused[0].detail.contains("by=osv"), "{}", reused[0].detail);
// Bytes with no stored record: the gate scans as before (and allows here).
let other = b"never scanned".to_vec();
let set = [one(&id, &other, &[])];
assert!(matches!(
gate.check(&ctx(&id, &set)).unwrap(),
Verdict::Allow
));
assert_eq!(
audit
.events()
.iter()
.filter(|e| e.detail.contains("reused=true"))
.count(),
1
);
}
}