src/provenance.rs
//! Provenance verification hooks (feature `scan`) — the A3/A24/A25/A26 surface.
//!
//! Engine A's [`crate::scan::Provenance`] is a bare "signed?" boolean. Real
//! supply-chain integrity wants more: *who* signed (sigstore/Fulcio OIDC
//! identity), at what **SLSA** build level, and whether the artifact's claimed
//! source repo + commit match. modgunn does not bundle a cosign/rekor client
//! (that is the dynamic plane's job, network-bound), so this module defines:
//!
//! - [`Attestation`] — the verified provenance facts a verifier produces.
//! - [`ProvenanceRequirement`] — the policy (min SLSA level, trusted signer set,
//! expected source repo) and [`ProvenanceRequirement::evaluate`], which turns an
//! attestation (or its absence) into [`Finding`]s + a [`crate::scan::Provenance`]
//! Engine A can consume.
//! - [`attestation_delta`] — A25: builder-identity change / attestation
//! appears-or-disappears / source-repo mismatch between two snapshots.
//! - [`ScorecardSignals`] — A14/A24: an OSSF Scorecard signal struct (maintained?,
//! branch-protection, signed-releases, …) → advisory [`Finding`]s.
use serde::Deserialize;
use crate::core::{Finding, Severity};
use crate::scan::Provenance;
/// The provenance facts a verifier (cosign/rekor/slsa-verifier on the dynamic
/// plane) establishes about an artifact. `slsa_level` is 0 (none) … 3.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Attestation {
/// The verified signer / OIDC (Fulcio) identity, e.g. a CI workload identity.
pub signer_identity: Option<String>,
/// SLSA build level the provenance attests (0 = none, up to 3).
pub slsa_level: u8,
/// The source repo the artifact claims to be built from.
pub source_repo: Option<String>,
/// The source commit SHA the artifact claims to be built from.
pub source_sha: Option<String>,
}
impl Attestation {
/// A verified sigstore attestation by `identity` at the given SLSA level.
pub fn signed_by(identity: impl Into<String>, slsa_level: u8) -> Self {
Self {
signer_identity: Some(identity.into()),
slsa_level,
..Default::default()
}
}
}
// ── Sigstore / Rekor transparency-log entries ────────────────────────────────
//
// A cosign signature is only half the story: the OTHER half is that the signature
// was written to a PUBLIC, append-only transparency log (Rekor), so a signer cannot
// quietly sign a malicious artifact for one victim. This module reads a Rekor entry
// offline and turns it into the facts modgunn's provenance verdict keys on — which
// artifact digest was logged, by which Fulcio/OIDC identity, at which log index and
// integration time.
//
// Pure parse, no network and no crypto: verifying the inclusion proof against the
// log's signed tree head is the dynamic plane's job (a live Rekor client). What
// modgunn owns is the *record* — the digest→identity→index binding that a promotion
// gate compares against its policy, and the honest statement of which parts are
// self-asserted vs. proof-backed.
/// One Sigstore **Rekor** transparency-log entry, decoded far enough to be useful to
/// a provenance verdict.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RekorEntry {
/// The entry UUID (the log's leaf hash) — its stable identity.
pub uuid: String,
/// The monotonically-increasing position in the log. A *lower* index than an
/// artifact you already trust means the entry predates it.
pub log_index: u64,
/// Unix seconds at which the log integrated the entry — the "signed at" time a
/// gate compares against the artifact's build time.
pub integrated_time: i64,
/// The Rekor **type** of the entry body (`hashedrekord` / `intoto` / `dsse` / …).
pub kind: String,
/// The artifact digest the entry binds, lowercased hex (`""` when the body kind
/// carries none in a shape we read).
pub artifact_sha256: String,
/// The signer identity extracted from the entry, when the body states one.
pub signer_identity: Option<String>,
/// Whether the entry carries an **inclusion proof** — the cryptographic evidence
/// it really is in the log, as opposed to a bare self-asserted record.
pub has_inclusion_proof: bool,
/// Whether the entry carries a **signed entry timestamp** (the log's countersignature).
pub has_signed_timestamp: bool,
}
impl RekorEntry {
/// The provenance facts this log entry establishes, as the [`Attestation`] the
/// A3 gate evaluates.
///
/// **`slsa_level` is deliberately 0.** A Rekor entry proves *this digest was
/// logged by this identity*; it says nothing about how the artifact was BUILT,
/// which is what a SLSA level asserts. Inferring a level from log presence would
/// be exactly the kind of unearned claim `provenance-verify` exists to prevent —
/// a caller that also has a verified SLSA provenance predicate raises it
/// explicitly.
pub fn attestation(&self) -> Attestation {
Attestation {
signer_identity: self.signer_identity.clone(),
slsa_level: 0,
..Default::default()
}
}
/// Findings about the *strength* of this transparency record, worst first. An
/// entry with both an inclusion proof and a signed timestamp is clean.
pub fn findings(&self) -> Vec<Finding> {
let mut out = Vec::new();
if !self.has_inclusion_proof {
out.push(Finding {
severity: Severity::High,
id: "MODGUNN-REKOR-NO-INCLUSION-PROOF".into(),
summary: format!(
"Rekor entry {} carries no inclusion proof — the claim that it is in the log is self-asserted",
self.uuid
),
});
}
if !self.has_signed_timestamp {
out.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-REKOR-NO-SIGNED-TIMESTAMP".into(),
summary: format!(
"Rekor entry {} carries no signed entry timestamp (the log's countersignature)",
self.uuid
),
});
}
if self.signer_identity.is_none() {
out.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-REKOR-NO-SIGNER-IDENTITY".into(),
summary: format!("Rekor entry {} states no signer identity — nothing to match against trusted signers", self.uuid),
});
}
out
}
}
/// Decode standard base64 (`A–Z a–z 0–9 + /`, `=` padding). Rekor wraps the entry
/// body in it. A dozen lines of pure Rust rather than a `base64` dependency, so the
/// LEAN `scan` gate (holger / static-holger) stays dependency-clean — the whole point
/// of the feature split. Whitespace is ignored; any other invalid character, or a
/// truncated final group, yields `None` (never a partially-decoded body that would
/// parse into a *wrong* record).
fn base64_decode(s: &str) -> Option<Vec<u8>> {
fn val(c: u8) -> Option<u8> {
Some(match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
})
}
let (mut acc, mut bits, mut out, mut pad) = (0u32, 0u8, Vec::new(), 0usize);
for c in s.bytes() {
if c.is_ascii_whitespace() {
continue;
}
if c == b'=' {
pad += 1;
continue;
}
if pad > 0 {
return None; // data after padding — malformed
}
acc = (acc << 6) | val(c)? as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
// A leftover group of 6 bits is a truncated encoding, not a valid one.
if bits >= 6 || (acc & ((1u32 << bits) - 1)) != 0 {
return None;
}
Some(out)
}
/// Parse a **Rekor** log-entry response into [`RekorEntry`]s.
///
/// The `/api/v1/log/entries/{uuid}` response is a map `{ "<uuid>": { body,
/// integratedTime, logIndex, verification } }` (a retrieval by index or a search
/// returns several). The `body` is base64-encoded canonical JSON whose `kind` selects
/// the spec shape; the artifact digest and signer are read from the shapes that carry
/// them:
///
/// - **`hashedrekord`** — `spec.data.hash.value` is the artifact digest; the signer is
/// the `spec.signature.publicKey` (a Fulcio cert, reported as its base64 identity
/// only when the entry states a plain identity, see below).
/// - **`intoto` / `dsse`** — `spec.content.payloadHash.value` (or `spec.content.hash.value`)
/// is the payload digest; the signer identity is `spec.content.envelope.signatures[].keyid`
/// when present.
///
/// **The `verification` block is read, not assumed.** An entry with no
/// `inclusionProof` is a self-asserted record — modgunn records that fact
/// ([`RekorEntry::has_inclusion_proof`]) and [`RekorEntry::findings`] grades it
/// `High`, rather than treating "the JSON says it is in the log" as proof. A body
/// that is not valid base64, or not JSON once decoded, is skipped and does not
/// silently become an entry with empty fields.
pub fn parse_rekor_entries(json: &str) -> Result<Vec<RekorEntry>, serde_json::Error> {
let doc: serde_json::Value = serde_json::from_str(json)?;
// A single-entry object (already unwrapped) vs. the uuid-keyed map.
let pairs: Vec<(String, &serde_json::Value)> = if doc.get("body").is_some() {
vec![(
doc.get("uuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string(),
&doc,
)]
} else if let Some(map) = doc.as_object() {
map.iter().map(|(k, v)| (k.clone(), v)).collect()
} else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for (uuid, v) in pairs {
let Some(body_b64) = v.get("body").and_then(|b| b.as_str()) else {
continue;
};
let Some(bytes) = base64_decode(body_b64) else {
continue;
};
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let kind = body
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("")
.to_string();
let spec = body.get("spec");
// The digest, per body kind. `hashedrekord` puts it under data.hash;
// intoto/dsse under content.payloadHash (older: content.hash).
let digest = spec
.and_then(|s| s.pointer("/data/hash/value"))
.or_else(|| spec.and_then(|s| s.pointer("/content/payloadHash/value")))
.or_else(|| spec.and_then(|s| s.pointer("/content/hash/value")))
.and_then(|d| d.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let signer = spec
.and_then(|s| s.pointer("/content/envelope/signatures/0/keyid"))
.or_else(|| spec.and_then(|s| s.pointer("/signature/publicKey/identity")))
.and_then(|k| k.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let verification = v.get("verification");
out.push(RekorEntry {
uuid,
log_index: v.get("logIndex").and_then(|i| i.as_u64()).unwrap_or(0),
integrated_time: v
.get("integratedTime")
.and_then(|t| t.as_i64())
.unwrap_or(0),
kind,
artifact_sha256: digest,
signer_identity: signer,
has_inclusion_proof: verification.and_then(|x| x.get("inclusionProof")).is_some(),
has_signed_timestamp: verification
.and_then(|x| x.get("signedEntryTimestamp"))
.and_then(|t| t.as_str())
.map(|t| !t.trim().is_empty())
.unwrap_or(false),
});
}
Ok(out)
}
/// Policy for verifying an [`Attestation`] (the A3 check). All-empty/zero is
/// "accept anything signed".
#[derive(Debug, Clone, Default)]
pub struct ProvenanceRequirement {
/// Minimum acceptable SLSA build level (0 = don't care).
pub require_min_slsa: u8,
/// Signer identities the consumer trusts (empty = any signer accepted).
pub trusted_signers: Vec<String>,
/// The source repo the artifact must claim, if any.
pub expected_source_repo: Option<String>,
/// The exact source commit (VCS revision) the artifact must be built from, if
/// pinned. Pinning the commit — not just the repo — is what closes the "right
/// repo, unexpected commit" gap: a promotion can demand the artifact came from a
/// reviewed/tagged revision, so a build off an unmerged or attacker-pushed commit
/// on the trusted repo still fails. Empty = don't care.
pub expected_source_sha: Option<String>,
}
impl ProvenanceRequirement {
/// Evaluate an attestation against this requirement. Returns the
/// [`Provenance`] Engine A consumes (its `signed` reflects "passed all
/// checks") plus the [`Finding`]s explaining any shortfall. `None` ⇒ the
/// artifact carries no attestation at all.
pub fn evaluate(&self, att: Option<&Attestation>) -> (Provenance, Vec<Finding>) {
let Some(att) = att else {
return (
Provenance::unsigned(),
vec![Finding {
severity: Severity::High,
id: "MODGUNN-PROV-NO-ATTESTATION".into(),
summary: "artifact carries no provenance attestation".into(),
}],
);
};
let mut findings = Vec::new();
if att.slsa_level < self.require_min_slsa {
findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-SLSA-TOO-LOW".into(),
summary: format!(
"SLSA level {} below required {}",
att.slsa_level, self.require_min_slsa
),
});
}
match &att.signer_identity {
Some(id) if !self.trusted_signers.is_empty() && !self.trusted_signers.contains(id) => {
findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-UNTRUSTED-SIGNER".into(),
summary: format!("signer `{id}` is not in the trusted set"),
});
}
None => findings.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-PROV-NO-SIGNER".into(),
summary: "attestation has no signer identity".into(),
}),
Some(_) => {}
}
if let Some(expected) = &self.expected_source_repo {
if att.source_repo.as_deref() != Some(expected.as_str()) {
findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-SOURCE-MISMATCH".into(),
summary: format!(
"source repo `{}` ≠ expected `{expected}`",
att.source_repo.as_deref().unwrap_or("<none>")
),
});
}
}
if let Some(expected) = &self.expected_source_sha {
// Case-insensitive: git revisions are hex, and producers differ on case.
let matches = att
.source_sha
.as_deref()
.is_some_and(|got| got.eq_ignore_ascii_case(expected));
if !matches {
findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-COMMIT-MISMATCH".into(),
summary: format!(
"source commit `{}` ≠ expected `{expected}`",
att.source_sha.as_deref().unwrap_or("<none>")
),
});
}
}
let signer = att.signer_identity.clone();
// `signed` = it passed verification (no findings) AND has a signer.
let prov = Provenance {
signed: findings.is_empty() && signer.is_some(),
signer,
};
(prov, findings)
}
}
/// Case-insensitive equality for two optional hex VCS revisions.
fn sha_eq(a: Option<&str>, b: Option<&str>) -> bool {
match (a, b) {
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
(None, None) => true,
_ => false,
}
}
/// A25 — the **attestation delta** between two snapshots of the same artifact.
/// Fires on the supply-chain-anomaly signals: attestation disappearing, builder
/// (signer) identity changing, or the claimed source repo moving.
pub fn attestation_delta(old: Option<&Attestation>, new: Option<&Attestation>) -> Vec<Finding> {
let mut findings = Vec::new();
match (old, new) {
(Some(_), None) => findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-ATTESTATION-DROPPED".into(),
summary: "provenance attestation present before is now absent".into(),
}),
(None, Some(_)) => findings.push(Finding {
severity: Severity::Info,
id: "MODGUNN-PROV-ATTESTATION-ADDED".into(),
summary: "provenance attestation newly present".into(),
}),
(Some(o), Some(n)) => {
if o.signer_identity != n.signer_identity {
findings.push(Finding {
severity: Severity::High,
id: "MODGUNN-PROV-SIGNER-CHANGED".into(),
summary: format!(
"signer identity changed: {:?} → {:?}",
o.signer_identity, n.signer_identity
),
});
}
if o.source_repo != n.source_repo {
findings.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-PROV-SOURCE-CHANGED".into(),
summary: format!(
"source repo changed: {:?} → {:?}",
o.source_repo, n.source_repo
),
});
}
// A commit move for what should be the SAME immutable artifact is a
// rebuild-from-a-different-revision signal — a version ought to be
// reproducible from a single source commit. Only flagged when BOTH sides
// attest a commit (a first-time appearance is not a "change") and the
// repo held steady (a repo move already carries its own, stronger finding
// and would make a commit diff redundant noise).
if o.source_repo == n.source_repo
&& o.source_sha.is_some()
&& n.source_sha.is_some()
&& !sha_eq(o.source_sha.as_deref(), n.source_sha.as_deref())
{
findings.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-PROV-COMMIT-CHANGED".into(),
summary: format!(
"source commit changed on the same repo: {:?} → {:?}",
o.source_sha, n.source_sha
),
});
}
}
(None, None) => {}
}
findings
}
/// A14/A24 — an OSSF **Scorecard** signal struct (a subset of the real checks).
/// `overall` is the 0..=10 aggregate; the booleans are the high-signal checks.
#[derive(Debug, Clone, PartialEq)]
pub struct ScorecardSignals {
pub subject: String,
pub overall: f64,
pub maintained: bool,
pub branch_protection: bool,
pub signed_releases: bool,
pub dangerous_workflow: bool,
}
impl ScorecardSignals {
/// Turn the signals into advisory [`Finding`]s. A low overall score warns; an
/// unmaintained project or a dangerous workflow is a stronger signal (the
/// bus-factor / takeover-risk A14/A24 features).
pub fn findings(&self) -> Vec<Finding> {
let mut out = Vec::new();
if self.overall < 5.0 {
out.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-SCORECARD-LOW".into(),
summary: format!(
"OSSF Scorecard {:.1}/10 for `{}`",
self.overall, self.subject
),
});
}
if !self.maintained {
out.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-SCORECARD-UNMAINTAINED".into(),
summary: format!("`{}` appears unmaintained (bus-factor risk)", self.subject),
});
}
if self.dangerous_workflow {
out.push(Finding {
severity: Severity::High,
id: "MODGUNN-SCORECARD-DANGEROUS-WORKFLOW".into(),
summary: format!("`{}` has a dangerous CI workflow", self.subject),
});
}
if !self.branch_protection {
out.push(Finding {
severity: Severity::Low,
id: "MODGUNN-SCORECARD-NO-BRANCH-PROTECTION".into(),
summary: format!(
"`{}` lacks branch protection on its default branch",
self.subject
),
});
}
out
}
}
/// Parse an **OpenSSF Scorecard** JSON report (`scorecard --format json`) into
/// [`ScorecardSignals`] — the read-side counterpart to
/// [`ScorecardSignals::findings`]'s fold. Mirrors `feeds::parse_*`: pure serde_json,
/// offline, one fixture-driven parser, no twin of the signal model (L5 — it produces
/// the SAME [`ScorecardSignals`] the warehouse round-trips and `provenance-log` reads).
///
/// Scorecard scores every check `0..=10` (higher = better; `-1` = inconclusive /
/// not-run) plus a top-level aggregate `score`. The high-signal checks fold to the
/// booleans: a check *passes* at Scorecard's own **Medium** risk tier (`score >= 5`).
/// **Dangerous-Workflow is inverted** — it scores `10` when clean and `0` when a
/// dangerous CI pattern (self-hosted-runner injection / script-injection) is found,
/// so a present score in `0..10` sets [`ScorecardSignals::dangerous_workflow`] (which
/// `findings()` maps to a High). Unknown / absent checks stay `false` (no signal).
pub fn parse_scorecard(json: &str) -> Result<ScorecardSignals, serde_json::Error> {
let doc: ScorecardDoc = serde_json::from_str(json)?;
// Scorecard's own risk tiers put "Medium" at 5/10; use it as the pass line.
const PASS: i64 = 5;
let score_of = |name: &str| {
doc.checks
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.map(|c| c.score)
};
let passes = |name: &str| score_of(name).is_some_and(|s| s >= PASS);
let signals = ScorecardSignals {
subject: doc.repo.name,
overall: doc.score,
maintained: passes("Maintained"),
branch_protection: passes("Branch-Protection"),
signed_releases: passes("Signed-Releases"),
// 10 ⇒ no dangerous workflow found; 0 ⇒ found; -1 ⇒ inconclusive → not flagged.
dangerous_workflow: matches!(score_of("Dangerous-Workflow"), Some(s) if (0..10).contains(&s)),
};
// Introspection marker — the Scorecard ingest surface reports each report it
// folds (GREEN when parsed; the dangerous-workflow flag rides the detail) so
// `nornir test` SEES the A14/A24 seam. Feature-gated: a compiled-out no-op with
// no nornir dep in a lean `scan` build.
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/scorecard-ingest",
"parse_scorecard",
true,
&format!(
"{}: {:.1}/10 dangerous_workflow={} branch_protection={}",
signals.subject, signals.overall, signals.dangerous_workflow, signals.branch_protection
),
);
Ok(signals)
}
/// The subset of the OpenSSF Scorecard JSON report `parse_scorecard` folds. `serde`
/// ignores the many other fields (`date`, `scorecard`, per-check `reason`/`details`).
#[derive(Debug, Deserialize)]
struct ScorecardDoc {
#[serde(default)]
score: f64,
#[serde(default)]
repo: ScorecardRepo,
#[serde(default)]
checks: Vec<ScorecardCheck>,
}
#[derive(Debug, Default, Deserialize)]
struct ScorecardRepo {
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
struct ScorecardCheck {
#[serde(default)]
name: String,
#[serde(default)]
score: i64,
}
// ─── Real znippy-sealed build-provenance verifier (feature `provenance-verify`) ──
//
// The A3 surface above only POLICY-SCORES an `Attestation` it is handed. This is
// the missing half of the build-provenance chain (Tier-1 bug #2): OPEN the
// `<artifact>.build-provenance.znippy` blob nornir's `build::seal::dump_beside_artifact`
// writes, cryptographically VERIFY it, and PRODUCE the `Attestation` `evaluate`
// consumes — so a verdict runs on a genuinely-verified attestation, never an
// assumed one.
//
// Format (matched byte-for-byte to nornir's seal): the archive is a znippy archive
// holding one entry `attestation.json` = a serialized
// `nornir_build_thing::provenance::Attestation` (record + 32-byte digest + a
// detached CMS `Seal`). Verification: (1) recompute the record's canonical SHA-256
// and confirm it equals the stored digest (record-tamper check); (2) verify the
// detached CMS `SignedData` over that digest, chaining the signer cert to a trusted
// root (`znippy_common::sign::verify_digest`); (3) confirm the artifact's
// `blob_sha256` is one the provenance attests (content-addressing). Only then is an
// `Attestation` produced.
#[cfg(feature = "provenance-verify")]
mod verify {
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use nornir_build_thing::provenance::Attestation as SealedAttestation;
use znippy_common::archive::{ZnippyArchive, ZnippyReader};
use znippy_common::sign::{dsse_verify, verify_digest, CertStore, DsseEnvelope, VerifiedSigner};
use super::{Attestation, ProvenanceRequirement};
use crate::core::{Finding, Severity};
use crate::scan::Provenance;
/// The suffix nornir's `seal::dump_beside_artifact` appends to the artifact name.
pub const SEALED_PROVENANCE_SUFFIX: &str = ".build-provenance.znippy";
/// The single entry inside the sealed archive (nornir stages `attestation.json`).
pub const ATTESTATION_ENTRY: &str = "attestation.json";
/// SLSA build level a verified znippy-sealed build-provenance attests: L2 — a
/// scripted/hosted build emitting a **signed, content-addressed** provenance
/// record. This is a floor (the seal proves builder identity + integrity, not
/// the hermetic-build / non-falsifiable guarantees SLSA L3 additionally wants),
/// so a policy requiring L3 still (correctly) fails a merely-sealed artifact.
pub const VERIFIED_SLSA_LEVEL: u8 = 2;
/// The verdict of verifying one sealed build-provenance blob — the row the
/// `build_provenance` warehouse ledger records.
#[derive(Debug, Clone)]
pub struct ProvenanceVerification {
/// Whether the seal genuinely verified (signature + chain + digest + content).
pub verified: bool,
/// The provenance record's UUID (empty when the blob could not be read).
pub artifact_id: String,
/// The artifact content hash the verdict is about (the checked
/// `blob_sha256`, else the record's primary artifact hash).
pub blob_sha256: String,
/// The repo the record claims (for warehouse scoping).
pub repo: String,
/// The verified signer identity (CN, else full subject); `None` when unverified.
pub signer: Option<String>,
/// A clear human reason — "verified …" or the exact reason it was rejected.
pub reason: String,
/// The genuinely-verified attestation `evaluate` may consume. `Some` **iff**
/// `verified`; `None` for a tampered / unverifiable blob (never an assumed one).
pub attestation: Option<Attestation>,
}
impl ProvenanceVerification {
fn unreadable(reason: String) -> Self {
Self {
verified: false,
artifact_id: String::new(),
blob_sha256: String::new(),
repo: String::new(),
signer: None,
reason,
attestation: None,
}
}
}
/// Open the sealed archive and parse its `attestation.json`. Tolerates a
/// path-prefixed entry (nornir's `compress_dir` stages the file under a dir, so
/// the stored name may carry that prefix) by matching on the basename.
fn read_sealed_attestation(archive_path: &Path) -> Result<SealedAttestation> {
let archive = ZnippyArchive::open(archive_path).with_context(|| {
format!("open sealed provenance archive {}", archive_path.display())
})?;
let bytes = match archive.extract_file(ATTESTATION_ENTRY) {
Ok(b) => b,
Err(_) => {
let files = archive
.list_files()
.context("list sealed archive entries")?;
let entry = files
.iter()
.find(|p| p.rsplit('/').next() == Some(ATTESTATION_ENTRY))
.ok_or_else(|| {
anyhow!("sealed archive carries no `{ATTESTATION_ENTRY}` entry")
})?;
archive
.extract_file(entry)
.with_context(|| format!("extract {entry} from sealed archive"))?
}
};
let text = String::from_utf8(bytes).context("attestation.json is not valid UTF-8")?;
SealedAttestation::from_json(&text).context("parse sealed attestation")
}
/// Open + cryptographically verify a znippy-sealed
/// `<artifact>.build-provenance.znippy`. `trusted_roots` are DER CA certs the
/// signer must chain to. When `expected_blob_sha256` is given, the artifact's
/// content hash must be one the provenance attests (content-addressing). Never
/// panics / errors: an unreadable / tampered / untrusted / mismatched blob is a
/// clear un-verified verdict, not an `Err`.
pub fn verify_sealed_provenance(
archive_path: &Path,
trusted_roots: &[Vec<u8>],
expected_blob_sha256: Option<&str>,
) -> ProvenanceVerification {
let sealed = match read_sealed_attestation(archive_path) {
Ok(a) => a,
Err(e) => {
return ProvenanceVerification::unreadable(format!(
"cannot read sealed provenance: {e:#}"
))
}
};
let artifact_id = sealed.record.id.clone();
let repo = sealed.record.repo.clone();
let blob_sha256 = expected_blob_sha256
.map(str::to_string)
.or_else(|| sealed.record.artifacts.first().map(|a| a.sha256.clone()))
.unwrap_or_default();
let verdict =
|verified: bool, signer: Option<String>, reason: String, att: Option<Attestation>| {
ProvenanceVerification {
verified,
artifact_id: artifact_id.clone(),
blob_sha256: blob_sha256.clone(),
repo: repo.clone(),
signer,
reason,
attestation: att,
}
};
// 1. The stored digest must still match the record (record-tamper check).
match sealed.digest_matches_record() {
Ok(true) => {}
Ok(false) => {
return verdict(
false,
None,
"attestation digest does not match its record (record tampered)".into(),
None,
)
}
Err(e) => {
return verdict(
false,
None,
format!("cannot recompute attestation digest: {e:#}"),
None,
)
}
}
// 2. It must carry a cryptographic seal — CMS (`seal`) OR the additive DSSE
// (`dsse`, the in-toto SLSA envelope). An unsealed record is unverifiable.
if sealed.seal.is_none() && sealed.dsse.is_none() {
return verdict(
false,
None,
"attestation carries no cryptographic seal (unverifiable)".into(),
None,
);
}
// 3. Verify + chain the signer to a root. modgunn already reads a Rekor
// `content.envelope.signatures[].keyid`; verifying our OWN DSSE envelope
// here IS the producer↔verifier agreement.
let store = match CertStore::from_der_certs(trusted_roots) {
Ok(s) => s,
Err(e) => {
return verdict(
false,
None,
format!("cannot load trusted roots: {e:#}"),
None,
)
}
};
let signer_id: VerifiedSigner = if let Some(dsse) = sealed.dsse.as_ref() {
// DSSE: the envelope's payload MUST be the SLSA statement this record
// derives (else a genuine envelope was spliced onto a different record),
// then the DSSE signature must verify against the carried signer cert.
let env = match DsseEnvelope::from_json(&dsse.envelope_json) {
Ok(e) => e,
Err(e) => {
return verdict(false, None, format!("cannot parse DSSE envelope: {e:#}"), None)
}
};
let expected = match sealed.record.slsa_statement_bytes() {
Ok(b) => b,
Err(e) => {
return verdict(
false,
None,
format!("cannot recompute SLSA statement: {e:#}"),
None,
)
}
};
if env.payload != expected {
return verdict(
false,
None,
"DSSE envelope payload does not match the record's SLSA statement \
(record or envelope tampered)"
.into(),
None,
);
}
match dsse_verify(&env, &dsse.signer_certs, &store) {
Ok(mut v) if !v.is_empty() => v.remove(0),
Ok(_) => {
return verdict(
false,
None,
"DSSE envelope produced no verified signer".into(),
None,
)
}
Err(e) => {
return verdict(
false,
None,
format!("DSSE signature/chain verification failed: {e:#}"),
None,
)
}
}
} else {
let seal = sealed.seal.as_ref().expect("checked present above");
match verify_digest(&sealed.digest, &seal.cms_der, &store) {
Ok(id) => id,
Err(e) => {
return verdict(
false,
None,
format!("seal signature/chain verification failed: {e:#}"),
None,
)
}
}
};
// `verify_digest` returns a `VerifiedSigner` (the identity PLUS the signed
// attributes it actually verified), so the identity is read through the
// accessors rather than as bare fields.
let signer_label = if signer_id.common_name().is_empty() {
signer_id.subject().to_string()
} else {
signer_id.common_name().to_string()
};
// 4. Content-addressing: the artifact's blob hash must be one this
// provenance attests (a genuine seal over an UNRELATED artifact is not a
// verdict for THIS artifact).
if let Some(want) = expected_blob_sha256 {
let attested = sealed
.record
.artifacts
.iter()
.any(|a| a.sha256.eq_ignore_ascii_case(want));
if !attested {
return verdict(
false,
Some(signer_label),
format!(
"content digest {want} is not attested by this provenance \
(no matching artifact hash)"
),
None,
);
}
}
// Genuinely verified → produce the attestation `evaluate` consumes.
let attestation = Attestation {
signer_identity: Some(signer_label.clone()),
slsa_level: VERIFIED_SLSA_LEVEL,
source_repo: Some(sealed.record.repo.clone()),
source_sha: Some(sealed.record.inputs.vcs_rev.clone()),
};
verdict(
true,
Some(signer_label.clone()),
format!(
"verified: znippy-sealed build-provenance signed by `{signer_label}`; \
record digest + content hash confirmed"
),
Some(attestation),
)
}
impl ProvenanceRequirement {
/// Open + cryptographically VERIFY a znippy-sealed
/// `<artifact>.build-provenance.znippy`, then run [`Self::evaluate`] on the
/// genuinely-verified attestation. When the seal cannot be verified (absent,
/// tampered, untrusted signer, or a content-hash mismatch) the artifact is
/// treated as UNSIGNED with a single clear finding — policy is never scored
/// against unverified/assumed provenance. Returns the Engine-A [`Provenance`],
/// the [`Finding`]s, and the raw [`ProvenanceVerification`] verdict (which the
/// `build_provenance` warehouse ledger records).
pub fn evaluate_sealed(
&self,
archive_path: &Path,
trusted_roots: &[Vec<u8>],
expected_blob_sha256: Option<&str>,
) -> (Provenance, Vec<Finding>, ProvenanceVerification) {
let v = verify_sealed_provenance(archive_path, trusted_roots, expected_blob_sha256);
if v.verified {
let (prov, findings) = self.evaluate(v.attestation.as_ref());
(prov, findings, v)
} else {
let finding = Finding {
severity: Severity::High,
id: "MODGUNN-PROV-UNVERIFIABLE".into(),
summary: v.reason.clone(),
};
(Provenance::unsigned(), vec![finding], v)
}
}
}
/// Mint a REAL znippy-sealed `<name>.build-provenance.znippy` blob + its trusted
/// CA DER root, for driving the `verify-provenance` verb end-to-end. Seals the
/// SAME way nornir does (an `nornir_build_thing` attestation → detached CMS via
/// `znippy_common::sign` → one-entry (`attestation.json`) znippy archive), so a
/// caller gets a blob the production verifier genuinely accepts.
///
/// Returns `(blob_path, ca_der_root, artifact_sha256)`. `signer_cn` becomes the
/// verified signer identity (e.g. `"nordisk-ci"` to satisfy `--trusted-signer`).
/// Exposed under `test-support` so an OUT-OF-CRATE integration test (the
/// `tests/cli_provenance.rs` subprocess harness) can reuse the exact fixture the
/// in-crate `verify_tests` seal, instead of each re-inventing a private one.
#[cfg(any(test, feature = "test-support"))]
pub fn seal_provenance_fixture(
dir: &Path,
signer_cn: &str,
) -> Result<(std::path::PathBuf, Vec<u8>, String)> {
use nornir_build_thing::provenance::{BuildProvenance, HostInfo, Outcome, Seal, Toolchain};
use nornir_build_thing::variant::{BuildVariant, Profile};
use znippy_common::meta_sink_append::create_archive_to_vec;
use znippy_common::sign::dev::{mint_ca, new_ed25519_signer};
use znippy_common::sign::ArchiveSigner;
std::fs::create_dir_all(dir.join("target/release")).context("create fixture target dir")?;
let art = dir.join("target/release/holger-full");
std::fs::write(&art, b"sealed holger-full bytes").context("write fixture artifact")?;
let variant = BuildVariant {
name: "full".to_string(),
package: Some("holger-full".to_string()),
bin: Some("holger-full".to_string()),
features: vec!["full".to_string()],
profile: Profile::Release,
..Default::default()
};
let att = BuildProvenance::capture(
&variant,
"holger",
dir,
Toolchain::default(),
HostInfo::default(),
Outcome::Success,
&[art],
)
.context("capture build provenance")?
.attest()
.context("attest build provenance")?;
let (ca_key, ca_der) = mint_ca("modgunn-verify-test CA").context("mint CA")?;
let signer = new_ed25519_signer(&ca_key, &ca_der, signer_cn).context("mint signer")?;
let cms = signer.sign_digest(&att.digest).context("sign digest")?;
let art_sha = att.record.artifacts[0].sha256.clone();
let mut sealed = att;
sealed.seal = Some(Seal {
alg: "ed25519".to_string(),
signer_cn: signer_cn.to_string(),
cms_der: cms,
});
let json = sealed.to_json().context("serialize sealed attestation")?;
let (bytes, _report) =
create_archive_to_vec(&[(ATTESTATION_ENTRY.to_string(), json.into_bytes())], 3)
.context("pack sealed archive")?;
let blob = dir.join(format!("holger-full{SEALED_PROVENANCE_SUFFIX}"));
std::fs::write(&blob, &bytes).context("write sealed blob")?;
Ok((blob, ca_der, art_sha))
}
/// Like [`seal_provenance_fixture`] but seals with a **DSSE** envelope over the
/// in-toto SLSA statement (the SLSA-interop shape) in the additive
/// `Attestation::dsse` slot, so a test can prove modgunn verifies our DSSE
/// envelope end-to-end. Seals the same way nornir's `ZnippySealer` DSSE mode
/// does — `dsse_sign` over `BuildProvenance::slsa_statement_bytes`, carrying the
/// signer cert — without depending on nornir (modgunn is upstream of it).
#[cfg(any(test, feature = "test-support"))]
pub fn seal_provenance_fixture_dsse(
dir: &Path,
signer_cn: &str,
) -> Result<(std::path::PathBuf, Vec<u8>, String)> {
use nornir_build_thing::provenance::{
BuildProvenance, DsseSeal, HostInfo, Outcome, Toolchain, DSSE_PAYLOAD_TYPE_INTOTO,
};
use nornir_build_thing::variant::{BuildVariant, Profile};
use znippy_common::meta_sink_append::create_archive_to_vec;
use znippy_common::sign::dev::{mint_ca, new_ed25519_signer};
use znippy_common::sign::{dsse_sign, ArchiveSigner};
std::fs::create_dir_all(dir.join("target/release")).context("create fixture target dir")?;
let art = dir.join("target/release/holger-full");
std::fs::write(&art, b"sealed holger-full bytes").context("write fixture artifact")?;
let variant = BuildVariant {
name: "full".to_string(),
package: Some("holger-full".to_string()),
bin: Some("holger-full".to_string()),
features: vec!["full".to_string()],
profile: Profile::Release,
..Default::default()
};
let att = BuildProvenance::capture(
&variant,
"holger",
dir,
Toolchain::default(),
HostInfo::default(),
Outcome::Success,
&[art],
)
.context("capture build provenance")?
.attest()
.context("attest build provenance")?;
let (ca_key, ca_der) = mint_ca("modgunn-dsse-test CA").context("mint CA")?;
let signer = new_ed25519_signer(&ca_key, &ca_der, signer_cn).context("mint signer")?;
let payload = att.record.slsa_statement_bytes().context("SLSA statement bytes")?;
let signer_ref: &dyn ArchiveSigner = &signer;
let env =
dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, &payload, &[signer_ref]).context("DSSE sign")?;
let art_sha = att.record.artifacts[0].sha256.clone();
let mut sealed = att;
sealed.dsse = Some(DsseSeal {
alg: "ed25519".to_string(),
signer_cn: signer_cn.to_string(),
payload_type: DSSE_PAYLOAD_TYPE_INTOTO.to_string(),
envelope_json: env.to_json().context("serialize DSSE envelope")?,
signer_certs: vec![signer_ref.certificate_der().to_vec()],
});
let json = sealed.to_json().context("serialize sealed attestation")?;
let (bytes, _report) =
create_archive_to_vec(&[(ATTESTATION_ENTRY.to_string(), json.into_bytes())], 3)
.context("pack sealed DSSE archive")?;
let blob = dir.join(format!("holger-full{SEALED_PROVENANCE_SUFFIX}"));
std::fs::write(&blob, &bytes).context("write sealed blob")?;
Ok((blob, ca_der, art_sha))
}
}
#[cfg(feature = "provenance-verify")]
pub use verify::{
verify_sealed_provenance, ProvenanceVerification, ATTESTATION_ENTRY, SEALED_PROVENANCE_SUFFIX,
VERIFIED_SLSA_LEVEL,
};
#[cfg(all(feature = "provenance-verify", any(test, feature = "test-support")))]
pub use verify::{seal_provenance_fixture, seal_provenance_fixture_dsse};
#[cfg(test)]
mod tests {
use super::*;
/// Real base64-encoded Rekor bodies (produced by an actual encoder, not hand-
/// written), so the pure-Rust decoder is exercised on genuine input. Both body
/// kinds are covered, and the two edges that decide whether this collector is
/// honest: an entry with NO inclusion proof is recorded as self-asserted rather
/// than trusted, and slsa_level stays 0 (log presence says nothing about how the
/// artifact was built).
#[test]
fn rekor_entries_decode_both_kinds_and_grade_the_evidence() {
let json = format!(
r#"{{
"uuid-1": {{ "body": "eyJhcGlWZXJzaW9uIjogIjAuMC4xIiwgImtpbmQiOiAiaGFzaGVkcmVrb3JkIiwgInNwZWMiOiB7ImRhdGEiOiB7Imhhc2giOiB7ImFsZ29yaXRobSI6ICJzaGEyNTYiLCAidmFsdWUiOiAiQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQkFCQUJBQiJ9fSwgInNpZ25hdHVyZSI6IHsiY29udGVudCI6ICJjMmxuIiwgInB1YmxpY0tleSI6IHsiaWRlbnRpdHkiOiAiaHR0cHM6Ly9naXRodWIuY29tL25vcmRpc2svbW9kZ3Vubi8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjEifX19fQ==", "integratedTime": 1700000000, "logIndex": 42,
"verification": {{ "inclusionProof": {{ "logIndex": 42 }},
"signedEntryTimestamp": "MEUCIQ==" }} }},
"uuid-2": {{ "body": "eyJhcGlWZXJzaW9uIjogIjAuMC4xIiwgImtpbmQiOiAiaW50b3RvIiwgInNwZWMiOiB7ImNvbnRlbnQiOiB7InBheWxvYWRIYXNoIjogeyJhbGdvcml0aG0iOiAic2hhMjU2IiwgInZhbHVlIjogImNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2RjZGNkY2QifSwgImVudmVsb3BlIjogeyJzaWduYXR1cmVzIjogW3sia2V5aWQiOiAiZnVsY2lvLWNpQGV4YW1wbGUifV19fX19", "integratedTime": 1700000001, "logIndex": 43,
"verification": {{}} }},
"uuid-3": {{ "body": "!!!not base64!!!", "logIndex": 44 }}
}}"#
);
let mut entries = parse_rekor_entries(&json).unwrap();
entries.sort_by_key(|e| e.log_index);
assert_eq!(
entries.len(),
2,
"the un-decodable body is SKIPPED, not landed with empty fields"
);
// ── hashedrekord: fully-evidenced ──
let hr = &entries[0];
assert_eq!(hr.kind, "hashedrekord");
assert_eq!(
hr.artifact_sha256,
"ab".repeat(32),
"digest lowercased for the join"
);
assert_eq!(hr.log_index, 42);
assert_eq!(hr.integrated_time, 1_700_000_000);
assert_eq!(
hr.signer_identity.as_deref(),
Some("https://github.com/nordisk/modgunn/.github/workflows/release.yml@refs/tags/v1")
);
assert!(hr.has_inclusion_proof && hr.has_signed_timestamp);
assert!(
hr.findings().is_empty(),
"a fully-evidenced entry is clean: {:?}",
hr.findings()
);
assert_eq!(
hr.attestation().slsa_level,
0,
"log presence is NOT a SLSA level — never an unearned claim"
);
assert_eq!(hr.attestation().signer_identity, hr.signer_identity);
// ── intoto: no verification block ⇒ self-asserted ──
let it = &entries[1];
assert_eq!(it.kind, "intoto");
assert_eq!(
it.artifact_sha256,
"cd".repeat(32),
"intoto digest read from content.payloadHash"
);
assert_eq!(it.signer_identity.as_deref(), Some("fulcio-ci@example"));
assert!(!it.has_inclusion_proof && !it.has_signed_timestamp);
let it_findings = it.findings();
let ids: Vec<&str> = it_findings.iter().map(|f| f.id.as_str()).collect();
assert_eq!(
ids,
vec![
"MODGUNN-REKOR-NO-INCLUSION-PROOF",
"MODGUNN-REKOR-NO-SIGNED-TIMESTAMP"
],
"an unproven entry is graded, not trusted"
);
assert_eq!(it_findings[0].severity, Severity::High);
}
/// The base64 decoder is the collector's trust boundary — a body that decodes
/// WRONG would parse into a plausible but false record. Truncated groups, data
/// after padding and stray characters all yield `None` rather than partial bytes.
#[test]
fn base64_decoder_rejects_malformed_input_rather_than_partially_decoding() {
assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
assert_eq!(base64_decode("aGVsbG8h").unwrap(), b"hello!");
assert_eq!(base64_decode("").unwrap(), Vec::<u8>::new());
assert_eq!(
base64_decode("aGVs bG8=\n").unwrap(),
b"hello",
"whitespace ignored"
);
assert_eq!(
base64_decode("a"),
None,
"a lone 6-bit group is truncated, not 0 bytes"
);
assert_eq!(
base64_decode("aGVsbG8=X"),
None,
"data after padding is malformed"
);
assert_eq!(
base64_decode("aGVs*G8="),
None,
"a stray character is rejected"
);
assert_eq!(base64_decode("!!!not base64!!!"), None);
// Non-zero trailing bits mean the encoder disagreed with us about the length.
assert_eq!(base64_decode("aGVsbG9="), None);
}
#[test]
fn missing_attestation_is_high_and_unsigned() {
let req = ProvenanceRequirement {
require_min_slsa: 2,
..Default::default()
};
let (prov, findings) = req.evaluate(None);
assert!(!prov.signed);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].id, "MODGUNN-PROV-NO-ATTESTATION");
}
#[test]
fn trusted_signer_at_required_slsa_passes_clean() {
let req = ProvenanceRequirement {
require_min_slsa: 2,
trusted_signers: vec!["nordisk-ci".into()],
expected_source_repo: Some("git.gunnar.rs/nordisk/x".into()),
// Pin the commit too — and prove case-insensitive matching (DEADBEEF).
expected_source_sha: Some("DEADBEEF".into()),
};
let att = Attestation {
signer_identity: Some("nordisk-ci".into()),
slsa_level: 3,
source_repo: Some("git.gunnar.rs/nordisk/x".into()),
source_sha: Some("deadbeef".into()),
};
let (prov, findings) = req.evaluate(Some(&att));
assert!(prov.signed, "clean attestation is treated as signed");
assert_eq!(prov.signer.as_deref(), Some("nordisk-ci"));
assert!(
findings.is_empty(),
"commit pin matches case-insensitively: {findings:?}"
);
}
#[test]
fn expected_commit_mismatch_and_absence_each_block() {
let req = ProvenanceRequirement {
expected_source_sha: Some("cafebabe".into()),
..Default::default()
};
// Wrong commit on the artifact → COMMIT-MISMATCH.
let wrong = Attestation {
signer_identity: Some("nordisk-ci".into()),
slsa_level: 2,
source_repo: Some("r".into()),
source_sha: Some("deadbeef".into()),
};
let (prov, findings) = req.evaluate(Some(&wrong));
assert!(!prov.signed, "a commit-pin miss fails the artifact");
assert!(
findings
.iter()
.any(|f| f.id == "MODGUNN-PROV-COMMIT-MISMATCH"),
"{findings:?}"
);
// No commit attested at all, but one is required → still MISMATCH (not silent).
let none = Attestation {
source_sha: None,
..wrong.clone()
};
let (_p, findings) = req.evaluate(Some(&none));
assert!(
findings
.iter()
.any(|f| f.id == "MODGUNN-PROV-COMMIT-MISMATCH"),
"{findings:?}"
);
}
#[test]
fn untrusted_signer_low_slsa_and_source_mismatch_each_flag() {
let req = ProvenanceRequirement {
require_min_slsa: 3,
trusted_signers: vec!["nordisk-ci".into()],
expected_source_repo: Some("good/repo".into()),
..Default::default()
};
let att = Attestation {
signer_identity: Some("attacker".into()),
slsa_level: 1,
source_repo: Some("evil/fork".into()),
source_sha: None,
};
let (prov, findings) = req.evaluate(Some(&att));
assert!(!prov.signed);
let ids: Vec<&str> = findings.iter().map(|f| f.id.as_str()).collect();
assert!(ids.contains(&"MODGUNN-PROV-SLSA-TOO-LOW"));
assert!(ids.contains(&"MODGUNN-PROV-UNTRUSTED-SIGNER"));
assert!(ids.contains(&"MODGUNN-PROV-SOURCE-MISMATCH"));
}
#[test]
fn attestation_delta_flags_signer_change_and_drop() {
let a = Attestation::signed_by("ci-a", 3);
let b = Attestation::signed_by("ci-b", 3);
let changed = attestation_delta(Some(&a), Some(&b));
assert_eq!(changed[0].id, "MODGUNN-PROV-SIGNER-CHANGED");
let dropped = attestation_delta(Some(&a), None);
assert_eq!(dropped[0].id, "MODGUNN-PROV-ATTESTATION-DROPPED");
assert_eq!(dropped[0].severity, Severity::High);
}
#[test]
fn attestation_delta_flags_commit_change_on_same_repo_only() {
let at = |sha: &str| Attestation {
signer_identity: Some("ci".into()),
slsa_level: 2,
source_repo: Some("nordisk/x".into()),
source_sha: Some(sha.into()),
};
// Same repo, different commit → COMMIT-CHANGED (a rebuild-from-elsewhere signal).
let ids: Vec<String> = attestation_delta(Some(&at("aaaa")), Some(&at("bbbb")))
.into_iter()
.map(|f| f.id)
.collect();
assert!(
ids.contains(&"MODGUNN-PROV-COMMIT-CHANGED".to_string()),
"{ids:?}"
);
assert!(
!ids.contains(&"MODGUNN-PROV-SOURCE-CHANGED".to_string()),
"repo held: {ids:?}"
);
// Same commit (case-insensitive) → no finding.
assert!(attestation_delta(Some(&at("ABCD")), Some(&at("abcd"))).is_empty());
// A repo move already carries SOURCE-CHANGED; the commit diff is suppressed as
// redundant noise (only the stronger repo-change signal fires).
let moved = Attestation {
source_repo: Some("attacker/fork".into()),
..at("bbbb")
};
let ids: Vec<String> = attestation_delta(Some(&at("aaaa")), Some(&moved))
.into_iter()
.map(|f| f.id)
.collect();
assert!(
ids.contains(&"MODGUNN-PROV-SOURCE-CHANGED".to_string()),
"{ids:?}"
);
assert!(
!ids.contains(&"MODGUNN-PROV-COMMIT-CHANGED".to_string()),
"no redundant commit noise: {ids:?}"
);
// First-time commit appearance (None → Some) is not a "change".
let first = Attestation {
source_sha: None,
..at("aaaa")
};
let ids: Vec<String> = attestation_delta(Some(&first), Some(&at("aaaa")))
.into_iter()
.map(|f| f.id)
.collect();
assert!(
!ids.contains(&"MODGUNN-PROV-COMMIT-CHANGED".to_string()),
"{ids:?}"
);
}
#[test]
fn scorecard_low_and_unmaintained_warn() {
let s = ScorecardSignals {
subject: "left-pad".into(),
overall: 3.2,
maintained: false,
branch_protection: false,
signed_releases: false,
dangerous_workflow: false,
};
let f = s.findings();
let ids: Vec<&str> = f.iter().map(|x| x.id.as_str()).collect();
assert!(ids.contains(&"MODGUNN-SCORECARD-LOW"));
assert!(ids.contains(&"MODGUNN-SCORECARD-UNMAINTAINED"));
}
/// RED-when-broken: a real OpenSSF Scorecard JSON report with a
/// `Dangerous-Workflow` check scored 0 (a dangerous CI pattern was found) MUST
/// fold to a **High** `MODGUNN-SCORECARD-DANGEROUS-WORKFLOW` finding, and a
/// failing `Branch-Protection` to a Low finding. If the score→severity mapping
/// (or Scorecard's inverted dangerous-workflow scale) is broken, the label /
/// severity assertion fires red.
#[test]
fn parse_scorecard_maps_dangerous_workflow_to_high() {
#[cfg(feature = "testmatrix")]
let _guard = crate::testmatrix_test_guard(); // serialize the shared drain buffer
#[cfg(feature = "testmatrix")]
let _ = nornir_testmatrix::drain_functional_rows(); // clean buffer
// Shape mirrors `scorecard --format json`: aggregate `score`, `repo.name`,
// and a `checks[]` of `{name, score, reason}` on Scorecard's 0..=10 scale
// (higher = better; -1 = inconclusive).
let json = r#"{
"date": "2026-07-14",
"repo": { "name": "github.com/acme/widget", "commit": "deadbeef" },
"scorecard": { "version": "v5.0.0" },
"score": 3.4,
"checks": [
{ "name": "Maintained", "score": 8, "reason": "30 commits in 90d" },
{ "name": "Branch-Protection", "score": 2, "reason": "no required reviews" },
{ "name": "Signed-Releases", "score": -1, "reason": "no releases" },
{ "name": "Dangerous-Workflow", "score": 0, "reason": "script injection in CI" }
]
}"#;
let s = parse_scorecard(json).unwrap();
assert_eq!(s.subject, "github.com/acme/widget");
assert_eq!(s.overall, 3.4);
assert!(s.maintained, "Maintained 8 ≥ 5 passes");
assert!(!s.branch_protection, "Branch-Protection 2 < 5 fails");
assert!(!s.signed_releases, "inconclusive (-1) is not a pass");
assert!(
s.dangerous_workflow,
"Dangerous-Workflow 0 ⇒ a dangerous pattern was found"
);
// The load-bearing severity/label mapping.
let dw = s
.findings()
.into_iter()
.find(|f| f.id == "MODGUNN-SCORECARD-DANGEROUS-WORKFLOW")
.expect("dangerous-workflow finding present");
assert_eq!(
dw.severity,
Severity::High,
"dangerous workflow is a High signal"
);
assert_eq!(
dw.severity.as_str(),
"High",
"canonical severity label consumers render"
);
let ids: Vec<String> = s.findings().into_iter().map(|f| f.id).collect();
assert!(
ids.contains(&"MODGUNN-SCORECARD-NO-BRANCH-PROTECTION".to_string()),
"{ids:?}"
);
#[cfg(feature = "testmatrix")]
{
use nornir_testmatrix::{drain_functional_rows, status};
let rows = drain_functional_rows();
let hit = rows
.iter()
.find(|r| r.suite == "modgunn/scorecard-ingest" && r.test_name == "parse_scorecard")
.unwrap_or_else(|| panic!("no scorecard ingest row: {rows:#?}"));
assert!(
status::is_green(&hit.status),
"parsed OK ⇒ GREEN row: {hit:#?}"
);
assert_eq!(
hit.aspect, "functional",
"ingest markers ride the `functional` aspect"
);
}
}
/// Guards the inverted Dangerous-Workflow scale (RED if the `< 10` comparison is
/// flipped): a clean report (score 10) must NOT emit the High finding.
#[test]
fn parse_scorecard_clean_workflow_not_flagged() {
let json = r#"{
"repo": { "name": "github.com/acme/safe" },
"score": 8.1,
"checks": [
{ "name": "Maintained", "score": 10 },
{ "name": "Branch-Protection", "score": 9 },
{ "name": "Signed-Releases", "score": 10 },
{ "name": "Dangerous-Workflow", "score": 10 }
]
}"#;
let s = parse_scorecard(json).unwrap();
assert!(!s.dangerous_workflow, "score 10 ⇒ no dangerous workflow");
assert!(
s.branch_protection && s.signed_releases && s.maintained,
"all high-signal checks pass"
);
let ids: Vec<String> = s.findings().into_iter().map(|f| f.id).collect();
assert!(
ids.is_empty(),
"a clean, well-scored project has no scorecard findings: {ids:?}"
);
}
}
// Round-trip / tamper tests for the real seal verifier. Seals a fixture exactly
// the way nornir does — an `nornir_build_thing` attestation, a detached CMS via
// `znippy_common::sign`, packed into a znippy archive — then verifies it back.
#[cfg(all(test, feature = "provenance-verify"))]
mod verify_tests {
use super::verify::{
verify_sealed_provenance, ATTESTATION_ENTRY, SEALED_PROVENANCE_SUFFIX, VERIFIED_SLSA_LEVEL,
};
use super::ProvenanceRequirement;
use nornir_build_thing::provenance::{
Attestation as SealedAttestation, BuildProvenance, HostInfo, Outcome, Seal, Toolchain,
};
use nornir_build_thing::variant::{BuildVariant, Profile};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use znippy_common::meta_sink_append::create_archive_to_vec;
use znippy_common::sign::dev::{mint_ca, new_ed25519_signer};
use znippy_common::sign::ArchiveSigner;
fn scratch_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("modgunn-prov-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Capture + seal a build-provenance for a fake `holger-full` artifact.
/// Returns `(scratch_dir, signed_attestation, ca_der, artifact_sha256, signer_cn)`.
fn seal_fixture(cn: &str) -> (PathBuf, SealedAttestation, Vec<u8>, String) {
let dir = scratch_dir("seal");
std::fs::create_dir_all(dir.join("target/release")).unwrap();
let art = dir.join("target/release/holger-full");
std::fs::write(&art, b"sealed holger-full bytes").unwrap();
let variant = BuildVariant {
name: "full".to_string(),
package: Some("holger-full".to_string()),
bin: Some("holger-full".to_string()),
features: vec!["full".to_string()],
profile: Profile::Release,
..Default::default()
};
let att = BuildProvenance::capture(
&variant,
"holger",
&dir,
Toolchain::default(),
HostInfo::default(),
Outcome::Success,
&[art],
)
.unwrap()
.attest()
.unwrap();
let (ca_key, ca_der) = mint_ca("modgunn-verify-test CA").unwrap();
let signer = new_ed25519_signer(&ca_key, &ca_der, cn).unwrap();
let cms = signer.sign_digest(&att.digest).unwrap();
let art_sha = att.record.artifacts[0].sha256.clone();
let mut sealed = att;
sealed.seal = Some(Seal {
alg: "ed25519".to_string(),
signer_cn: cn.to_string(),
cms_der: cms,
});
(dir, sealed, ca_der, art_sha)
}
/// Pack a sealed attestation into a `<name>.build-provenance.znippy` beside it —
/// the same one-entry (`attestation.json`) znippy archive nornir's seal writes.
fn pack(dir: &std::path::Path, name: &str, att: &SealedAttestation) -> PathBuf {
let json = att.to_json().unwrap();
let (bytes, _report) =
create_archive_to_vec(&[(ATTESTATION_ENTRY.to_string(), json.into_bytes())], 3)
.unwrap();
let path = dir.join(format!("{name}{SEALED_PROVENANCE_SUFFIX}"));
std::fs::write(&path, &bytes).unwrap();
path
}
#[test]
fn seal_then_verify_round_trip_ok() {
let (dir, sealed, ca_der, art_sha) = seal_fixture("nornir-build-signer");
let archive = pack(&dir, "holger-full", &sealed);
let v = verify_sealed_provenance(&archive, &[ca_der], Some(&art_sha));
assert!(v.verified, "expected verified, got: {}", v.reason);
assert_eq!(v.signer.as_deref(), Some("nornir-build-signer"));
assert_eq!(v.blob_sha256, art_sha);
assert_eq!(v.repo, "holger");
let att = v.attestation.expect("verified ⇒ attestation produced");
assert_eq!(att.slsa_level, VERIFIED_SLSA_LEVEL);
assert_eq!(att.signer_identity.as_deref(), Some("nornir-build-signer"));
assert_eq!(att.source_repo.as_deref(), Some("holger"));
std::fs::remove_dir_all(&dir).ok();
}
/// **The producer↔verifier agreement test.** nornir's DSSE mode seals a build
/// provenance as a DSSE envelope over the in-toto SLSA statement; modgunn — the
/// independent verifier that already reads a Rekor `content.envelope.signatures[]
/// .keyid` — must accept it end-to-end through the SAME `verify_sealed_provenance`
/// the CLI drives.
#[test]
fn dsse_sealed_provenance_verifies_end_to_end() {
let dir = scratch_dir("dsse");
let (archive, ca_der, art_sha) =
super::verify::seal_provenance_fixture_dsse(&dir, "nordisk-ci").unwrap();
let v = verify_sealed_provenance(&archive, &[ca_der.clone()], Some(&art_sha));
assert!(v.verified, "modgunn must verify our DSSE envelope, got: {}", v.reason);
assert_eq!(v.signer.as_deref(), Some("nordisk-ci"));
assert_eq!(v.blob_sha256, art_sha);
let att = v.attestation.expect("verified ⇒ attestation produced");
assert_eq!(att.slsa_level, VERIFIED_SLSA_LEVEL);
assert_eq!(att.source_repo.as_deref(), Some("holger"));
// RED — an untrusted root cannot chain the DSSE signer.
let (_rogue_key, rogue_ca) = mint_ca("rogue DSSE CA").unwrap();
let bad = verify_sealed_provenance(&archive, &[rogue_ca], Some(&art_sha));
assert!(!bad.verified, "an untrusted CA must be rejected for DSSE too");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tampered_record_fails_verification() {
let (dir, sealed, ca_der, _art_sha) = seal_fixture("nornir-build-signer");
// Mutate the record but keep the original (now-stale) seal + digest: the
// recomputed canonical digest no longer matches the sealed digest.
let mut tampered = sealed.clone();
tampered.record.inputs.vcs_rev = "deadbeefdeadbeef".to_string();
let archive = pack(&dir, "tampered", &tampered);
let v = verify_sealed_provenance(&archive, &[ca_der], None);
assert!(!v.verified, "tampered record must not verify");
assert!(
v.reason.contains("does not match") || v.reason.contains("tampered"),
"unexpected reason: {}",
v.reason
);
assert!(
v.attestation.is_none(),
"no attestation for a tampered blob"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn untrusted_root_is_rejected() {
let (dir, sealed, _ca_der, art_sha) = seal_fixture("nornir-build-signer");
let archive = pack(&dir, "holger-full", &sealed);
let (_rogue_key, rogue_ca) = mint_ca("rogue CA").unwrap();
let v = verify_sealed_provenance(&archive, &[rogue_ca], Some(&art_sha));
assert!(!v.verified, "an untrusted CA must be rejected");
assert!(
v.reason.contains("verification failed") || v.reason.contains("chain"),
"unexpected reason: {}",
v.reason
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn content_hash_mismatch_is_rejected() {
let (dir, sealed, ca_der, _art_sha) = seal_fixture("nornir-build-signer");
let archive = pack(&dir, "holger-full", &sealed);
// A genuine seal, but for an artifact whose content hash the provenance
// does not attest.
let wrong = "0".repeat(64);
let v = verify_sealed_provenance(&archive, &[ca_der], Some(&wrong));
assert!(!v.verified);
assert!(
v.reason.contains("not attested"),
"unexpected reason: {}",
v.reason
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn evaluate_sealed_scores_a_verified_attestation() {
let (dir, sealed, ca_der, art_sha) = seal_fixture("nordisk-ci");
let archive = pack(&dir, "holger-full", &sealed);
// A clean policy: require the level the seal attests, trust the signer, and
// pin the source repo — the verified attestation satisfies all three.
let req = ProvenanceRequirement {
require_min_slsa: VERIFIED_SLSA_LEVEL,
trusted_signers: vec!["nordisk-ci".to_string()],
expected_source_repo: Some("holger".to_string()),
// The seal's attested commit (the fixture's vcs_rev) — pinned exactly.
expected_source_sha: Some(sealed.record.inputs.vcs_rev.clone()),
};
let (prov, findings, v) = req.evaluate_sealed(&archive, &[ca_der.clone()], Some(&art_sha));
assert!(v.verified);
assert!(
prov.signed,
"a verified, policy-clean attestation is treated as signed"
);
assert_eq!(prov.signer.as_deref(), Some("nordisk-ci"));
assert!(
findings.is_empty(),
"clean policy ⇒ no findings, got {findings:?}"
);
// The unverifiable path never scores policy on assumed data: one clear finding.
let mut tampered = sealed.clone();
tampered.record.inputs.vcs_rev = "cafebabe".to_string();
let bad = pack(&dir, "tampered", &tampered);
let (prov2, findings2, v2) = req.evaluate_sealed(&bad, &[ca_der], None);
assert!(!v2.verified);
assert!(!prov2.signed);
assert_eq!(findings2.len(), 1);
assert_eq!(findings2[0].id, "MODGUNN-PROV-UNVERIFIABLE");
std::fs::remove_dir_all(&dir).ok();
}
/// A genuinely-verified seal that FAILS the A3 policy still fires the right
/// policy findings — the CLI `verify-provenance --min-slsa / --trusted-signer`
/// enforcement path. A merely-sealed blob attests only SLSA L2, so a policy
/// requiring L3 fires SLSA-TOO-LOW; a signer outside the trusted set fires
/// UNTRUSTED-SIGNER; and a mismatched --expected-repo fires SOURCE-MISMATCH.
#[test]
fn evaluate_sealed_flags_policy_shortfalls_on_a_verified_seal() {
let (dir, sealed, ca_der, art_sha) = seal_fixture("nornir-build-signer");
let archive = pack(&dir, "holger-full", &sealed);
// Require L3 (above the L2 a seal attests), trust only someone else, and pin
// a different source repo — all three fire against the genuinely-verified att.
let strict = ProvenanceRequirement {
require_min_slsa: 3,
trusted_signers: vec!["someone-else".to_string()],
expected_source_repo: Some("not-holger".to_string()),
// Pin a commit the seal does not attest → COMMIT-MISMATCH also fires.
expected_source_sha: Some("0000000000000000000000000000000000000000".to_string()),
};
let (prov, findings, v) =
strict.evaluate_sealed(&archive, &[ca_der.clone()], Some(&art_sha));
assert!(
v.verified,
"the seal itself verifies (policy is a separate layer)"
);
assert!(
!prov.signed,
"a policy-failing attestation is not treated as signed"
);
let ids: Vec<&str> = findings.iter().map(|f| f.id.as_str()).collect();
assert!(
ids.contains(&"MODGUNN-PROV-SLSA-TOO-LOW"),
"L3 unmet: {ids:?}"
);
assert!(
ids.contains(&"MODGUNN-PROV-UNTRUSTED-SIGNER"),
"untrusted signer: {ids:?}"
);
assert!(
ids.contains(&"MODGUNN-PROV-SOURCE-MISMATCH"),
"repo mismatch: {ids:?}"
);
assert!(
ids.contains(&"MODGUNN-PROV-COMMIT-MISMATCH"),
"commit pin unmet: {ids:?}"
);
// Only the min-SLSA knob: exactly SLSA-TOO-LOW, nothing else (the seal is L2).
let slsa_only = ProvenanceRequirement {
require_min_slsa: 3,
..Default::default()
};
let (_p, f2, _v) = slsa_only.evaluate_sealed(&archive, &[ca_der.clone()], Some(&art_sha));
let ids2: Vec<&str> = f2.iter().map(|f| f.id.as_str()).collect();
assert_eq!(
ids2,
vec!["MODGUNN-PROV-SLSA-TOO-LOW"],
"only the slsa knob fires: {ids2:?}"
);
// Only the trusted-signer knob, with the real signer trusted at L2 → clean.
let signer_ok = ProvenanceRequirement {
require_min_slsa: VERIFIED_SLSA_LEVEL,
trusted_signers: vec!["nornir-build-signer".to_string()],
..Default::default()
};
let (prov3, f3, _v) = signer_ok.evaluate_sealed(&archive, &[ca_der], Some(&art_sha));
assert!(
prov3.signed,
"trusted signer at the attested level passes clean"
);
assert!(f3.is_empty(), "no findings for a satisfied policy: {f3:?}");
std::fs::remove_dir_all(&dir).ok();
}
/// The `--blob-dir` batch verify: each sealed blob in a dir gets its OWN verdict
/// against the trusted root (the batch-discovery + per-blob verdict path the CLI
/// globs) — a blob signed by a different CA is independently rejected.
#[test]
fn batch_blobs_get_independent_per_blob_verdicts() {
let (dir, sealed_a, ca_der, _sha_a) = seal_fixture("nornir-build-signer");
let a = pack(&dir, "holger-full", &sealed_a);
// A second, independently-sealed artifact packed beside the first.
let (dir2, sealed_b, _ca_b, _sha_b) = seal_fixture("nornir-build-signer");
let b_bytes = std::fs::read(pack(&dir2, "other-artifact", &sealed_b)).unwrap();
let b = dir.join(format!("other-artifact{SEALED_PROVENANCE_SUFFIX}"));
std::fs::write(&b, &b_bytes).unwrap();
for blob in [&a, &b] {
let v = verify_sealed_provenance(blob, &[ca_der.clone()], None);
// `b` was signed by a DIFFERENT CA, so only `a` verifies against ca_der;
// this asserts the per-blob independence of the verdict rather than a
// shared trust decision.
if blob == &a {
assert!(
v.verified,
"blob a verifies against its own CA: {}",
v.reason
);
} else {
assert!(!v.verified, "blob b was signed by a different CA");
}
}
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&dir2).ok();
}
}