From 9718a85ebc22f81349356bbf436cb5853b9b2a14 Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 19:32:39 -0400 Subject: [PATCH 01/11] feat(ci): add pipeline receipt contract Establish a versioned, fail-closed receipt and aggregation boundary before workflow fan-in is wired. Unsigned v1 receipts cannot authorize promotion, and local filesystem fingerprint generation remains non-promotable. Refs #350 --- docs/pipeline-receipts.md | 49 ++ schemas/pipeline-shard-receipt/v1.schema.json | 19 + src/cli/verify.rs | 86 +++ src/main.rs | 12 + src/verification/fingerprint.rs | 114 ++++ src/verification/mod.rs | 12 + src/verification/path_validation.rs | 33 ++ src/verification/pipeline_receipt.rs | 500 ++++++++++++++++++ src/verification/policy.rs | 61 +++ tests/fixtures/pipeline-shard-receipt-v1.json | 19 + tests/pipeline_receipt_contract_tests.rs | 356 +++++++++++++ 11 files changed, 1261 insertions(+) create mode 100644 docs/pipeline-receipts.md create mode 100644 schemas/pipeline-shard-receipt/v1.schema.json create mode 100644 src/verification/fingerprint.rs create mode 100644 src/verification/path_validation.rs create mode 100644 src/verification/pipeline_receipt.rs create mode 100644 src/verification/policy.rs create mode 100644 tests/fixtures/pipeline-shard-receipt-v1.json create mode 100644 tests/pipeline_receipt_contract_tests.rs diff --git a/docs/pipeline-receipts.md b/docs/pipeline-receipts.md new file mode 100644 index 00000000..ce79522c --- /dev/null +++ b/docs/pipeline-receipts.md @@ -0,0 +1,49 @@ +# Pipeline shard receipts + +`PipelineShardReceipt` is the unsigned, target-scoped evidence contract used by CI +verification jobs. Its schema identifier is `pipeline-shard-receipt/v1`. +Receipts bind a repository commit and deterministic source and lock fingerprints +to an OS, architecture, toolchain, profile, feature set, and binding runtime. + +Validate one locally with: + +```text +sbom-tools verify receipt path/to/receipt.json +sbom-tools verify receipt-aggregate receipts/ --policy aggregate-policy.json +``` + +Receipts are unsigned and are non-promotion evidence; this slice rejects +`promotable: true`. Slice 5 must introduce signed promotion authority. +`source_fingerprint(root)` includes path-qualified file names and contents while +excluding `.git`, `target`, and `receipts` directories. `lock_fingerprint(root)` +uses the applicable Cargo, toolchain, binding, and Dagger lock inputs. Both +functions sort paths before hashing, so the result is reproducible across +filesystems. + +Receipts created from a local working tree must use `trust_context: "local"` and +`promotable: false`. Local receipts are useful for reproduction but cannot pass +a promotion gate. CI aggregation rejects missing or duplicate targets, source or +lock mismatches, failed/cancelled/skipped required checks, missing or digest- +invalid artifacts, and duplicate artifact identities. + +The receipt is an action result, not a dependency cache. Cache keys must remain +separate from receipt and artifact identities; cache contents are never accepted +as verification evidence. Workflow wiring and cryptographic signatures are +explicitly deferred until later slices. Workflow integrations should eventually +emit receipts with `if: always()` while preserving native OS execution and +existing status guards. + +Fingerprinting reads ordinary filesystem paths and has a documented local +mutation race: a file changed between directory enumeration and reading can +produce a fingerprint that does not describe one atomic filesystem snapshot. +The generator is deliberately local/non-promotable; aggregation trusts only +the externally supplied expected digests (they are policy inputs, not +recomputed by the aggregate command). + +The aggregate policy is JSON with exactly `expected_targets`, `context`, +`required_checks`, and `artifacts` fields; each field uses the corresponding +receipt contract shape. Unknown fields are rejected. I/O and malformed JSON are +operational errors; a readable receipt or policy that violates the contract is +a gate verdict and exits 1. Aggregate success, including an explicitly expected +`trust_context: "local"`, is verification only and never a promotion decision +in v1. diff --git a/schemas/pipeline-shard-receipt/v1.schema.json b/schemas/pipeline-shard-receipt/v1.schema.json new file mode 100644 index 00000000..415175a2 --- /dev/null +++ b/schemas/pipeline-shard-receipt/v1.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "pipeline-shard-receipt/v1", + "title": "PipelineShardReceipt", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repository", "workflow", "run_id", "commit_sha", "source_fingerprint", "trust_context", "promotable", "target", "lock_digest", "versions", "checks", "artifacts", "dagger_trace", "started_at", "completed_at", "failure_classification"], + "properties": { + "schema": {"const": "pipeline-shard-receipt/v1"}, "repository": {"type":"string","minLength":1}, "workflow": {"type":"string","minLength":1}, "run_id": {"type":["string","null"]}, "commit_sha": {"type":"string","pattern":"^[0-9a-f]{40,64}$"}, + "source_fingerprint": {"$ref":"#/$defs/digest"}, "lock_digest": {"$ref":"#/$defs/digest"}, "trust_context": {"enum":["pull-request","protected-main","release","local"]}, "promotable": {"const":false}, + "target": {"$ref":"#/$defs/target"}, "versions": {"type":"object","additionalProperties":{"type":"string"}}, "checks": {"type":"array","items":{"$ref":"#/$defs/check"},"minItems":1}, "artifacts": {"type":"array","items":{"$ref":"#/$defs/artifact"}}, "dagger_trace": {"type":["string","null"]}, "started_at": {"type":"string","format":"date-time"}, "completed_at": {"type":"string","format":"date-time"}, "failure_classification": {"type":["string","null"]} + }, + "$defs": { + "digest": {"type":"string","pattern":"^sha256:[0-9a-f]{64}$"}, + "target": {"type":"object","additionalProperties":false,"required":["os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string"}},"binding_runtime":{"type":["string","null"]}}}, + "check": {"type":"object","additionalProperties":false,"required":["name","outcome","passed","failed","ignored"],"properties":{"name":{"type":"string","minLength":1},"outcome":{"enum":["passed","failed","cancelled","skipped"]},"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"ignored":{"type":"integer","minimum":0}}}, + "artifact": {"type":"object","additionalProperties":false,"required":["name","path","size","sha256"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1},"size":{"type":"integer","minimum":0},"sha256":{"$ref":"#/$defs/digest"}}} + } +} diff --git a/src/cli/verify.rs b/src/cli/verify.rs index a70f10a3..4021b175 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -56,6 +56,14 @@ pub enum VerifyAction { )] format: String, }, + /// Validate a versioned pipeline shard receipt + Receipt { file: PathBuf }, + /// Aggregate receipts from a JSON file or directory using a strict policy JSON file. + ReceiptAggregate { + receipts: PathBuf, + #[arg(long)] + policy: PathBuf, + }, } /// Run the verify command. @@ -219,5 +227,83 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { Ok(exit_codes::SUCCESS) } } + VerifyAction::Receipt { file } => { + match crate::verification::check_receipt(&file) { + Ok(()) => {} + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt verification failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + } + if !quiet { + println!("Receipt valid: {}", file.display()); + } + Ok(exit_codes::SUCCESS) + } + VerifyAction::ReceiptAggregate { receipts, policy } => { + let policy = read_policy(&policy)?; + let receipt_paths = receipt_paths(&receipts)?; + let mut loaded = Vec::with_capacity(receipt_paths.len()); + for path in receipt_paths { + match crate::verification::read_receipt(&path) { + Ok(receipt) => loaded.push(receipt), + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt aggregation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + } + } + match crate::verification::aggregate_receipts(&loaded, &policy) { + Ok(result) => { + if !quiet { + println!( + "Receipts valid: {} ({} artifacts)", + result.receipt_count, result.artifact_count + ); + } + Ok(exit_codes::SUCCESS) + } + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt aggregation failed: {message}"); + Ok(exit_codes::CHANGES_DETECTED) + } + Err(error) => Err(error.into()), + } + } + } +} + +fn read_policy(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path)?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn receipt_paths(path: &std::path::Path) -> Result> { + if path.is_file() { + return Ok(vec![path.to_path_buf()]); + } + if !path.is_dir() { + anyhow::bail!( + "receipt input is not a file or directory: {}", + path.display() + ); + } + let mut paths = Vec::new(); + for entry in std::fs::read_dir(path)? { + let entry = entry?; + if entry.file_type()?.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") + { + paths.push(entry.path()); + } + } + paths.sort(); + if paths.is_empty() { + anyhow::bail!( + "receipt directory contains no JSON files: {}", + path.display() + ); } + Ok(paths) } diff --git a/src/main.rs b/src/main.rs index 7d34597c..4551850d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1343,6 +1343,14 @@ enum VerifyAction { )] format: TableJsonFormat, }, + /// Validate a versioned pipeline shard receipt + Receipt { file: PathBuf }, + /// Aggregate receipts from a JSON file or directory using a strict policy JSON file. + ReceiptAggregate { + receipts: PathBuf, + #[arg(long)] + policy: PathBuf, + }, } /// Arguments for the `license-check` subcommand @@ -2610,6 +2618,10 @@ fn run() -> Result<()> { model_dir, format: format.as_str().to_string(), }, + VerifyAction::Receipt { file } => cli::VerifyAction::Receipt { file }, + VerifyAction::ReceiptAggregate { receipts, policy } => { + cli::VerifyAction::ReceiptAggregate { receipts, policy } + } }; let exit_code = cli::run_verify(cli_action, cli.quiet)?; diff --git a/src/verification/fingerprint.rs b/src/verification/fingerprint.rs new file mode 100644 index 00000000..1371415c --- /dev/null +++ b/src/verification/fingerprint.rs @@ -0,0 +1,114 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use super::{ + path_validation::validate_relative_path, + pipeline_receipt::{ReceiptError, Sha256Digest}, +}; + +/// Compute a deterministic digest of source files, excluding generated and receipt directories. +pub fn source_fingerprint(root: &Path) -> Result { + let mut files = Vec::new(); + collect_source_files(root, root, &mut files)?; + fingerprint_files(root, files) +} + +/// Compute a deterministic digest of explicitly selected lock files. +pub fn lock_fingerprint(root: &Path, paths: &[PathBuf]) -> Result { + if paths.is_empty() { + return Err(ReceiptError::Contract( + "lock input list must not be empty".into(), + )); + } + let mut files = Vec::new(); + for path in paths { + let value = path + .to_str() + .ok_or_else(|| ReceiptError::Contract("lock path must be UTF-8".into()))?; + validate_relative_path(value, "lock path")?; + let full = root.join(path); + let meta = fs::symlink_metadata(&full).map_err(|source| ReceiptError::Io { + path: full.clone(), + source, + })?; + if meta.file_type().is_symlink() || !meta.is_file() { + return Err(ReceiptError::Contract( + "lock input must be a regular file".into(), + )); + } + files.push(full); + } + fingerprint_files(root, files) +} + +fn collect_source_files( + root: &Path, + dir: &Path, + out: &mut Vec, +) -> Result<(), ReceiptError> { + for entry in fs::read_dir(dir).map_err(|source| ReceiptError::Io { + path: dir.into(), + source, + })? { + let path = entry + .map_err(|source| ReceiptError::Io { + path: dir.into(), + source, + })? + .path(); + let rel = path + .strip_prefix(root) + .map_err(|_| ReceiptError::Contract("path escaped root".into()))?; + if rel.components().any(|component| { + matches!( + component.as_os_str().to_str(), + Some(".git" | "target" | "receipts") + ) + }) { + continue; + } + let metadata = fs::symlink_metadata(&path).map_err(|source| ReceiptError::Io { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(ReceiptError::Contract("symlink in source tree".into())); + } + if rel.to_str().is_none_or(|value| value.is_empty()) { + return Err(ReceiptError::Contract("non-UTF8 source path".into())); + } + if metadata.is_dir() { + collect_source_files(root, &path, out)?; + } else if metadata.is_file() { + out.push(path); + } + } + Ok(()) +} + +fn fingerprint_files(root: &Path, mut files: Vec) -> Result { + files.sort_by(|a, b| { + a.strip_prefix(root) + .map_or(Path::new(""), |path| path) + .cmp(b.strip_prefix(root).map_or(Path::new(""), |path| path)) + }); + let mut input = Vec::new(); + for path in files { + let rel = path + .strip_prefix(root) + .map_err(|_| ReceiptError::Contract("path escaped root".into()))? + .to_str() + .ok_or_else(|| ReceiptError::Contract("non-UTF8 source path".into()))?; + let bytes = fs::read(&path).map_err(|source| ReceiptError::Io { + path: path.clone(), + source, + })?; + input.extend_from_slice(&(rel.len() as u64).to_be_bytes()); + input.extend_from_slice(rel.as_bytes()); + input.extend_from_slice(&(bytes.len() as u64).to_be_bytes()); + input.extend_from_slice(&bytes); + } + Ok(Sha256Digest::from_bytes(&input)) +} diff --git a/src/verification/mod.rs b/src/verification/mod.rs index c3a7ef90..67736655 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -4,13 +4,25 @@ //! and SBOM signature/provenance checking. mod audit; +mod fingerprint; mod hash; mod model_dir; +mod path_validation; +pub mod pipeline_receipt; +mod policy; pub use audit::{HashAuditReport, HashAuditResult, audit_component_hashes}; +pub use fingerprint::{lock_fingerprint, source_fingerprint}; pub use hash::{ HashError, HashVerifyResult, compute_file_sha256, read_hash_file, verify_file_hash, }; pub use model_dir::{ ComponentModelVerification, ModelVerifyReport, ModelVerifyResult, verify_model_dir, }; +pub use pipeline_receipt::{ + AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, + PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, ReceiptArtifact, ReceiptError, + ReceiptInput, Sha256Digest, TargetIdentity, TrustContext, TrustedArtifact, VerificationCheck, + aggregate_receipts, check_receipt, generate_receipt, read_receipt, validate_receipt, + write_receipt, +}; diff --git a/src/verification/path_validation.rs b/src/verification/path_validation.rs new file mode 100644 index 00000000..bd3ef6dc --- /dev/null +++ b/src/verification/path_validation.rs @@ -0,0 +1,33 @@ +use std::path::{Component, Path}; + +use super::pipeline_receipt::ReceiptError; + +/// Validate a manifest path is a non-empty, UTF-8, relative path without traversal. +pub(crate) fn validate_relative_path(value: &str, label: &str) -> Result<(), ReceiptError> { + let path = Path::new(value); + if value.is_empty() + || value.contains('\\') + || value.starts_with("//") + || (value.len() >= 2 && value.as_bytes()[1] == b':') + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(ReceiptError::Contract(format!( + "{label} must be a safe relative path" + ))); + } + if path + .components() + .any(|component| matches!(component, Component::CurDir)) + { + return Err(ReceiptError::Contract(format!( + "{label} must not contain '.' components" + ))); + } + Ok(()) +} diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs new file mode 100644 index 00000000..23d16faa --- /dev/null +++ b/src/verification/pipeline_receipt.rs @@ -0,0 +1,500 @@ +//! Unsigned, target-scoped verification receipts. +use super::{ + fingerprint::{lock_fingerprint, source_fingerprint}, + path_validation::validate_relative_path, + policy::validate_policy, +}; +use chrono::{DateTime, FixedOffset}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; +use thiserror::Error; +pub const PIPELINE_SHARD_RECEIPT_SCHEMA: &str = "pipeline-shard-receipt/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Sha256Digest(String); +impl Sha256Digest { + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref(); + let hex = value + .strip_prefix("sha256:") + .ok_or_else(|| ReceiptError::InvalidDigest(value.into()))?; + if hex.len() != 64 + || !hex.bytes().all(|b| b.is_ascii_hexdigit()) + || hex != hex.to_ascii_lowercase() + { + return Err(ReceiptError::InvalidDigest(value.into())); + } + Ok(Self(value.into())) + } + pub fn from_bytes(bytes: &[u8]) -> Self { + let mut h = Sha256::new(); + h.update(bytes); + let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect(); + Self(format!("sha256:{hex}")) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl Serialize for Sha256Digest { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.0) + } +} +impl<'de> Deserialize<'de> for Sha256Digest { + fn deserialize>(d: D) -> Result { + Self::new(String::deserialize(d)?).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Error)] +pub enum ReceiptError { + #[error("invalid sha256 digest: {0}")] + InvalidDigest(String), + #[error("receipt I/O failed for {path}: {source}")] + Io { + path: PathBuf, + source: std::io::Error, + }, + #[error("receipt JSON failed for {path}: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + #[error("receipt contract violation: {0}")] + Contract(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TrustContext { + PullRequest, + ProtectedMain, + Release, + Local, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TargetIdentity { + pub os: String, + pub architecture: String, + pub toolchain: String, + pub profile: String, + pub features: Vec, + pub binding_runtime: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckOutcome { + Passed, + Failed, + Cancelled, + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VerificationCheck { + pub name: String, + pub outcome: CheckOutcome, + pub passed: u64, + pub failed: u64, + pub ignored: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReceiptArtifact { + pub name: String, + pub path: String, + pub size: u64, + pub sha256: Sha256Digest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PipelineShardReceipt { + pub schema: String, + pub repository: String, + pub workflow: String, + pub run_id: Option, + pub commit_sha: String, + pub source_fingerprint: Sha256Digest, + pub trust_context: TrustContext, + pub promotable: bool, + pub target: TargetIdentity, + pub lock_digest: Sha256Digest, + pub versions: BTreeMap, + pub checks: Vec, + pub artifacts: Vec, + pub dagger_trace: Option, + pub started_at: String, + pub completed_at: String, + pub failure_classification: Option, +} + +pub struct ReceiptInput { + pub repository: String, + pub workflow: String, + pub run_id: Option, + pub commit_sha: String, + pub trust_context: TrustContext, + pub promotable: bool, + pub target: TargetIdentity, + pub source_root: PathBuf, + pub lock_paths: Vec, + pub versions: BTreeMap, + pub checks: Vec, + pub artifacts: Vec, + pub dagger_trace: Option, + pub started_at: String, + pub completed_at: String, + pub failure_classification: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedContext { + pub repository: String, + pub workflow: String, + pub commit_sha: String, + pub trust_context: TrustContext, + pub promotable: bool, + pub source_fingerprint: Sha256Digest, + pub lock_digest: Sha256Digest, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AggregatePolicy { + pub expected_targets: Vec, + pub context: ExpectedContext, + pub required_checks: Vec, + pub artifacts: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrustedArtifact { + pub name: String, + pub path: String, + pub size: u64, + pub sha256: Sha256Digest, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AggregateVerification { + pub receipt_count: usize, + pub artifact_count: usize, +} + +pub fn validate_receipt(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { + if r.schema != PIPELINE_SHARD_RECEIPT_SCHEMA { + return Err(ReceiptError::Contract("unsupported schema".into())); + } + if r.repository.is_empty() + || r.workflow.is_empty() + || r.target.os.is_empty() + || r.target.architecture.is_empty() + || r.target.toolchain.is_empty() + || r.target.profile.is_empty() + { + return Err(ReceiptError::Contract( + "required identity field is empty".into(), + )); + } + validate_target(&r.target)?; + for artifact in &r.artifacts { + if artifact.name.is_empty() { + return Err(ReceiptError::Contract( + "artifact names must be nonempty".into(), + )); + } + validate_relative_path(&artifact.path, "artifact path")?; + } + validate_commit(&r.commit_sha)?; + validate_times(&r.started_at, &r.completed_at)?; + if r.promotable { + return Err(ReceiptError::Contract( + "unsigned receipts cannot be promotable; signed promotion authority is deferred".into(), + )); + } + let mut names = BTreeSet::new(); + for check in &r.checks { + if check.name.is_empty() || !names.insert(&check.name) { + return Err(ReceiptError::Contract( + "check names must be unique and nonempty".into(), + )); + } + validate_check(check)?; + } + if r.checks.is_empty() { + return Err(ReceiptError::Contract( + "at least one check is required".into(), + )); + } + let failed = r + .checks + .iter() + .any(|c| matches!(c.outcome, CheckOutcome::Failed | CheckOutcome::Cancelled)); + if failed != r.failure_classification.is_some() { + return Err(ReceiptError::Contract( + "failure classification disagrees with check outcomes".into(), + )); + } + Ok(()) +} +fn validate_target(target: &TargetIdentity) -> Result<(), ReceiptError> { + if target.os.is_empty() + || target.architecture.is_empty() + || target.toolchain.is_empty() + || target.profile.is_empty() + { + return Err(ReceiptError::Contract( + "target identity fields must be nonempty".into(), + )); + } + Ok(()) +} +fn validate_commit(value: &str) -> Result<(), ReceiptError> { + if !(40..=64).contains(&value.len()) + || !value.bytes().all(|b| b.is_ascii_hexdigit()) + || value != value.to_ascii_lowercase() + { + return Err(ReceiptError::Contract( + "commit_sha must be lowercase hexadecimal SHA".into(), + )); + } + Ok(()) +} +fn validate_times(start: &str, end: &str) -> Result<(), ReceiptError> { + let a = DateTime::::parse_from_rfc3339(start) + .map_err(|_| ReceiptError::Contract("started_at must be RFC3339".into()))?; + let b = DateTime::::parse_from_rfc3339(end) + .map_err(|_| ReceiptError::Contract("completed_at must be RFC3339".into()))?; + if b < a { + return Err(ReceiptError::Contract( + "completed_at precedes started_at".into(), + )); + } + Ok(()) +} +fn validate_check(c: &VerificationCheck) -> Result<(), ReceiptError> { + if c.passed + .checked_add(c.failed) + .and_then(|n| n.checked_add(c.ignored)) + == Some(0) + { + return Err(ReceiptError::Contract(format!( + "check {} has no recorded cases", + c.name + ))); + } + if matches!(c.outcome, CheckOutcome::Passed) && c.failed > 0 { + return Err(ReceiptError::Contract(format!( + "passed check {} reports failures", + c.name + ))); + } + match c.outcome { + CheckOutcome::Failed | CheckOutcome::Cancelled if c.failed == 0 => { + return Err(ReceiptError::Contract(format!( + "failed check {} reports no failures", + c.name + ))); + } + CheckOutcome::Skipped if c.failed != 0 || c.passed != 0 => { + return Err(ReceiptError::Contract(format!( + "skipped check {} reports passed or failed cases", + c.name + ))); + } + _ => {} + } + Ok(()) +} + +pub fn write_receipt(path: &Path, receipt: &PipelineShardReceipt) -> Result<(), ReceiptError> { + validate_receipt(receipt)?; + let bytes = serde_json::to_vec_pretty(receipt).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + fs::write(path, bytes).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + }) +} +pub fn read_receipt(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + let r = serde_json::from_slice(&bytes).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + validate_receipt(&r)?; + Ok(r) +} +pub fn check_receipt(path: &Path) -> Result<(), ReceiptError> { + read_receipt(path).map(|_| ()) +} + +pub fn generate_receipt(input: ReceiptInput) -> Result { + let receipt = PipelineShardReceipt { + schema: PIPELINE_SHARD_RECEIPT_SCHEMA.into(), + repository: input.repository, + workflow: input.workflow, + run_id: input.run_id, + commit_sha: input.commit_sha, + source_fingerprint: source_fingerprint(&input.source_root)?, + trust_context: input.trust_context, + promotable: input.promotable, + target: input.target, + lock_digest: lock_fingerprint(&input.source_root, &input.lock_paths)?, + versions: input.versions, + checks: input.checks, + artifacts: input.artifacts, + dagger_trace: input.dagger_trace, + started_at: input.started_at, + completed_at: input.completed_at, + failure_classification: input.failure_classification, + }; + validate_receipt(&receipt)?; + Ok(receipt) +} + +pub fn aggregate_receipts( + receipts: &[PipelineShardReceipt], + policy: &AggregatePolicy, +) -> Result { + validate_policy(policy)?; + if receipts.len() != policy.expected_targets.len() { + return Err(ReceiptError::Contract( + "receipt count does not match expected targets".into(), + )); + } + validate_expected_targets(policy)?; + validate_trusted_artifacts(policy)?; + let (artifact_ids, trusted_ids) = verify_receipt_set(receipts, policy)?; + if trusted_ids.len() != policy.artifacts.len() { + return Err(ReceiptError::Contract("required artifact missing".into())); + } + Ok(AggregateVerification { + receipt_count: receipts.len(), + artifact_count: artifact_ids.len(), + }) +} + +fn validate_expected_targets(policy: &AggregatePolicy) -> Result<(), ReceiptError> { + let mut expected_ids = BTreeSet::new(); + for target in &policy.expected_targets { + validate_target(target)?; + let id = + serde_json::to_string(target).map_err(|e| ReceiptError::Contract(e.to_string()))?; + if !expected_ids.insert(id) { + return Err(ReceiptError::Contract("duplicate expected target".into())); + } + } + Ok(()) +} + +fn validate_trusted_artifacts(policy: &AggregatePolicy) -> Result<(), ReceiptError> { + let mut trusted_ids = BTreeSet::new(); + for artifact in &policy.artifacts { + validate_relative_path(&artifact.path, "trusted artifact path")?; + let id = format!("{}:{}", artifact.name, artifact.path); + if !trusted_ids.insert(id) { + return Err(ReceiptError::Contract( + "duplicate trusted artifact identity".into(), + )); + } + } + Ok(()) +} + +fn verify_receipt_set( + receipts: &[PipelineShardReceipt], + policy: &AggregatePolicy, +) -> Result<(BTreeSet, BTreeSet), ReceiptError> { + let context = &policy.context; + let mut seen = BTreeSet::new(); + let mut artifact_ids = BTreeSet::new(); + let mut trusted_ids = BTreeSet::new(); + for r in receipts { + validate_receipt(r)?; + compare_context(r, context)?; + let id = + serde_json::to_string(&r.target).map_err(|e| ReceiptError::Contract(e.to_string()))?; + if !seen.insert(id) || !policy.expected_targets.contains(&r.target) { + return Err(ReceiptError::Contract( + "duplicate or unexpected target".into(), + )); + } + if r.checks.iter().any(|check| { + matches!( + check.outcome, + CheckOutcome::Failed | CheckOutcome::Cancelled + ) + }) { + return Err(ReceiptError::Contract( + "receipt contains a failed or cancelled check".into(), + )); + } + verify_checks(r, &policy.required_checks)?; + compare_artifacts(r, &policy.artifacts, &mut artifact_ids, &mut trusted_ids)?; + } + Ok((artifact_ids, trusted_ids)) +} +fn compare_context(r: &PipelineShardReceipt, e: &ExpectedContext) -> Result<(), ReceiptError> { + if r.repository != e.repository + || r.workflow != e.workflow + || r.commit_sha != e.commit_sha + || r.trust_context != e.trust_context + || r.promotable != e.promotable + || r.source_fingerprint != e.source_fingerprint + || r.lock_digest != e.lock_digest + { + return Err(ReceiptError::Contract("receipt context mismatch".into())); + } + Ok(()) +} + +fn verify_checks(r: &PipelineShardReceipt, required: &[String]) -> Result<(), ReceiptError> { + for name in required { + let matches: Vec<_> = r.checks.iter().filter(|c| &c.name == name).collect(); + if matches.len() != 1 || !matches!(matches[0].outcome, CheckOutcome::Passed) { + return Err(ReceiptError::Contract(format!( + "required check {name} did not pass" + ))); + } + } + Ok(()) +} +fn compare_artifacts( + r: &PipelineShardReceipt, + trusted: &[TrustedArtifact], + ids: &mut BTreeSet, + trusted_ids: &mut BTreeSet, +) -> Result<(), ReceiptError> { + for claim in &r.artifacts { + let id = format!("{}:{}", claim.name, claim.path); + if !ids.insert(id.clone()) { + return Err(ReceiptError::Contract("duplicate artifact identity".into())); + } + let expected = trusted + .iter() + .find(|a| a.name == claim.name && a.path == claim.path) + .ok_or_else(|| ReceiptError::Contract("unexpected artifact".into()))?; + trusted_ids.insert(id.clone()); + if expected.size != claim.size || expected.sha256 != claim.sha256 { + return Err(ReceiptError::Contract("artifact manifest mismatch".into())); + } + } + Ok(()) +} diff --git a/src/verification/policy.rs b/src/verification/policy.rs new file mode 100644 index 00000000..9b9f7248 --- /dev/null +++ b/src/verification/policy.rs @@ -0,0 +1,61 @@ +use super::pipeline_receipt::{AggregatePolicy, ReceiptError}; +use std::collections::BTreeSet; + +pub(crate) fn validate_policy(policy: &AggregatePolicy) -> Result<(), ReceiptError> { + let context = &policy.context; + if policy.expected_targets.is_empty() || policy.required_checks.is_empty() { + return Err(ReceiptError::Contract( + "aggregate policy targets and required checks must be nonempty".into(), + )); + } + if context.promotable { + return Err(ReceiptError::Contract( + "unsigned receipt policies cannot be promotable".into(), + )); + } + if context.repository.is_empty() || context.workflow.is_empty() { + return Err(ReceiptError::Contract( + "expected context identity is empty".into(), + )); + } + if !(40..=64).contains(&context.commit_sha.len()) + || !context.commit_sha.bytes().all(|b| b.is_ascii_hexdigit()) + || context.commit_sha != context.commit_sha.to_ascii_lowercase() + { + return Err(ReceiptError::Contract( + "expected commit_sha is invalid".into(), + )); + } + let mut checks = BTreeSet::new(); + if policy + .required_checks + .iter() + .any(|name| name.is_empty() || !checks.insert(name)) + { + return Err(ReceiptError::Contract( + "required check names must be unique and nonempty".into(), + )); + } + for target in &policy.expected_targets { + let mut features = BTreeSet::new(); + if target + .features + .iter() + .any(|feature| feature.is_empty() || !features.insert(feature)) + { + return Err(ReceiptError::Contract( + "target feature names must be unique and nonempty".into(), + )); + } + } + if policy + .artifacts + .iter() + .any(|artifact| artifact.name.is_empty()) + { + return Err(ReceiptError::Contract( + "trusted artifact name must be nonempty".into(), + )); + } + Ok(()) +} diff --git a/tests/fixtures/pipeline-shard-receipt-v1.json b/tests/fixtures/pipeline-shard-receipt-v1.json new file mode 100644 index 00000000..f348feca --- /dev/null +++ b/tests/fixtures/pipeline-shard-receipt-v1.json @@ -0,0 +1,19 @@ +{ + "schema": "pipeline-shard-receipt/v1", + "repository": "sbom-tool/sbom-tools", + "workflow": "CI", + "run_id": "local-fixture", + "commit_sha": "0000000000000000000000000000000000000000", + "source_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "trust_context": "local", + "promotable": false, + "target": {"os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null}, + "lock_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "versions": {"rust":"1.88"}, + "checks": [{"name":"cargo-test","outcome":"passed","passed":1,"failed":0,"ignored":0}], + "artifacts": [], + "dagger_trace": null, + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:01:00Z", + "failure_classification": null +} diff --git a/tests/pipeline_receipt_contract_tests.rs b/tests/pipeline_receipt_contract_tests.rs new file mode 100644 index 00000000..29ef5374 --- /dev/null +++ b/tests/pipeline_receipt_contract_tests.rs @@ -0,0 +1,356 @@ +use sbom_tools::verification::*; +use std::{collections::BTreeMap, fs, path::PathBuf, process::Command}; + +fn digest(byte: u8) -> Sha256Digest { + Sha256Digest::new(format!( + "sha256:{}", + char::from(byte).to_string().repeat(64) + )) + .unwrap() +} +fn target() -> TargetIdentity { + TargetIdentity { + os: "linux".into(), + architecture: "x86_64".into(), + toolchain: "stable".into(), + profile: "release".into(), + features: vec![], + binding_runtime: None, + } +} +fn receipt(source: Sha256Digest, lock: Sha256Digest) -> PipelineShardReceipt { + PipelineShardReceipt { + schema: PIPELINE_SHARD_RECEIPT_SCHEMA.into(), + repository: "org/repo".into(), + workflow: "ci".into(), + run_id: None, + commit_sha: "a".repeat(40), + source_fingerprint: source, + trust_context: TrustContext::ProtectedMain, + promotable: false, + target: target(), + lock_digest: lock, + versions: BTreeMap::new(), + checks: vec![VerificationCheck { + name: "build".into(), + outcome: CheckOutcome::Passed, + passed: 1, + failed: 0, + ignored: 0, + }], + artifacts: vec![], + dagger_trace: None, + started_at: "2026-01-01T00:00:00Z".into(), + completed_at: "2026-01-01T00:01:00Z".into(), + failure_classification: None, + } +} +fn policy(source: Sha256Digest, lock: Sha256Digest) -> AggregatePolicy { + AggregatePolicy { + expected_targets: vec![target()], + context: ExpectedContext { + repository: "org/repo".into(), + workflow: "ci".into(), + commit_sha: "a".repeat(40), + trust_context: TrustContext::ProtectedMain, + promotable: false, + source_fingerprint: source, + lock_digest: lock, + }, + required_checks: vec!["build".into()], + artifacts: vec![], + } +} + +#[test] +fn aggregate_success_and_source_mismatch() { + let s = digest(b'a'); + let l = digest(b'b'); + assert!( + aggregate_receipts( + &[receipt(s.clone(), l.clone())], + &policy(s.clone(), l.clone()) + ) + .is_ok() + ); + assert!(aggregate_receipts(&[receipt(digest(b'c'), l.clone())], &policy(s, l)).is_err()); +} +#[test] +fn aggregate_rejects_lock_context_and_promotable_mismatch() { + let s = digest(b'a'); + let l = digest(b'b'); + assert!( + aggregate_receipts( + &[receipt(s.clone(), digest(b'c'))], + &policy(s.clone(), l.clone()) + ) + .is_err() + ); + let mut p = policy(s, l); + p.context.repository = "other".into(); + assert!(aggregate_receipts(&[receipt(digest(b'a'), digest(b'b'))], &p).is_err()); + let mut r = receipt(digest(b'a'), digest(b'b')); + r.promotable = true; + assert!(validate_receipt(&r).is_err()); + let mut p = policy(digest(b'a'), digest(b'b')); + p.context.promotable = true; + p.artifacts.push(TrustedArtifact { + name: "artifact".into(), + path: "a.bin".into(), + size: 1, + sha256: digest(b'c'), + }); + assert!(aggregate_receipts(&[receipt(digest(b'a'), digest(b'b'))], &p).is_err()); +} + +#[test] +fn schema_forbids_unsigned_promotable_receipts() { + let schema = include_str!("../schemas/pipeline-shard-receipt/v1.schema.json"); + assert!(schema.contains("\"promotable\": {\"const\":false}")); +} +#[test] +fn policy_rejects_duplicate_checks_features_targets_and_artifacts() { + let s = digest(b'a'); + let l = digest(b'b'); + let mut p = policy(s.clone(), l.clone()); + p.required_checks.push("build".into()); + assert!(aggregate_receipts(&[receipt(s.clone(), l.clone())], &p).is_err()); + let mut p = policy(s.clone(), l.clone()); + p.expected_targets[0].features = vec!["x".into(), "x".into()]; + assert!(aggregate_receipts(&[receipt(s.clone(), l.clone())], &p).is_err()); + let mut p = policy(s.clone(), l.clone()); + p.expected_targets.push(target()); + assert!(aggregate_receipts(&[receipt(s.clone(), l.clone())], &p).is_err()); + let a = TrustedArtifact { + name: "".into(), + path: "a.bin".into(), + size: 0, + sha256: digest(b'c'), + }; + let mut p = policy(s, l); + p.artifacts = vec![a.clone(), a]; + assert!(aggregate_receipts(&[receipt(digest(b'a'), digest(b'b'))], &p).is_err()); +} +#[test] +fn receipt_rejects_unknown_fields_and_invalid_values() { + let mut value = serde_json::to_value(receipt(digest(b'a'), digest(b'b'))).unwrap(); + value["unexpected"] = serde_json::Value::Bool(true); + assert!(serde_json::from_value::(value).is_err()); + assert!(Sha256Digest::new("sha256:AA").is_err()); + let mut r = receipt(digest(b'a'), digest(b'b')); + r.commit_sha = "not-a-sha".into(); + assert!(validate_receipt(&r).is_err()); + r = receipt(digest(b'a'), digest(b'b')); + r.completed_at = "2025-01-01T00:00:00Z".into(); + assert!(validate_receipt(&r).is_err()); +} +#[test] +fn receipt_rejects_required_failed_cancelled_skipped_and_duplicate_checks() { + for outcome in [ + CheckOutcome::Failed, + CheckOutcome::Cancelled, + CheckOutcome::Skipped, + ] { + let mut r = receipt(digest(b'a'), digest(b'b')); + r.checks[0].outcome = outcome; + r.checks[0].failed = if outcome == CheckOutcome::Skipped { + 0 + } else { + 1 + }; + r.failure_classification = (outcome != CheckOutcome::Skipped).then_some("test".into()); + assert!(aggregate_receipts(&[r], &policy(digest(b'a'), digest(b'b'))).is_err()); + } + let mut r = receipt(digest(b'a'), digest(b'b')); + r.checks.push(r.checks[0].clone()); + assert!(validate_receipt(&r).is_err()); +} +#[test] +fn fingerprints_are_deterministic_and_reject_empty_or_unsafe_lock_inputs() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a"), b"one").unwrap(); + let first = source_fingerprint(dir.path()).unwrap(); + let second = source_fingerprint(dir.path()).unwrap(); + assert_eq!(first, second); + fs::write(dir.path().join("a"), b"two").unwrap(); + assert_ne!(first, source_fingerprint(dir.path()).unwrap()); + assert!(lock_fingerprint(dir.path(), &[]).is_err()); + assert!(lock_fingerprint(dir.path(), &[PathBuf::from("../a")]).is_err()); + assert!(lock_fingerprint(dir.path(), &[PathBuf::from("/tmp/a")]).is_err()); +} +#[cfg(unix)] +#[test] +fn source_fingerprint_rejects_symlinks() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a"), b"one").unwrap(); + std::os::unix::fs::symlink("a", dir.path().join("link")).unwrap(); + assert!(source_fingerprint(dir.path()).is_err()); +} + +#[test] +fn relative_paths_reject_backslash_drive_unc_and_vacuous_values() { + for value in ["a\\b", "C:/artifact", "//server/share", "", ".", "../a"] { + let mut r = receipt(digest(b'a'), digest(b'b')); + r.artifacts = vec![ReceiptArtifact { + name: "artifact".into(), + path: value.into(), + size: 1, + sha256: digest(b'c'), + }]; + assert!(validate_receipt(&r).is_err(), "path {value:?} accepted"); + } + let mut p = policy(digest(b'a'), digest(b'b')); + p.required_checks = vec![]; + assert!(aggregate_receipts(&[receipt(digest(b'a'), digest(b'b'))], &p).is_err()); + p = policy(digest(b'a'), digest(b'b')); + p.expected_targets = vec![]; + assert!(aggregate_receipts(&[receipt(digest(b'a'), digest(b'b'))], &p).is_err()); +} + +#[test] +fn receipt_rejects_empty_artifact_name() { + let mut r = receipt(digest(b'a'), digest(b'b')); + r.artifacts.push(ReceiptArtifact { + name: String::new(), + path: "artifact.bin".into(), + size: 1, + sha256: digest(b'c'), + }); + assert!(validate_receipt(&r).is_err()); +} + +#[test] +fn aggregate_rejects_any_failed_or_cancelled_and_allows_optional_skipped() { + let s = digest(b'a'); + let l = digest(b'b'); + for outcome in [CheckOutcome::Failed, CheckOutcome::Cancelled] { + let mut r = receipt(s.clone(), l.clone()); + r.checks.push(VerificationCheck { + name: "optional".into(), + outcome, + passed: 0, + failed: 1, + ignored: 0, + }); + r.failure_classification = Some("failure".into()); + assert!(aggregate_receipts(&[r], &policy(s.clone(), l.clone())).is_err()); + } + let mut r = receipt(s.clone(), l.clone()); + r.checks.push(VerificationCheck { + name: "optional".into(), + outcome: CheckOutcome::Skipped, + passed: 0, + failed: 0, + ignored: 1, + }); + assert!(aggregate_receipts(&[r], &policy(s, l)).is_ok()); +} + +fn cli_fixtures() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let valid = dir.path().join("valid.json"); + let invalid = dir.path().join("invalid.json"); + let policy_path = dir.path().join("policy.json"); + let receipt_dir = dir.path().join("receipts"); + fs::create_dir(&receipt_dir).unwrap(); + let s = digest(b'a'); + let l = digest(b'b'); + fs::write( + &valid, + serde_json::to_vec(&receipt(s.clone(), l.clone())).unwrap(), + ) + .unwrap(); + fs::write(receipt_dir.join("z-valid.json"), fs::read(&valid).unwrap()).unwrap(); + fs::write(receipt_dir.join("README.txt"), b"ignored").unwrap(); + let mut bad = receipt(s.clone(), l.clone()); + bad.promotable = true; + fs::write(&invalid, serde_json::to_vec(&bad).unwrap()).unwrap(); + fs::write(&policy_path, serde_json::to_vec(&policy(s, l)).unwrap()).unwrap(); + (dir, valid, invalid, policy_path, receipt_dir) +} + +fn cli_run(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(args) + .output() + .unwrap() +} + +#[test] +fn cli_receipt_valid_and_quiet_contract() { + let (_dir, valid, _invalid, _policy, _receipts) = cli_fixtures(); + let output = cli_run(&["verify", "receipt", valid.to_str().unwrap()]); + assert_eq!(output.status.code(), Some(0)); + assert!(!output.stdout.is_empty()); + let output = cli_run(&["--quiet", "verify", "receipt", valid.to_str().unwrap()]); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); +} + +#[test] +fn cli_receipt_verdict_and_operational_errors() { + let (dir, _valid, invalid, _policy, _receipts) = cli_fixtures(); + assert_eq!( + cli_run(&["verify", "receipt", invalid.to_str().unwrap()]) + .status + .code(), + Some(1) + ); + let missing = dir.path().join("missing.json"); + assert_eq!( + cli_run(&["verify", "receipt", missing.to_str().unwrap()]) + .status + .code(), + Some(3) + ); + fs::write(&missing, b"{").unwrap(); + assert_eq!( + cli_run(&["verify", "receipt", missing.to_str().unwrap()]) + .status + .code(), + Some(3) + ); +} + +#[test] +fn cli_aggregate_help_file_and_sorted_directory() { + let (_dir, valid, _invalid, policy, receipts) = cli_fixtures(); + let help = cli_run(&["verify", "receipt-aggregate", "--help"]); + assert_eq!(help.status.code(), Some(0)); + let help_text = String::from_utf8_lossy(&help.stdout); + assert!(help_text.contains("--policy")); + assert!(help_text.contains("receipt-aggregate")); + let output = cli_run(&[ + "verify", + "receipt-aggregate", + valid.to_str().unwrap(), + "--policy", + policy.to_str().unwrap(), + ]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + cli_run(&[ + "verify", + "receipt-aggregate", + receipts.to_str().unwrap(), + "--policy", + policy.to_str().unwrap() + ]) + .status + .code(), + Some(0) + ); + let (_dir, _valid, invalid, policy, _receipts) = cli_fixtures(); + assert_eq!( + cli_run(&[ + "verify", + "receipt-aggregate", + invalid.to_str().unwrap(), + "--policy", + policy.to_str().unwrap(), + ]) + .status + .code(), + Some(1) + ); +} From 28f53933015571e0c9733f416bddb88ac281e9bb Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 20:02:28 -0400 Subject: [PATCH 02/11] feat(ci): generate pipeline receipts deterministically Keep digest and trust-context derivation in tested Rust instead of workflow shell logic. Hosted metadata remains self-asserted and all v1 receipts are unsigned and non-promotable. Refs #350 --- docs/pipeline-receipts.md | 19 +- .../input-v1.schema.json | 25 ++ schemas/pipeline-shard-receipt/v1.schema.json | 2 +- src/cli/verify.rs | 39 +++ src/main.rs | 10 + src/verification/mod.rs | 24 +- src/verification/pipeline_receipt.rs | 161 +++++------ ...int.rs => pipeline_receipt_fingerprint.rs} | 11 +- .../pipeline_receipt_generator.rs | 252 ++++++++++++++++++ src/verification/pipeline_receipt_io.rs | 40 +++ ...alidation.rs => pipeline_receipt_paths.rs} | 0 .../{policy.rs => pipeline_receipt_policy.rs} | 45 +++- tests/fixtures/pipeline-shard-receipt-v1.json | 2 +- tests/pipeline_receipt_contract_tests.rs | 31 +++ tests/pipeline_receipt_generator_tests.rs | 219 +++++++++++++++ 15 files changed, 769 insertions(+), 111 deletions(-) create mode 100644 schemas/pipeline-shard-receipt/input-v1.schema.json rename src/verification/{fingerprint.rs => pipeline_receipt_fingerprint.rs} (91%) create mode 100644 src/verification/pipeline_receipt_generator.rs create mode 100644 src/verification/pipeline_receipt_io.rs rename src/verification/{path_validation.rs => pipeline_receipt_paths.rs} (100%) rename src/verification/{policy.rs => pipeline_receipt_policy.rs} (55%) create mode 100644 tests/pipeline_receipt_generator_tests.rs diff --git a/docs/pipeline-receipts.md b/docs/pipeline-receipts.md index ce79522c..ea113246 100644 --- a/docs/pipeline-receipts.md +++ b/docs/pipeline-receipts.md @@ -10,8 +10,20 @@ Validate one locally with: ```text sbom-tools verify receipt path/to/receipt.json sbom-tools verify receipt-aggregate receipts/ --policy aggregate-policy.json +sbom-tools verify receipt-generate --input receipt-input.json --output receipt.json ``` +The generator input is the strict JSON contract in +`schemas/pipeline-shard-receipt/input-v1.schema.json` (schema identifier +`pipeline-shard-receipt-input/v1`). It contains repository/workflow and commit +metadata, source and lock paths, an artifact root plus name/path artifact +descriptors, target identity, checks, timestamps, and either explicit +`local: true` or hosted event metadata. It contains no digest fields: source, +lock, and artifact digests are computed by the generator. Unknown fields are +rejected. Hosted metadata is self-asserted and unsigned in v1; its `sha` is +authoritative for consistency checking and must equal `commit_sha`, and its +repository must equal `repository`. The generator cannot authorize promotion. + Receipts are unsigned and are non-promotion evidence; this slice rejects `promotable: true`. Slice 5 must introduce signed promotion authority. `source_fingerprint(root)` includes path-qualified file names and contents while @@ -33,9 +45,10 @@ explicitly deferred until later slices. Workflow integrations should eventually emit receipts with `if: always()` while preserving native OS execution and existing status guards. -Fingerprinting reads ordinary filesystem paths and has a documented local -mutation race: a file changed between directory enumeration and reading can -produce a fingerprint that does not describe one atomic filesystem snapshot. +Fingerprinting reads ordinary filesystem paths and is non-atomic: a file can +change between enumeration and reading, so this is subject to local TOCTOU +races and does not describe an atomic filesystem snapshot. Artifact containment +checks have the same bounded local race. The generator is deliberately local/non-promotable; aggregation trusts only the externally supplied expected digests (they are policy inputs, not recomputed by the aggregate command). diff --git a/schemas/pipeline-shard-receipt/input-v1.schema.json b/schemas/pipeline-shard-receipt/input-v1.schema.json new file mode 100644 index 00000000..25afdb61 --- /dev/null +++ b/schemas/pipeline-shard-receipt/input-v1.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "pipeline-shard-receipt-input/v1", + "title": "PipelineShardReceiptGenerationInput", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repository", "workflow", "commit_sha", "source_root", "lock_paths", "artifact_root", "artifacts", "target", "versions", "checks", "started_at", "completed_at", "run_id", "dagger_trace", "failure_classification", "hosted", "local"], + "properties": { + "schema": {"const": "pipeline-shard-receipt-input/v1"}, + "repository": {"type":"string","minLength":1}, "workflow": {"type":"string","minLength":1}, + "commit_sha": {"type":"string","pattern":"^[0-9a-f]{40,64}$"}, "source_root": {"type":"string","minLength":1}, + "lock_paths": {"type":"array","minItems":1,"items":{"type":"string","minLength":1}}, + "artifact_root": {"type":"string","minLength":1}, + "artifacts": {"type":"array","items":{"type":"object","additionalProperties":false,"required":["name","path"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1}}}}, + "target": {"$ref":"#/$defs/target"}, "versions":{"type":"object","additionalProperties":{"type":"string"}}, "checks":{"type":"array","items":{"$ref":"#/$defs/check"},"minItems":1}, + "started_at":{"type":"string","format":"date-time"}, "completed_at":{"type":"string","format":"date-time"}, + "run_id":{"type":["string","null"]}, "dagger_trace":{"type":["string","null"]}, "failure_classification":{"type":["string","null"]}, + "hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}, + "local":{"type":"boolean"} + }, + "$defs": { + "target": {"type":"object","additionalProperties":false,"required":["verification_scope","os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"verification_scope":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$","minLength":1},"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string"}},"binding_runtime":{"type":["string","null"]}}}, + "check": {"type":"object","additionalProperties":false,"required":["name","outcome","passed","failed","ignored"],"properties":{"name":{"type":"string","minLength":1},"outcome":{"enum":["passed","failed","cancelled","skipped"]},"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"ignored":{"type":"integer","minimum":0}}} + } +} diff --git a/schemas/pipeline-shard-receipt/v1.schema.json b/schemas/pipeline-shard-receipt/v1.schema.json index 415175a2..4ac87ada 100644 --- a/schemas/pipeline-shard-receipt/v1.schema.json +++ b/schemas/pipeline-shard-receipt/v1.schema.json @@ -12,7 +12,7 @@ }, "$defs": { "digest": {"type":"string","pattern":"^sha256:[0-9a-f]{64}$"}, - "target": {"type":"object","additionalProperties":false,"required":["os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string"}},"binding_runtime":{"type":["string","null"]}}}, + "target": {"type":"object","additionalProperties":false,"required":["verification_scope","os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"verification_scope":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$","minLength":1},"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string"}},"binding_runtime":{"type":["string","null"]}}}, "check": {"type":"object","additionalProperties":false,"required":["name","outcome","passed","failed","ignored"],"properties":{"name":{"type":"string","minLength":1},"outcome":{"enum":["passed","failed","cancelled","skipped"]},"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"ignored":{"type":"integer","minimum":0}}}, "artifact": {"type":"object","additionalProperties":false,"required":["name","path","size","sha256"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1},"size":{"type":"integer","minimum":0},"sha256":{"$ref":"#/$defs/digest"}}} } diff --git a/src/cli/verify.rs b/src/cli/verify.rs index 4021b175..fb47fa27 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -64,6 +64,13 @@ pub enum VerifyAction { #[arg(long)] policy: PathBuf, }, + /// Generate an unsigned receipt from a strict, digest-free JSON descriptor. + ReceiptGenerate { + #[arg(long)] + input: PathBuf, + #[arg(long)] + output: PathBuf, + }, } /// Run the verify command. @@ -272,6 +279,38 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { Err(error) => Err(error.into()), } } + VerifyAction::ReceiptGenerate { input, output } => { + let bytes = std::fs::read(&input)?; + let value: serde_json::Value = serde_json::from_slice(&bytes)?; + let descriptor: crate::verification::ReceiptGenerationInput = + match serde_json::from_value(value) { + Ok(descriptor) => descriptor, + Err(error) => { + eprintln!("receipt generation failed: {error}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + }; + let receipt = match crate::verification::generate_receipt_from_descriptor(descriptor) { + Ok(receipt) => receipt, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + match crate::verification::write_receipt(&output, &receipt) { + Ok(()) => {} + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + } + if !quiet { + println!("Receipt generated: {}", output.display()); + } + Ok(exit_codes::SUCCESS) + } } } diff --git a/src/main.rs b/src/main.rs index 4551850d..1bb1572c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1351,6 +1351,13 @@ enum VerifyAction { #[arg(long)] policy: PathBuf, }, + /// Generate an unsigned receipt from a strict, digest-free JSON descriptor. + ReceiptGenerate { + #[arg(long)] + input: PathBuf, + #[arg(long)] + output: PathBuf, + }, } /// Arguments for the `license-check` subcommand @@ -2622,6 +2629,9 @@ fn run() -> Result<()> { VerifyAction::ReceiptAggregate { receipts, policy } => { cli::VerifyAction::ReceiptAggregate { receipts, policy } } + VerifyAction::ReceiptGenerate { input, output } => { + cli::VerifyAction::ReceiptGenerate { input, output } + } }; let exit_code = cli::run_verify(cli_action, cli.quiet)?; diff --git a/src/verification/mod.rs b/src/verification/mod.rs index 67736655..69bc74e7 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -4,15 +4,16 @@ //! and SBOM signature/provenance checking. mod audit; -mod fingerprint; mod hash; mod model_dir; -mod path_validation; pub mod pipeline_receipt; -mod policy; +mod pipeline_receipt_fingerprint; +mod pipeline_receipt_generator; +mod pipeline_receipt_io; +mod pipeline_receipt_paths; +mod pipeline_receipt_policy; pub use audit::{HashAuditReport, HashAuditResult, audit_component_hashes}; -pub use fingerprint::{lock_fingerprint, source_fingerprint}; pub use hash::{ HashError, HashVerifyResult, compute_file_sha256, read_hash_file, verify_file_hash, }; @@ -20,9 +21,14 @@ pub use model_dir::{ ComponentModelVerification, ModelVerifyReport, ModelVerifyResult, verify_model_dir, }; pub use pipeline_receipt::{ - AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, - PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, ReceiptArtifact, ReceiptError, - ReceiptInput, Sha256Digest, TargetIdentity, TrustContext, TrustedArtifact, VerificationCheck, - aggregate_receipts, check_receipt, generate_receipt, read_receipt, validate_receipt, - write_receipt, + AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, HostedReceiptMetadata, + PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, + ReceiptArtifact, ReceiptArtifactInput, ReceiptError, ReceiptGenerationInput, ReceiptInput, + Sha256Digest, TargetIdentity, TrustContext, TrustedArtifact, VerificationCheck, + aggregate_receipts, validate_receipt, }; +pub use pipeline_receipt_fingerprint::{lock_fingerprint, source_fingerprint}; +pub use pipeline_receipt_generator::{ + derive_trust_context, generate_receipt, generate_receipt_from_descriptor, +}; +pub use pipeline_receipt_io::{check_receipt, read_receipt, write_receipt}; diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs index 23d16faa..fbebef27 100644 --- a/src/verification/pipeline_receipt.rs +++ b/src/verification/pipeline_receipt.rs @@ -1,19 +1,18 @@ //! Unsigned, target-scoped verification receipts. use super::{ - fingerprint::{lock_fingerprint, source_fingerprint}, - path_validation::validate_relative_path, - policy::validate_policy, + pipeline_receipt_paths::validate_relative_path, + pipeline_receipt_policy::{compare_artifacts, validate_policy}, }; use chrono::{DateTime, FixedOffset}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, - fs, - path::{Path, PathBuf}, + path::PathBuf, }; use thiserror::Error; pub const PIPELINE_SHARD_RECEIPT_SCHEMA: &str = "pipeline-shard-receipt/v1"; +pub const PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA: &str = "pipeline-shard-receipt-input/v1"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Sha256Digest(String); @@ -82,6 +81,7 @@ pub enum TrustContext { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TargetIdentity { + pub verification_scope: String, pub os: String, pub architecture: String, pub toolchain: String, @@ -159,6 +159,48 @@ pub struct ReceiptInput { pub failure_classification: Option, } +/// Strict, digest-free descriptor consumed by the CI receipt generator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReceiptGenerationInput { + pub schema: String, + pub repository: String, + pub workflow: String, + pub commit_sha: String, + pub source_root: PathBuf, + pub lock_paths: Vec, + pub artifact_root: PathBuf, + pub artifacts: Vec, + pub target: TargetIdentity, + pub versions: BTreeMap, + pub checks: Vec, + pub started_at: String, + pub completed_at: String, + pub run_id: Option, + pub dagger_trace: Option, + pub failure_classification: Option, + pub hosted: Option, + pub local: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReceiptArtifactInput { + pub name: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostedReceiptMetadata { + pub event_name: String, + pub ref_name: String, + pub repository: String, + pub default_branch: String, + pub sha: String, + pub head_repository: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ExpectedContext { @@ -196,13 +238,12 @@ pub fn validate_receipt(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { if r.schema != PIPELINE_SHARD_RECEIPT_SCHEMA { return Err(ReceiptError::Contract("unsupported schema".into())); } - if r.repository.is_empty() - || r.workflow.is_empty() - || r.target.os.is_empty() - || r.target.architecture.is_empty() - || r.target.toolchain.is_empty() - || r.target.profile.is_empty() - { + validate_receipt_identity(r)?; + validate_receipt_checks(r)?; + Ok(()) +} +fn validate_receipt_identity(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { + if r.repository.is_empty() || r.workflow.is_empty() { return Err(ReceiptError::Contract( "required identity field is empty".into(), )); @@ -223,6 +264,9 @@ pub fn validate_receipt(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { "unsigned receipts cannot be promotable; signed promotion authority is deferred".into(), )); } + Ok(()) +} +fn validate_receipt_checks(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { let mut names = BTreeSet::new(); for check in &r.checks { if check.name.is_empty() || !names.insert(&check.name) { @@ -249,17 +293,33 @@ pub fn validate_receipt(r: &PipelineShardReceipt) -> Result<(), ReceiptError> { Ok(()) } fn validate_target(target: &TargetIdentity) -> Result<(), ReceiptError> { - if target.os.is_empty() + if !is_portable_scope(&target.verification_scope) + || target.os.is_empty() || target.architecture.is_empty() || target.toolchain.is_empty() || target.profile.is_empty() { return Err(ReceiptError::Contract( - "target identity fields must be nonempty".into(), + "target identity fields must be nonempty and verification_scope must be portable" + .into(), )); } Ok(()) } + +fn is_portable_scope(value: &str) -> bool { + !value.is_empty() + && value.is_ascii() + && !value.contains(char::is_whitespace) + && !value.contains('\\') + && !value.starts_with('/') + && !value + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/')) +} fn validate_commit(value: &str) -> Result<(), ReceiptError> { if !(40..=64).contains(&value.len()) || !value.bytes().all(|b| b.is_ascii_hexdigit()) @@ -318,57 +378,6 @@ fn validate_check(c: &VerificationCheck) -> Result<(), ReceiptError> { Ok(()) } -pub fn write_receipt(path: &Path, receipt: &PipelineShardReceipt) -> Result<(), ReceiptError> { - validate_receipt(receipt)?; - let bytes = serde_json::to_vec_pretty(receipt).map_err(|source| ReceiptError::Json { - path: path.into(), - source, - })?; - fs::write(path, bytes).map_err(|source| ReceiptError::Io { - path: path.into(), - source, - }) -} -pub fn read_receipt(path: &Path) -> Result { - let bytes = fs::read(path).map_err(|source| ReceiptError::Io { - path: path.into(), - source, - })?; - let r = serde_json::from_slice(&bytes).map_err(|source| ReceiptError::Json { - path: path.into(), - source, - })?; - validate_receipt(&r)?; - Ok(r) -} -pub fn check_receipt(path: &Path) -> Result<(), ReceiptError> { - read_receipt(path).map(|_| ()) -} - -pub fn generate_receipt(input: ReceiptInput) -> Result { - let receipt = PipelineShardReceipt { - schema: PIPELINE_SHARD_RECEIPT_SCHEMA.into(), - repository: input.repository, - workflow: input.workflow, - run_id: input.run_id, - commit_sha: input.commit_sha, - source_fingerprint: source_fingerprint(&input.source_root)?, - trust_context: input.trust_context, - promotable: input.promotable, - target: input.target, - lock_digest: lock_fingerprint(&input.source_root, &input.lock_paths)?, - versions: input.versions, - checks: input.checks, - artifacts: input.artifacts, - dagger_trace: input.dagger_trace, - started_at: input.started_at, - completed_at: input.completed_at, - failure_classification: input.failure_classification, - }; - validate_receipt(&receipt)?; - Ok(receipt) -} - pub fn aggregate_receipts( receipts: &[PipelineShardReceipt], policy: &AggregatePolicy, @@ -476,25 +485,3 @@ fn verify_checks(r: &PipelineShardReceipt, required: &[String]) -> Result<(), Re } Ok(()) } -fn compare_artifacts( - r: &PipelineShardReceipt, - trusted: &[TrustedArtifact], - ids: &mut BTreeSet, - trusted_ids: &mut BTreeSet, -) -> Result<(), ReceiptError> { - for claim in &r.artifacts { - let id = format!("{}:{}", claim.name, claim.path); - if !ids.insert(id.clone()) { - return Err(ReceiptError::Contract("duplicate artifact identity".into())); - } - let expected = trusted - .iter() - .find(|a| a.name == claim.name && a.path == claim.path) - .ok_or_else(|| ReceiptError::Contract("unexpected artifact".into()))?; - trusted_ids.insert(id.clone()); - if expected.size != claim.size || expected.sha256 != claim.sha256 { - return Err(ReceiptError::Contract("artifact manifest mismatch".into())); - } - } - Ok(()) -} diff --git a/src/verification/fingerprint.rs b/src/verification/pipeline_receipt_fingerprint.rs similarity index 91% rename from src/verification/fingerprint.rs rename to src/verification/pipeline_receipt_fingerprint.rs index 1371415c..461ff4b5 100644 --- a/src/verification/fingerprint.rs +++ b/src/verification/pipeline_receipt_fingerprint.rs @@ -4,12 +4,21 @@ use std::{ }; use super::{ - path_validation::validate_relative_path, pipeline_receipt::{ReceiptError, Sha256Digest}, + pipeline_receipt_paths::validate_relative_path, }; /// Compute a deterministic digest of source files, excluding generated and receipt directories. pub fn source_fingerprint(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root).map_err(|source| ReceiptError::Io { + path: root.into(), + source, + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ReceiptError::Contract( + "source root must be a regular directory".into(), + )); + } let mut files = Vec::new(); collect_source_files(root, root, &mut files)?; fingerprint_files(root, files) diff --git a/src/verification/pipeline_receipt_generator.rs b/src/verification/pipeline_receipt_generator.rs new file mode 100644 index 00000000..464ace33 --- /dev/null +++ b/src/verification/pipeline_receipt_generator.rs @@ -0,0 +1,252 @@ +//! Descriptor-driven receipt generation and hosted trust derivation. +use super::{ + pipeline_receipt::{ + HostedReceiptMetadata, PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, PIPELINE_SHARD_RECEIPT_SCHEMA, + PipelineShardReceipt, ReceiptArtifact, ReceiptArtifactInput, ReceiptError, + ReceiptGenerationInput, ReceiptInput, Sha256Digest, TrustContext, validate_receipt, + }, + pipeline_receipt_fingerprint::{lock_fingerprint, source_fingerprint}, + pipeline_receipt_paths::validate_relative_path, +}; +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +pub fn generate_receipt(input: ReceiptInput) -> Result { + let receipt = PipelineShardReceipt { + schema: PIPELINE_SHARD_RECEIPT_SCHEMA.into(), + repository: input.repository, + workflow: input.workflow, + run_id: input.run_id, + commit_sha: input.commit_sha, + source_fingerprint: source_fingerprint(&input.source_root)?, + trust_context: input.trust_context, + promotable: input.promotable, + target: input.target, + lock_digest: lock_fingerprint(&input.source_root, &input.lock_paths)?, + versions: input.versions, + checks: input.checks, + artifacts: input.artifacts, + dagger_trace: input.dagger_trace, + started_at: input.started_at, + completed_at: input.completed_at, + failure_classification: input.failure_classification, + }; + validate_receipt(&receipt)?; + Ok(receipt) +} + +pub fn derive_trust_context( + hosted: Option<&HostedReceiptMetadata>, + local: bool, +) -> Result<(TrustContext, bool), ReceiptError> { + if local { + if hosted.is_some() { + return Err(ReceiptError::Contract( + "local mode must not include hosted metadata".into(), + )); + } + return Ok((TrustContext::Local, false)); + } + let metadata = hosted.ok_or_else(|| { + ReceiptError::Contract("hosted metadata is required unless local mode is explicit".into()) + })?; + validate_hosted_metadata(metadata)?; + classify_hosted_event(metadata) +} + +fn validate_hosted_metadata(metadata: &HostedReceiptMetadata) -> Result<(), ReceiptError> { + if metadata.repository.is_empty() + || metadata.default_branch.is_empty() + || metadata.ref_name.is_empty() + { + return Err(ReceiptError::Contract( + "hosted metadata is incomplete".into(), + )); + } + Ok(()) +} + +fn classify_hosted_event( + metadata: &HostedReceiptMetadata, +) -> Result<(TrustContext, bool), ReceiptError> { + match metadata.event_name.as_str() { + "pull_request" | "pull_request_target" + if metadata.ref_name.starts_with("refs/pull/") + && metadata + .head_repository + .as_deref() + .is_some_and(|r| !r.is_empty()) => + { + Ok((TrustContext::PullRequest, false)) + } + "push" if metadata.ref_name == format!("refs/heads/{}", metadata.default_branch) => { + Ok((TrustContext::ProtectedMain, false)) + } + "push" + if metadata.ref_name.starts_with("refs/tags/") + && metadata.ref_name.len() > "refs/tags/".len() => + { + Ok((TrustContext::Release, false)) + } + "pull_request" | "pull_request_target" => Err(ReceiptError::Contract( + "ambiguous pull request metadata".into(), + )), + _ => Err(ReceiptError::Contract( + "unsupported or ambiguous hosted event".into(), + )), + } +} + +pub fn generate_receipt_from_descriptor( + descriptor: ReceiptGenerationInput, +) -> Result { + validate_descriptor(&descriptor)?; + let (trust_context, promotable) = + derive_trust_context(descriptor.hosted.as_ref(), descriptor.local)?; + let target = canonical_target(descriptor.target)?; + let artifacts = hash_artifacts(&descriptor.artifact_root, &descriptor.artifacts)?; + generate_receipt(ReceiptInput { + repository: descriptor.repository, + workflow: descriptor.workflow, + run_id: descriptor.run_id, + commit_sha: descriptor.commit_sha, + trust_context, + promotable, + target, + source_root: descriptor.source_root, + lock_paths: descriptor.lock_paths, + versions: descriptor.versions, + checks: descriptor.checks, + artifacts, + dagger_trace: descriptor.dagger_trace, + started_at: descriptor.started_at, + completed_at: descriptor.completed_at, + failure_classification: descriptor.failure_classification, + }) +} + +fn validate_descriptor(descriptor: &ReceiptGenerationInput) -> Result<(), ReceiptError> { + if descriptor.schema != PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA + || descriptor.repository.is_empty() + || descriptor.workflow.is_empty() + { + return Err(ReceiptError::Contract( + "invalid generator input identity or schema".into(), + )); + } + if let Some(hosted) = &descriptor.hosted { + if hosted.sha != descriptor.commit_sha { + return Err(ReceiptError::Contract( + "hosted SHA does not match commit_sha".into(), + )); + } + if hosted.repository != descriptor.repository { + return Err(ReceiptError::Contract( + "hosted repository does not match receipt repository".into(), + )); + } + } + Ok(()) +} + +fn canonical_target( + mut target: super::pipeline_receipt::TargetIdentity, +) -> Result { + target.features.sort(); + if target.features.iter().any(String::is_empty) + || target.features.windows(2).any(|p| p[0] == p[1]) + { + return Err(ReceiptError::Contract( + "target features must be unique and nonempty".into(), + )); + } + Ok(target) +} + +fn hash_artifacts( + root: &Path, + inputs: &[ReceiptArtifactInput], +) -> Result, ReceiptError> { + let meta = fs::symlink_metadata(root).map_err(|source| ReceiptError::Io { + path: root.into(), + source, + })?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(ReceiptError::Contract( + "artifact root must be a regular directory".into(), + )); + } + let canonical_root = fs::canonicalize(root).map_err(|source| ReceiptError::Io { + path: root.into(), + source, + })?; + let mut names = BTreeSet::new(); + let mut artifacts = Vec::with_capacity(inputs.len()); + for input in inputs { + if input.name.is_empty() || !names.insert(&input.name) { + return Err(ReceiptError::Contract( + "artifact names must be unique and nonempty".into(), + )); + } + validate_relative_path(&input.path, "artifact path")?; + let path = root.join(&input.path); + reject_symlink_components(root, &input.path)?; + artifacts.push(hash_one_artifact(&path, &canonical_root, input)?); + } + Ok(artifacts) +} + +fn hash_one_artifact( + path: &Path, + root: &Path, + input: &ReceiptArtifactInput, +) -> Result { + let resolved = fs::canonicalize(path).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + if !resolved.starts_with(root) { + return Err(ReceiptError::Contract( + "artifact path escapes artifact root".into(), + )); + } + let metadata = fs::symlink_metadata(path).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ReceiptError::Contract( + "artifact must be a regular file".into(), + )); + } + let bytes = fs::read(path).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + Ok(ReceiptArtifact { + name: input.name.clone(), + path: input.path.clone(), + size: bytes.len() as u64, + sha256: Sha256Digest::from_bytes(&bytes), + }) +} + +fn reject_symlink_components(root: &Path, relative: &str) -> Result<(), ReceiptError> { + let mut current = PathBuf::from(root); + for component in Path::new(relative).components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t).map_err(|source| ReceiptError::Io { + path: current.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(ReceiptError::Contract( + "artifact path contains a symlink".into(), + )); + } + } + Ok(()) +} diff --git a/src/verification/pipeline_receipt_io.rs b/src/verification/pipeline_receipt_io.rs new file mode 100644 index 00000000..d79a8fe0 --- /dev/null +++ b/src/verification/pipeline_receipt_io.rs @@ -0,0 +1,40 @@ +//! JSON serialization for pipeline receipts. +use super::pipeline_receipt::{PipelineShardReceipt, ReceiptError, validate_receipt}; +use std::{fs, fs::OpenOptions, io::Write, path::Path}; + +pub fn write_receipt(path: &Path, receipt: &PipelineShardReceipt) -> Result<(), ReceiptError> { + validate_receipt(receipt)?; + let bytes = serde_json::to_vec_pretty(receipt).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + file.write_all(&bytes).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + }) +} + +pub fn read_receipt(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + let receipt = serde_json::from_slice(&bytes).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + validate_receipt(&receipt)?; + Ok(receipt) +} + +pub fn check_receipt(path: &Path) -> Result<(), ReceiptError> { + read_receipt(path).map(|_| ()) +} diff --git a/src/verification/path_validation.rs b/src/verification/pipeline_receipt_paths.rs similarity index 100% rename from src/verification/path_validation.rs rename to src/verification/pipeline_receipt_paths.rs diff --git a/src/verification/policy.rs b/src/verification/pipeline_receipt_policy.rs similarity index 55% rename from src/verification/policy.rs rename to src/verification/pipeline_receipt_policy.rs index 9b9f7248..d79e1103 100644 --- a/src/verification/policy.rs +++ b/src/verification/pipeline_receipt_policy.rs @@ -1,21 +1,22 @@ -use super::pipeline_receipt::{AggregatePolicy, ReceiptError}; +use super::pipeline_receipt::{ + AggregatePolicy, PipelineShardReceipt, ReceiptError, TrustedArtifact, +}; use std::collections::BTreeSet; pub(crate) fn validate_policy(policy: &AggregatePolicy) -> Result<(), ReceiptError> { - let context = &policy.context; if policy.expected_targets.is_empty() || policy.required_checks.is_empty() { return Err(ReceiptError::Contract( "aggregate policy targets and required checks must be nonempty".into(), )); } - if context.promotable { - return Err(ReceiptError::Contract( - "unsigned receipt policies cannot be promotable".into(), - )); - } - if context.repository.is_empty() || context.workflow.is_empty() { + validate_policy_context(policy)?; + validate_policy_lists(policy) +} +fn validate_policy_context(policy: &AggregatePolicy) -> Result<(), ReceiptError> { + let context = &policy.context; + if context.promotable || context.repository.is_empty() || context.workflow.is_empty() { return Err(ReceiptError::Contract( - "expected context identity is empty".into(), + "expected unsigned policy context is invalid".into(), )); } if !(40..=64).contains(&context.commit_sha.len()) @@ -26,6 +27,9 @@ pub(crate) fn validate_policy(policy: &AggregatePolicy) -> Result<(), ReceiptErr "expected commit_sha is invalid".into(), )); } + Ok(()) +} +fn validate_policy_lists(policy: &AggregatePolicy) -> Result<(), ReceiptError> { let mut checks = BTreeSet::new(); if policy .required_checks @@ -59,3 +63,26 @@ pub(crate) fn validate_policy(policy: &AggregatePolicy) -> Result<(), ReceiptErr } Ok(()) } + +pub(crate) fn compare_artifacts( + receipt: &PipelineShardReceipt, + trusted: &[TrustedArtifact], + ids: &mut BTreeSet, + trusted_ids: &mut BTreeSet, +) -> Result<(), ReceiptError> { + for claim in &receipt.artifacts { + let id = format!("{}:{}", claim.name, claim.path); + if !ids.insert(id.clone()) { + return Err(ReceiptError::Contract("duplicate artifact identity".into())); + } + let expected = trusted + .iter() + .find(|a| a.name == claim.name && a.path == claim.path) + .ok_or_else(|| ReceiptError::Contract("unexpected artifact".into()))?; + trusted_ids.insert(id); + if expected.size != claim.size || expected.sha256 != claim.sha256 { + return Err(ReceiptError::Contract("artifact manifest mismatch".into())); + } + } + Ok(()) +} diff --git a/tests/fixtures/pipeline-shard-receipt-v1.json b/tests/fixtures/pipeline-shard-receipt-v1.json index f348feca..c98bb305 100644 --- a/tests/fixtures/pipeline-shard-receipt-v1.json +++ b/tests/fixtures/pipeline-shard-receipt-v1.json @@ -7,7 +7,7 @@ "source_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "trust_context": "local", "promotable": false, - "target": {"os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null}, + "target": {"verification_scope":"linux-x86_64","os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null}, "lock_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "versions": {"rust":"1.88"}, "checks": [{"name":"cargo-test","outcome":"passed","passed":1,"failed":0,"ignored":0}], diff --git a/tests/pipeline_receipt_contract_tests.rs b/tests/pipeline_receipt_contract_tests.rs index 29ef5374..ac476785 100644 --- a/tests/pipeline_receipt_contract_tests.rs +++ b/tests/pipeline_receipt_contract_tests.rs @@ -10,6 +10,7 @@ fn digest(byte: u8) -> Sha256Digest { } fn target() -> TargetIdentity { TargetIdentity { + verification_scope: "unit".into(), os: "linux".into(), architecture: "x86_64".into(), toolchain: "stable".into(), @@ -219,6 +220,36 @@ fn receipt_rejects_empty_artifact_name() { assert!(validate_receipt(&r).is_err()); } +#[test] +fn fixture_and_scope_segments_validate() { + let fixture = include_str!("fixtures/pipeline-shard-receipt-v1.json"); + let receipt: PipelineShardReceipt = serde_json::from_str(fixture).unwrap(); + validate_receipt(&receipt).unwrap(); + for scope in ["a//b", ".", "..", "a/./b", "a/../b", "/a", "a\\b"] { + let mut receipt = receipt.clone(); + receipt.target.verification_scope = scope.into(); + assert!(validate_receipt(&receipt).is_err(), "accepted {scope}"); + } +} + +#[test] +fn receipt_output_does_not_overwrite_existing_paths() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("receipt.json"); + let receipt = receipt(digest(b'a'), digest(b'b')); + fs::write(&path, b"original").unwrap(); + assert!(write_receipt(&path, &receipt).is_err()); + #[cfg(unix)] + { + let target = dir.path().join("target"); + fs::write(&target, b"target").unwrap(); + let link = dir.path().join("link.json"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(write_receipt(&link, &receipt).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"target"); + } +} + #[test] fn aggregate_rejects_any_failed_or_cancelled_and_allows_optional_skipped() { let s = digest(b'a'); diff --git a/tests/pipeline_receipt_generator_tests.rs b/tests/pipeline_receipt_generator_tests.rs new file mode 100644 index 00000000..4807e100 --- /dev/null +++ b/tests/pipeline_receipt_generator_tests.rs @@ -0,0 +1,219 @@ +use sbom_tools::verification::*; +use std::{collections::BTreeMap, fs, process::Command}; + +fn target() -> TargetIdentity { + TargetIdentity { + verification_scope: "linux-x86_64".into(), + os: "linux".into(), + architecture: "x86_64".into(), + toolchain: "stable".into(), + profile: "release".into(), + features: vec!["z".into(), "a".into()], + binding_runtime: None, + } +} +fn descriptor( + root: &std::path::Path, + hosted: Option, + local: bool, +) -> ReceiptGenerationInput { + ReceiptGenerationInput { + schema: PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA.into(), + repository: "org/repo".into(), + workflow: "verify".into(), + commit_sha: "a".repeat(40), + source_root: root.into(), + lock_paths: vec!["Cargo.lock".into()], + artifact_root: root.join("artifacts"), + artifacts: vec![ReceiptArtifactInput { + name: "report".into(), + path: "report.json".into(), + }], + target: target(), + versions: BTreeMap::new(), + checks: vec![VerificationCheck { + name: "build".into(), + outcome: CheckOutcome::Passed, + passed: 1, + failed: 0, + ignored: 0, + }], + started_at: "2026-01-01T00:00:00Z".into(), + completed_at: "2026-01-01T00:01:00Z".into(), + run_id: None, + dagger_trace: None, + failure_classification: None, + hosted, + local, + } +} +fn setup() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Cargo.lock"), b"lock").unwrap(); + fs::create_dir(dir.path().join("artifacts")).unwrap(); + fs::write(dir.path().join("artifacts/report.json"), b"report").unwrap(); + dir +} +fn hosted(event: &str, reference: &str) -> HostedReceiptMetadata { + HostedReceiptMetadata { + event_name: event.into(), + ref_name: reference.into(), + repository: "org/repo".into(), + default_branch: "main".into(), + sha: "a".repeat(40), + head_repository: Some("fork/repo".into()), + } +} + +#[test] +fn local_generation_is_deterministic_and_canonicalizes_features() { + let dir = setup(); + let input = descriptor(dir.path(), None, true); + let one = generate_receipt_from_descriptor(input.clone()).unwrap(); + let two = generate_receipt_from_descriptor(input).unwrap(); + assert_eq!(one, two); + assert_eq!(one.target.features, vec!["a", "z"]); + assert_eq!(one.trust_context, TrustContext::Local); + assert!(!one.promotable); + assert_eq!(one.artifacts[0].size, 6); + assert_eq!(one.artifacts[0].sha256, Sha256Digest::from_bytes(b"report")); +} + +#[test] +fn hosted_contexts_cover_pr_fork_main_and_release() { + let dir = setup(); + for (event, reference, expected) in [ + ( + "pull_request", + "refs/pull/7/merge", + TrustContext::PullRequest, + ), + ("push", "refs/heads/main", TrustContext::ProtectedMain), + ("push", "refs/tags/v1.0.0", TrustContext::Release), + ] { + let receipt = generate_receipt_from_descriptor(descriptor( + dir.path(), + Some(hosted(event, reference)), + false, + )) + .unwrap(); + assert_eq!(receipt.trust_context, expected); + } +} + +#[test] +fn hosted_metadata_is_authoritative_and_rejects_mismatch_or_ambiguity() { + let dir = setup(); + let mut bad = descriptor(dir.path(), Some(hosted("push", "refs/heads/main")), false); + bad.commit_sha = "b".repeat(40); + assert!(generate_receipt_from_descriptor(bad).is_err()); + let mut mismatch = descriptor(dir.path(), Some(hosted("push", "refs/heads/main")), false); + mismatch.repository = "other/repo".into(); + assert!(generate_receipt_from_descriptor(mismatch).is_err()); + let ambiguous = descriptor( + dir.path(), + Some(hosted("pull_request", "refs/heads/main")), + false, + ); + assert!(generate_receipt_from_descriptor(ambiguous).is_err()); + let unsupported = descriptor( + dir.path(), + Some(hosted("workflow_dispatch", "refs/heads/main")), + false, + ); + assert!(generate_receipt_from_descriptor(unsupported).is_err()); +} + +#[test] +fn generator_rejects_duplicate_features_empty_locks_invalid_sha_and_unknown_fields() { + let dir = setup(); + let mut duplicate = descriptor(dir.path(), None, true); + duplicate.target.features.push("a".into()); + assert!(generate_receipt_from_descriptor(duplicate).is_err()); + let mut empty_locks = descriptor(dir.path(), None, true); + empty_locks.lock_paths.clear(); + assert!(generate_receipt_from_descriptor(empty_locks).is_err()); + let mut invalid_sha = descriptor(dir.path(), None, true); + invalid_sha.commit_sha = "not-sha".into(); + assert!(generate_receipt_from_descriptor(invalid_sha).is_err()); + let mut value = serde_json::to_value(descriptor(dir.path(), None, true)).unwrap(); + value["digest"] = serde_json::Value::Bool(true); + assert!(serde_json::from_value::(value).is_err()); + let mut invalid_scope = descriptor(dir.path(), None, true); + invalid_scope.target.verification_scope = "../not-portable".into(); + assert!(generate_receipt_from_descriptor(invalid_scope).is_err()); +} + +#[cfg(unix)] +#[test] +fn artifact_missing_escape_and_symlink_are_rejected() { + let dir = setup(); + let mut missing = descriptor(dir.path(), None, true); + missing.artifacts[0].path = "missing".into(); + assert!(generate_receipt_from_descriptor(missing).is_err()); + let mut escape = descriptor(dir.path(), None, true); + escape.artifacts[0].path = "../Cargo.lock".into(); + assert!(generate_receipt_from_descriptor(escape).is_err()); + std::os::unix::fs::symlink("report.json", dir.path().join("artifacts/link")).unwrap(); + let mut link = descriptor(dir.path(), None, true); + link.artifacts[0].path = "link".into(); + assert!(generate_receipt_from_descriptor(link).is_err()); +} + +#[test] +fn compiled_cli_classifies_contract_failure() { + let dir = setup(); + let input = dir.path().join("input.json"); + let output = dir.path().join("out.json"); + let mut value = serde_json::to_value(descriptor(dir.path(), None, true)).unwrap(); + value["digest"] = serde_json::Value::Bool(true); + fs::write(&input, serde_json::to_vec(&value).unwrap()).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(["verify", "receipt-generate", "--input"]) + .arg(&input) + .args(["--output"]) + .arg(&output) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(1)); + assert!(!output.exists()); +} + +#[test] +fn compiled_cli_generates_a_readable_receipt() { + let dir = setup(); + let input = dir.path().join("input.json"); + let output = dir.path().join("out.json"); + fs::write( + &input, + serde_json::to_vec(&descriptor(dir.path(), None, true)).unwrap(), + ) + .unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(["verify", "receipt-generate", "--input"]) + .arg(&input) + .args(["--output"]) + .arg(&output) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(0)); + assert!(read_receipt(&output).is_ok()); + let malformed = dir.path().join("malformed.json"); + fs::write(&malformed, b"not-json").unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(["verify", "receipt-generate", "--input"]) + .arg(&malformed) + .args(["--output"]) + .arg(dir.path().join("bad.json")) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(3)); + let result = Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(["verify", "receipt-generate", "--input"]) + .arg(&input) + .args(["--output"]) + .arg(dir.path()) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(3)); +} From cc3085f13be293cb5756301932be6f04df3a7674 Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 20:21:47 -0400 Subject: [PATCH 03/11] fix(ci): stop logging receipt aggregate counts CodeQL treats policy-derived aggregate cardinalities as sensitive data reaching cleartext output. Keep the public aggregate result while emitting only a constant CLI success message. Refs #350 --- src/cli/verify.rs | 7 ++----- tests/pipeline_receipt_contract_tests.rs | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli/verify.rs b/src/cli/verify.rs index fb47fa27..ca8aa3a1 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -263,12 +263,9 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { } } match crate::verification::aggregate_receipts(&loaded, &policy) { - Ok(result) => { + Ok(_) => { if !quiet { - println!( - "Receipts valid: {} ({} artifacts)", - result.receipt_count, result.artifact_count - ); + println!("Receipts valid"); } Ok(exit_codes::SUCCESS) } diff --git a/tests/pipeline_receipt_contract_tests.rs b/tests/pipeline_receipt_contract_tests.rs index ac476785..7042d978 100644 --- a/tests/pipeline_receipt_contract_tests.rs +++ b/tests/pipeline_receipt_contract_tests.rs @@ -359,6 +359,7 @@ fn cli_aggregate_help_file_and_sorted_directory() { policy.to_str().unwrap(), ]); assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"Receipts valid\n"); assert_eq!( cli_run(&[ "verify", From 0ffd1d5d50cde3404e724be0a46c28fb50a318d4 Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 21:11:19 -0400 Subject: [PATCH 04/11] feat(ci): generate aggregate receipt policies Keep runtime digest and trust derivation in tested Rust so static manifests can define target topology without embedding gate logic in workflow YAML. Generated policies remain unsigned and non-promotable. Refs #350 --- docs/pipeline-receipts.md | 11 + .../aggregate-policy-context/v1.schema.json | 7 + .../aggregate-policy-manifest/v1.schema.json | 15 ++ src/cli/verify.rs | 72 ++++++ src/main.rs | 18 ++ src/verification/mod.rs | 5 + src/verification/pipeline_receipt.rs | 3 +- .../pipeline_receipt_generator.rs | 5 +- .../pipeline_receipt_policy_generator.rs | 176 ++++++++++++++ ...pipeline_receipt_policy_generator_tests.rs | 219 ++++++++++++++++++ 10 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 schemas/aggregate-policy-context/v1.schema.json create mode 100644 schemas/aggregate-policy-manifest/v1.schema.json create mode 100644 src/verification/pipeline_receipt_policy_generator.rs create mode 100644 tests/pipeline_receipt_policy_generator_tests.rs diff --git a/docs/pipeline-receipts.md b/docs/pipeline-receipts.md index ea113246..1adfb2cb 100644 --- a/docs/pipeline-receipts.md +++ b/docs/pipeline-receipts.md @@ -11,6 +11,8 @@ Validate one locally with: sbom-tools verify receipt path/to/receipt.json sbom-tools verify receipt-aggregate receipts/ --policy aggregate-policy.json sbom-tools verify receipt-generate --input receipt-input.json --output receipt.json +sbom-tools verify receipt-policy-generate --manifest policy-manifest.json \ + --context policy-context.json --output aggregate-policy.json ``` The generator input is the strict JSON contract in @@ -60,3 +62,12 @@ operational errors; a readable receipt or policy that violates the contract is a gate verdict and exits 1. Aggregate success, including an explicitly expected `trust_context: "local"`, is verification only and never a promotion decision in v1. + +Policy generation takes two strict versioned inputs. The static +`aggregate-policy-manifest/v1` owns workflow, source and lock roots, expected +target topology, required check names, and trusted artifact name/path inputs. +The runtime `aggregate-policy-context/v1` owns repository, commit SHA, and +hosted or explicit local metadata. Source and lock fingerprints plus artifact +sizes and digests are computed from disk. Generated policies are always +non-promotable, deterministically ordered, and written with create-new +semantics. diff --git a/schemas/aggregate-policy-context/v1.schema.json b/schemas/aggregate-policy-context/v1.schema.json new file mode 100644 index 00000000..af6f9fa6 --- /dev/null +++ b/schemas/aggregate-policy-context/v1.schema.json @@ -0,0 +1,7 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"aggregate-policy-context/v1", + "type":"object","additionalProperties":false, + "required":["schema","repository","commit_sha","hosted","local"], + "properties":{"schema":{"const":"aggregate-policy-context/v1"},"repository":{"type":"string","minLength":1},"commit_sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"local":{"type":"boolean"},"hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}} +} diff --git a/schemas/aggregate-policy-manifest/v1.schema.json b/schemas/aggregate-policy-manifest/v1.schema.json new file mode 100644 index 00000000..826ce2bc --- /dev/null +++ b/schemas/aggregate-policy-manifest/v1.schema.json @@ -0,0 +1,15 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"aggregate-policy-manifest/v1", + "type":"object","additionalProperties":false, + "required":["schema","workflow","source_root","lock_paths","artifact_root","expected_targets","required_checks","artifacts"], + "properties":{ + "schema":{"const":"aggregate-policy-manifest/v1"},"workflow":{"type":"string","minLength":1}, + "source_root":{"type":"string","minLength":1},"artifact_root":{"type":"string","minLength":1}, + "lock_paths":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","minLength":1}}, + "expected_targets":{"type":"array","minItems":1,"uniqueItems":true,"items":{"$ref":"#/$defs/target"}}, + "required_checks":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","minLength":1}}, + "artifacts":{"type":"array","uniqueItems":true,"items":{"type":"object","additionalProperties":false,"required":["name","path"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1}}}} + }, + "$defs":{"target":{"type":"object","additionalProperties":false,"required":["verification_scope","os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"verification_scope":{"type":"string","minLength":1,"pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$"},"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","uniqueItems":true,"items":{"type":"string","minLength":1}},"binding_runtime":{"type":["string","null"]}}}} +} diff --git a/src/cli/verify.rs b/src/cli/verify.rs index ca8aa3a1..f401acda 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -71,6 +71,15 @@ pub enum VerifyAction { #[arg(long)] output: PathBuf, }, + /// Generate an unsigned aggregate policy from static and runtime contracts. + ReceiptPolicyGenerate { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + context: PathBuf, + #[arg(long)] + output: PathBuf, + }, } /// Run the verify command. @@ -308,9 +317,72 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { } Ok(exit_codes::SUCCESS) } + VerifyAction::ReceiptPolicyGenerate { + manifest, + context, + output, + } => { + let manifest = + match read_json_contract::(&manifest) + { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt policy generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + let context = match read_json_contract::( + &context, + ) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt policy generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + let policy = match crate::verification::generate_policy(manifest, context) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt policy generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + match crate::verification::write_policy(&output, &policy) { + Ok(()) => {} + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt policy generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + } + if !quiet { + println!("Receipt policy generated: {}", output.display()); + } + Ok(exit_codes::SUCCESS) + } } } +fn read_json_contract( + path: &std::path::Path, +) -> Result { + let bytes = std::fs::read(path).map_err(|source| crate::verification::ReceiptError::Io { + path: path.into(), + source, + })?; + let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|source| { + crate::verification::ReceiptError::Json { + path: path.into(), + source, + } + })?; + serde_json::from_value(value) + .map_err(|source| crate::verification::ReceiptError::Contract(source.to_string())) +} + fn read_policy(path: &std::path::Path) -> Result { let bytes = std::fs::read(path)?; Ok(serde_json::from_slice(&bytes)?) diff --git a/src/main.rs b/src/main.rs index 1bb1572c..54e9d0f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1358,6 +1358,15 @@ enum VerifyAction { #[arg(long)] output: PathBuf, }, + /// Generate an unsigned aggregate policy from strict manifest and context JSON. + ReceiptPolicyGenerate { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + context: PathBuf, + #[arg(long)] + output: PathBuf, + }, } /// Arguments for the `license-check` subcommand @@ -2632,6 +2641,15 @@ fn run() -> Result<()> { VerifyAction::ReceiptGenerate { input, output } => { cli::VerifyAction::ReceiptGenerate { input, output } } + VerifyAction::ReceiptPolicyGenerate { + manifest, + context, + output, + } => cli::VerifyAction::ReceiptPolicyGenerate { + manifest, + context, + output, + }, }; let exit_code = cli::run_verify(cli_action, cli.quiet)?; diff --git a/src/verification/mod.rs b/src/verification/mod.rs index 69bc74e7..b1e37400 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -12,6 +12,7 @@ mod pipeline_receipt_generator; mod pipeline_receipt_io; mod pipeline_receipt_paths; mod pipeline_receipt_policy; +mod pipeline_receipt_policy_generator; pub use audit::{HashAuditReport, HashAuditResult, audit_component_hashes}; pub use hash::{ @@ -32,3 +33,7 @@ pub use pipeline_receipt_generator::{ derive_trust_context, generate_receipt, generate_receipt_from_descriptor, }; pub use pipeline_receipt_io::{check_receipt, read_receipt, write_receipt}; +pub use pipeline_receipt_policy_generator::{ + AGGREGATE_POLICY_CONTEXT_SCHEMA, AGGREGATE_POLICY_MANIFEST_SCHEMA, AggregatePolicyContextInput, + AggregatePolicyManifest, generate_policy, write_policy, +}; diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs index fbebef27..e20f7bf4 100644 --- a/src/verification/pipeline_receipt.rs +++ b/src/verification/pipeline_receipt.rs @@ -228,6 +228,7 @@ pub struct TrustedArtifact { pub size: u64, pub sha256: Sha256Digest, } + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AggregateVerification { pub receipt_count: usize, @@ -292,7 +293,7 @@ fn validate_receipt_checks(r: &PipelineShardReceipt) -> Result<(), ReceiptError> } Ok(()) } -fn validate_target(target: &TargetIdentity) -> Result<(), ReceiptError> { +pub(crate) fn validate_target(target: &TargetIdentity) -> Result<(), ReceiptError> { if !is_portable_scope(&target.verification_scope) || target.os.is_empty() || target.architecture.is_empty() diff --git a/src/verification/pipeline_receipt_generator.rs b/src/verification/pipeline_receipt_generator.rs index 464ace33..fbf88292 100644 --- a/src/verification/pipeline_receipt_generator.rs +++ b/src/verification/pipeline_receipt_generator.rs @@ -152,7 +152,7 @@ fn validate_descriptor(descriptor: &ReceiptGenerationInput) -> Result<(), Receip Ok(()) } -fn canonical_target( +pub(crate) fn canonical_target( mut target: super::pipeline_receipt::TargetIdentity, ) -> Result { target.features.sort(); @@ -163,10 +163,11 @@ fn canonical_target( "target features must be unique and nonempty".into(), )); } + super::pipeline_receipt::validate_target(&target)?; Ok(target) } -fn hash_artifacts( +pub(crate) fn hash_artifacts( root: &Path, inputs: &[ReceiptArtifactInput], ) -> Result, ReceiptError> { diff --git a/src/verification/pipeline_receipt_policy_generator.rs b/src/verification/pipeline_receipt_policy_generator.rs new file mode 100644 index 00000000..587ca3e8 --- /dev/null +++ b/src/verification/pipeline_receipt_policy_generator.rs @@ -0,0 +1,176 @@ +//! Deterministic construction of unsigned aggregate receipt policies. +use super::{ + pipeline_receipt::{ + AggregatePolicy, ExpectedContext, HostedReceiptMetadata, ReceiptArtifactInput, + ReceiptError, TargetIdentity, TrustedArtifact, + }, + pipeline_receipt_fingerprint::{lock_fingerprint, source_fingerprint}, + pipeline_receipt_generator::{canonical_target, derive_trust_context, hash_artifacts}, + pipeline_receipt_paths::validate_relative_path, +}; +use std::{collections::BTreeSet, fs, path::Path}; + +pub const AGGREGATE_POLICY_MANIFEST_SCHEMA: &str = "aggregate-policy-manifest/v1"; +pub const AGGREGATE_POLICY_CONTEXT_SCHEMA: &str = "aggregate-policy-context/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AggregatePolicyManifest { + pub schema: String, + pub workflow: String, + pub source_root: std::path::PathBuf, + pub lock_paths: Vec, + pub artifact_root: std::path::PathBuf, + pub expected_targets: Vec, + pub required_checks: Vec, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AggregatePolicyContextInput { + pub schema: String, + pub repository: String, + pub commit_sha: String, + pub hosted: Option, + pub local: bool, +} + +pub fn generate_policy( + mut manifest: AggregatePolicyManifest, + context: AggregatePolicyContextInput, +) -> Result { + for target in &mut manifest.expected_targets { + *target = canonical_target(target.clone())?; + } + validate_manifest(&manifest)?; + validate_context(&context)?; + if let Some(hosted) = &context.hosted { + if hosted.repository != context.repository || hosted.sha != context.commit_sha { + return Err(ReceiptError::Contract( + "runtime context does not match hosted metadata".into(), + )); + } + } + let (trust_context, _) = derive_trust_context(context.hosted.as_ref(), context.local)?; + let source_fingerprint = source_fingerprint(&manifest.source_root)?; + let lock_digest = lock_fingerprint(&manifest.source_root, &manifest.lock_paths)?; + let artifacts: Vec = + hash_artifacts(&manifest.artifact_root, &manifest.artifacts)? + .into_iter() + .map(|a| TrustedArtifact { + name: a.name, + path: a.path, + size: a.size, + sha256: a.sha256, + }) + .collect(); + let mut targets = manifest.expected_targets; + targets.sort_by_key(|target| serde_json::to_string(target).unwrap_or_default()); + let mut checks = manifest.required_checks; + checks.sort(); + let mut artifacts = artifacts; + artifacts.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path))); + Ok(AggregatePolicy { + expected_targets: targets, + context: ExpectedContext { + repository: context.repository, + workflow: manifest.workflow, + commit_sha: context.commit_sha, + trust_context, + promotable: false, + source_fingerprint, + lock_digest, + }, + required_checks: checks, + artifacts, + }) +} + +fn validate_manifest(manifest: &AggregatePolicyManifest) -> Result<(), ReceiptError> { + if manifest.schema != AGGREGATE_POLICY_MANIFEST_SCHEMA + || manifest.workflow.is_empty() + || manifest.source_root.as_os_str().is_empty() + || manifest.artifact_root.as_os_str().is_empty() + || manifest.lock_paths.is_empty() + || manifest.expected_targets.is_empty() + || manifest.required_checks.is_empty() + { + return Err(ReceiptError::Contract( + "invalid or incomplete policy manifest".into(), + )); + } + let mut locks = BTreeSet::new(); + for path in &manifest.lock_paths { + let value = path + .to_str() + .ok_or_else(|| ReceiptError::Contract("lock path must be UTF-8".into()))?; + validate_relative_path(value, "lock path")?; + if !locks.insert(value) { + return Err(ReceiptError::Contract("duplicate lock path".into())); + } + } + let mut targets = BTreeSet::new(); + for target in &manifest.expected_targets { + super::pipeline_receipt::validate_target(target)?; + let key = + serde_json::to_string(target).map_err(|e| ReceiptError::Contract(e.to_string()))?; + if !targets.insert(key) { + return Err(ReceiptError::Contract("duplicate expected target".into())); + } + } + let mut checks = BTreeSet::new(); + for check in &manifest.required_checks { + if check.is_empty() || !checks.insert(check) { + return Err(ReceiptError::Contract( + "required check names must be unique and nonempty".into(), + )); + } + } + let mut artifacts = BTreeSet::new(); + for artifact in &manifest.artifacts { + validate_relative_path(&artifact.path, "trusted artifact path")?; + if artifact.name.is_empty() || !artifacts.insert((&artifact.name, &artifact.path)) { + return Err(ReceiptError::Contract( + "trusted artifact identities must be unique and nonempty".into(), + )); + } + } + Ok(()) +} + +fn validate_context(context: &AggregatePolicyContextInput) -> Result<(), ReceiptError> { + if context.schema != AGGREGATE_POLICY_CONTEXT_SCHEMA || context.repository.is_empty() { + return Err(ReceiptError::Contract( + "invalid runtime policy context".into(), + )); + } + if !(40..=64).contains(&context.commit_sha.len()) + || !context.commit_sha.bytes().all(|b| b.is_ascii_hexdigit()) + || context.commit_sha != context.commit_sha.to_ascii_lowercase() + { + return Err(ReceiptError::Contract( + "runtime commit_sha is invalid".into(), + )); + } + Ok(()) +} + +pub fn write_policy(path: &Path, policy: &AggregatePolicy) -> Result<(), ReceiptError> { + let bytes = serde_json::to_vec_pretty(policy).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + std::io::Write::write_all(&mut file, &bytes).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + }) +} diff --git a/tests/pipeline_receipt_policy_generator_tests.rs b/tests/pipeline_receipt_policy_generator_tests.rs new file mode 100644 index 00000000..5bf44502 --- /dev/null +++ b/tests/pipeline_receipt_policy_generator_tests.rs @@ -0,0 +1,219 @@ +use sbom_tools::verification::*; +use std::{collections::BTreeMap, fs, process::Command}; + +fn target(scope: &str) -> TargetIdentity { + TargetIdentity { + verification_scope: scope.into(), + os: "linux".into(), + architecture: "x86_64".into(), + toolchain: "stable".into(), + profile: "release".into(), + features: vec![], + binding_runtime: None, + } +} +fn manifest(root: &std::path::Path) -> AggregatePolicyManifest { + AggregatePolicyManifest { + schema: AGGREGATE_POLICY_MANIFEST_SCHEMA.into(), + workflow: "verify".into(), + source_root: root.into(), + lock_paths: vec!["Cargo.lock".into()], + artifact_root: root.join("artifacts"), + expected_targets: vec![target("linux")], + required_checks: vec!["build".into()], + artifacts: vec![ReceiptArtifactInput { + name: "report".into(), + path: "report.json".into(), + }], + } +} +fn context() -> AggregatePolicyContextInput { + AggregatePolicyContextInput { + schema: AGGREGATE_POLICY_CONTEXT_SCHEMA.into(), + repository: "org/repo".into(), + commit_sha: "a".repeat(40), + hosted: None, + local: true, + } +} +fn setup() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Cargo.lock"), b"lock").unwrap(); + fs::create_dir(dir.path().join("artifacts")).unwrap(); + fs::write(dir.path().join("artifacts/report.json"), b"report").unwrap(); + dir +} + +#[test] +fn manifest_schema_declares_collection_uniqueness() { + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../schemas/aggregate-policy-manifest/v1.schema.json" + )) + .unwrap(); + for field in [ + "lock_paths", + "expected_targets", + "required_checks", + "artifacts", + ] { + assert_eq!(schema["properties"][field]["uniqueItems"], true); + } + assert_eq!( + schema["$defs"]["target"]["properties"]["features"]["uniqueItems"], + true + ); + assert_eq!( + schema["$defs"]["target"]["properties"]["features"]["items"]["minLength"], + 1 + ); +} + +#[test] +fn policy_generation_is_deterministic_and_hashes_artifacts() { + let dir = setup(); + let one = generate_policy(manifest(dir.path()), context()).unwrap(); + let two = generate_policy(manifest(dir.path()), context()).unwrap(); + assert_eq!(one, two); + assert!(!one.context.promotable); + assert_eq!(one.context.trust_context, TrustContext::Local); + assert_eq!(one.artifacts[0].sha256, Sha256Digest::from_bytes(b"report")); +} + +#[test] +fn policy_generation_rejects_duplicate_topology_and_context_mismatch() { + let dir = setup(); + let mut duplicate = manifest(dir.path()); + duplicate.expected_targets.push(target("linux")); + assert!(generate_policy(duplicate, context()).is_err()); + let mut mismatch = context(); + mismatch.repository = "other/repo".into(); + let mut hosted = mismatch.clone(); + hosted.hosted = Some(HostedReceiptMetadata { + event_name: "push".into(), + ref_name: "refs/heads/main".into(), + repository: "org/repo".into(), + default_branch: "main".into(), + sha: "a".repeat(40), + head_repository: None, + }); + assert!(generate_policy(manifest(dir.path()), hosted).is_err()); + + let mut checks = manifest(dir.path()); + checks.required_checks.push("build".into()); + assert!(generate_policy(checks, context()).is_err()); + let mut locks = manifest(dir.path()); + locks.lock_paths.push("Cargo.lock".into()); + assert!(generate_policy(locks, context()).is_err()); + let mut artifacts = manifest(dir.path()); + artifacts.artifacts.push(artifacts.artifacts[0].clone()); + assert!(generate_policy(artifacts, context()).is_err()); + + let mut ordered = manifest(dir.path()); + ordered.expected_targets[0].features = vec!["b".into(), "a".into()]; + ordered.expected_targets.push(TargetIdentity { + features: vec!["a".into(), "b".into()], + ..target("linux") + }); + assert!(generate_policy(ordered, context()).is_err()); + let mut duplicate_features = manifest(dir.path()); + duplicate_features.expected_targets[0].features = vec!["a".into(), "a".into()]; + assert!(generate_policy(duplicate_features, context()).is_err()); +} + +#[test] +fn policy_generation_rejects_empty_roots_and_roundtrips_aggregate_validation() { + let dir = setup(); + let mut bad_source = manifest(dir.path()); + bad_source.source_root = "".into(); + assert!(generate_policy(bad_source, context()).is_err()); + let mut bad_artifact = manifest(dir.path()); + bad_artifact.artifact_root = std::path::PathBuf::new(); + assert!(generate_policy(bad_artifact, context()).is_err()); + let policy = generate_policy(manifest(dir.path()), context()).unwrap(); + let receipt = generate_receipt_from_descriptor(ReceiptGenerationInput { + schema: PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA.into(), + repository: "org/repo".into(), + workflow: "verify".into(), + commit_sha: "a".repeat(40), + source_root: dir.path().into(), + lock_paths: vec!["Cargo.lock".into()], + artifact_root: dir.path().join("artifacts"), + artifacts: vec![ReceiptArtifactInput { + name: "report".into(), + path: "report.json".into(), + }], + target: target("linux"), + versions: BTreeMap::new(), + checks: vec![VerificationCheck { + name: "build".into(), + outcome: CheckOutcome::Passed, + passed: 1, + failed: 0, + ignored: 0, + }], + started_at: "2026-01-01T00:00:00Z".into(), + completed_at: "2026-01-01T00:01:00Z".into(), + run_id: None, + dagger_trace: None, + failure_classification: None, + hosted: None, + local: true, + }) + .unwrap(); + assert!(aggregate_receipts(&[receipt], &policy).is_ok()); + assert!(!policy.context.promotable); +} + +#[cfg(unix)] +#[test] +fn policy_generation_rejects_empty_locks_and_artifact_path_attacks() { + let dir = setup(); + let mut empty = manifest(dir.path()); + empty.lock_paths.clear(); + assert!(generate_policy(empty, context()).is_err()); + for path in ["missing.json", "../Cargo.lock"] { + let mut bad = manifest(dir.path()); + bad.artifacts[0].path = path.into(); + assert!(generate_policy(bad, context()).is_err()); + } + std::os::unix::fs::symlink("report.json", dir.path().join("artifacts/link")).unwrap(); + let mut symlink = manifest(dir.path()); + symlink.artifacts[0].path = "link".into(); + assert!(generate_policy(symlink, context()).is_err()); +} + +#[test] +fn compiled_policy_generator_has_zero_one_three_semantics_and_no_overwrite() { + let dir = setup(); + let manifest_path = dir.path().join("manifest.json"); + let context_path = dir.path().join("context.json"); + let output = dir.path().join("policy.json"); + fs::write( + &manifest_path, + serde_json::to_vec(&manifest(dir.path()).clone()).unwrap(), + ) + .unwrap(); + fs::write(&context_path, serde_json::to_vec(&context()).unwrap()).unwrap(); + let run = |out: &std::path::Path| { + Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args(["verify", "receipt-policy-generate", "--manifest"]) + .arg(&manifest_path) + .args(["--context"]) + .arg(&context_path) + .args(["--output"]) + .arg(out) + .output() + .unwrap() + }; + assert_eq!(run(&output).status.code(), Some(0)); + assert_eq!(run(&output).status.code(), Some(3)); + let mut value = serde_json::to_value(manifest(dir.path())).unwrap(); + value["unexpected"] = true.into(); + fs::write(&manifest_path, serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(run(&dir.path().join("bad.json")).status.code(), Some(1)); + fs::write(&manifest_path, b"{").unwrap(); + assert_eq!( + run(&dir.path().join("malformed.json")).status.code(), + Some(3) + ); +} From 3860b06a2f16c56d25a093e5b09a231618c8ef3e Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 21:47:36 -0400 Subject: [PATCH 05/11] feat(ci): add receipt job adapter Translate checked-in shard manifests and hosted runner outcomes into validated receipts without embedding evidence rules in workflow shell. Runner identity and context mismatches fail closed. Refs #350 --- src/cli/verify.rs | 126 +++++++++ src/main.rs | 64 +++++ src/verification/mod.rs | 12 +- src/verification/pipeline_receipt.rs | 1 + src/verification/pipeline_receipt_job.rs | 162 +++++++++++ .../pipeline_receipt_policy_generator.rs | 53 +++- tests/pipeline_receipt_job_tests.rs | 259 ++++++++++++++++++ 7 files changed, 671 insertions(+), 6 deletions(-) create mode 100644 src/verification/pipeline_receipt_job.rs create mode 100644 tests/pipeline_receipt_job_tests.rs diff --git a/src/cli/verify.rs b/src/cli/verify.rs index f401acda..5eba6ddd 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -80,6 +80,38 @@ pub enum VerifyAction { #[arg(long)] output: PathBuf, }, + /// Generate a target-scoped receipt from a checked-in job manifest and CI outcomes. + ReceiptJob { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + context: PathBuf, + #[arg(long)] + outcome: Vec, + #[arg(long)] + runner_os: Option, + #[arg(long)] + runner_arch: Option, + #[arg(long)] + output: PathBuf, + }, + /// Write a strict hosted receipt context from CI-provided values. + ReceiptContext { + #[arg(long)] + repository: String, + #[arg(long)] + commit_sha: String, + #[arg(long)] + event_name: String, + #[arg(long)] + ref_name: String, + #[arg(long)] + default_branch: String, + #[arg(long)] + head_repository: Option, + #[arg(long)] + output: PathBuf, + }, } /// Run the verify command. @@ -363,6 +395,100 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { } Ok(exit_codes::SUCCESS) } + VerifyAction::ReceiptJob { + manifest, + context, + outcome, + runner_os, + runner_arch, + output, + } => { + let manifest = + match read_json_contract::(&manifest) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt job generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + let context = match read_json_contract::( + &context, + ) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt job generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + let outcomes = outcome + .iter() + .map(|value| { + value + .split_once('=') + .map(|(name, state)| (name.to_owned(), state.to_owned())) + }) + .collect::>>() + .ok_or_else(|| anyhow::anyhow!("outcomes must use check=outcome"))?; + let receipt = match crate::verification::generate_job_receipt( + manifest, + context, + &outcomes + .iter() + .map(|(n, s)| (n.clone(), s.clone())) + .collect::>(), + runner_os.as_deref(), + runner_arch.as_deref(), + ) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt job generation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; + match crate::verification::write_receipt(&output, &receipt) { + Ok(()) => Ok(exit_codes::SUCCESS), + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt job generation failed: {message}"); + Ok(exit_codes::CHANGES_DETECTED) + } + Err(error) => Err(error.into()), + } + } + VerifyAction::ReceiptContext { + repository, + commit_sha, + event_name, + ref_name, + default_branch, + head_repository, + output, + } => { + let context = crate::verification::AggregatePolicyContextInput { + schema: crate::verification::AGGREGATE_POLICY_CONTEXT_SCHEMA.into(), + repository: repository.clone(), + commit_sha: commit_sha.clone(), + local: false, + hosted: Some(crate::verification::HostedReceiptMetadata { + event_name, + ref_name, + repository, + default_branch, + sha: commit_sha, + head_repository, + }), + }; + match crate::verification::write_context(&output, &context) { + Ok(()) => Ok(exit_codes::SUCCESS), + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt context failed: {message}"); + Ok(exit_codes::CHANGES_DETECTED) + } + Err(error) => Err(error.into()), + } + } } } diff --git a/src/main.rs b/src/main.rs index 54e9d0f7..49563013 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1367,6 +1367,38 @@ enum VerifyAction { #[arg(long)] output: PathBuf, }, + /// Generate a target-scoped receipt from a checked-in job manifest and CI outcomes. + ReceiptJob { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + context: PathBuf, + #[arg(long)] + outcome: Vec, + #[arg(long)] + runner_os: Option, + #[arg(long)] + runner_arch: Option, + #[arg(long)] + output: PathBuf, + }, + /// Write a strict hosted receipt context from CI-provided values. + ReceiptContext { + #[arg(long)] + repository: String, + #[arg(long)] + commit_sha: String, + #[arg(long)] + event_name: String, + #[arg(long)] + ref_name: String, + #[arg(long)] + default_branch: String, + #[arg(long)] + head_repository: Option, + #[arg(long)] + output: PathBuf, + }, } /// Arguments for the `license-check` subcommand @@ -2650,6 +2682,38 @@ fn run() -> Result<()> { context, output, }, + VerifyAction::ReceiptJob { + manifest, + context, + outcome, + runner_os, + runner_arch, + output, + } => cli::VerifyAction::ReceiptJob { + manifest, + context, + outcome, + runner_os, + runner_arch, + output, + }, + VerifyAction::ReceiptContext { + repository, + commit_sha, + event_name, + ref_name, + default_branch, + head_repository, + output, + } => cli::VerifyAction::ReceiptContext { + repository, + commit_sha, + event_name, + ref_name, + default_branch, + head_repository, + output, + }, }; let exit_code = cli::run_verify(cli_action, cli.quiet)?; diff --git a/src/verification/mod.rs b/src/verification/mod.rs index b1e37400..3ba97e02 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -10,6 +10,7 @@ pub mod pipeline_receipt; mod pipeline_receipt_fingerprint; mod pipeline_receipt_generator; mod pipeline_receipt_io; +mod pipeline_receipt_job; mod pipeline_receipt_paths; mod pipeline_receipt_policy; mod pipeline_receipt_policy_generator; @@ -23,17 +24,18 @@ pub use model_dir::{ }; pub use pipeline_receipt::{ AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, HostedReceiptMetadata, - PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, - ReceiptArtifact, ReceiptArtifactInput, ReceiptError, ReceiptGenerationInput, ReceiptInput, - Sha256Digest, TargetIdentity, TrustContext, TrustedArtifact, VerificationCheck, - aggregate_receipts, validate_receipt, + PIPELINE_SHARD_JOB_MANIFEST_SCHEMA, PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, + PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, ReceiptArtifact, ReceiptArtifactInput, + ReceiptError, ReceiptGenerationInput, ReceiptInput, Sha256Digest, TargetIdentity, TrustContext, + TrustedArtifact, VerificationCheck, aggregate_receipts, validate_receipt, }; pub use pipeline_receipt_fingerprint::{lock_fingerprint, source_fingerprint}; pub use pipeline_receipt_generator::{ derive_trust_context, generate_receipt, generate_receipt_from_descriptor, }; pub use pipeline_receipt_io::{check_receipt, read_receipt, write_receipt}; +pub use pipeline_receipt_job::{ReceiptJobManifest, generate_job_receipt}; pub use pipeline_receipt_policy_generator::{ AGGREGATE_POLICY_CONTEXT_SCHEMA, AGGREGATE_POLICY_MANIFEST_SCHEMA, AggregatePolicyContextInput, - AggregatePolicyManifest, generate_policy, write_policy, + AggregatePolicyManifest, generate_policy, write_context, write_policy, }; diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs index e20f7bf4..3a0586dd 100644 --- a/src/verification/pipeline_receipt.rs +++ b/src/verification/pipeline_receipt.rs @@ -13,6 +13,7 @@ use std::{ use thiserror::Error; pub const PIPELINE_SHARD_RECEIPT_SCHEMA: &str = "pipeline-shard-receipt/v1"; pub const PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA: &str = "pipeline-shard-receipt-input/v1"; +pub const PIPELINE_SHARD_JOB_MANIFEST_SCHEMA: &str = "pipeline-shard-job-manifest/v1"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Sha256Digest(String); diff --git a/src/verification/pipeline_receipt_job.rs b/src/verification/pipeline_receipt_job.rs new file mode 100644 index 00000000..e706117b --- /dev/null +++ b/src/verification/pipeline_receipt_job.rs @@ -0,0 +1,162 @@ +//! Strict workflow-job adapter for target-scoped receipt generation. +use super::{ + pipeline_receipt::{CheckOutcome, ReceiptError, ReceiptGenerationInput, VerificationCheck}, + pipeline_receipt_generator::generate_receipt_from_descriptor, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReceiptJobManifest { + pub schema: String, + pub workflow: String, + pub source_root: std::path::PathBuf, + pub lock_paths: Vec, + pub artifact_root: std::path::PathBuf, + pub artifacts: Vec, + pub target: super::pipeline_receipt::TargetIdentity, + pub checks: Vec, +} + +pub fn generate_job_receipt( + mut manifest: ReceiptJobManifest, + context: super::pipeline_receipt_policy_generator::AggregatePolicyContextInput, + outcomes: &[(String, String)], + runner_os: Option<&str>, + runner_arch: Option<&str>, +) -> Result { + if manifest.schema != super::pipeline_receipt::PIPELINE_SHARD_JOB_MANIFEST_SCHEMA { + return Err(ReceiptError::Contract("invalid job manifest schema".into())); + } + resolve_source_token(&mut manifest)?; + if manifest.checks.is_empty() { + return Err(ReceiptError::Contract("job manifest has no checks".into())); + } + validate_runner_target(&manifest.target, runner_os, runner_arch)?; + let checks = build_checks(&manifest.checks, outcomes)?; + let now = chrono::Utc::now().to_rfc3339(); + let failure_classification = checks.iter().find_map(|check| match check.outcome { + CheckOutcome::Failed => Some("failed".into()), + CheckOutcome::Cancelled => Some("cancelled".into()), + CheckOutcome::Passed | CheckOutcome::Skipped => None, + }); + generate_receipt_from_descriptor(ReceiptGenerationInput { + schema: super::pipeline_receipt::PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA.into(), + repository: context.repository, + workflow: manifest.workflow, + commit_sha: context.commit_sha, + source_root: manifest.source_root, + lock_paths: manifest.lock_paths, + artifact_root: manifest.artifact_root, + artifacts: manifest.artifacts, + target: manifest.target, + versions: BTreeMap::new(), + checks, + started_at: now.clone(), + completed_at: now, + run_id: None, + dagger_trace: None, + failure_classification, + hosted: context.hosted, + local: context.local, + }) +} + +fn resolve_source_token(manifest: &mut ReceiptJobManifest) -> Result<(), ReceiptError> { + if manifest.source_root.as_os_str() != "$RUNNER_TEMP/sbom-receipt-source" { + return Ok(()); + } + let temp = std::env::var("RUNNER_TEMP") + .map_err(|_| ReceiptError::Contract("RUNNER_TEMP is required".into()))?; + manifest.source_root = std::path::PathBuf::from(temp).join("sbom-receipt-source"); + manifest.artifact_root = manifest.source_root.clone(); + Ok(()) +} + +fn validate_runner_target( + target: &super::pipeline_receipt::TargetIdentity, + runner_os: Option<&str>, + runner_arch: Option<&str>, +) -> Result<(), ReceiptError> { + if let Some(os) = runner_os { + let actual = match os { + "Linux" => "linux", + "macOS" => "macos", + "Windows" => "windows", + _ => return Err(ReceiptError::Contract("unsupported runner OS".into())), + }; + if actual != target.os { + return Err(ReceiptError::Contract( + "runner OS does not match target".into(), + )); + } + } + if let Some(arch) = runner_arch { + let actual = match arch { + "X64" => "x86_64", + "ARM64" => "arm64", + _ => { + return Err(ReceiptError::Contract( + "unsupported runner architecture".into(), + )); + } + }; + if actual != target.architecture { + return Err(ReceiptError::Contract( + "runner architecture does not match target".into(), + )); + } + } + Ok(()) +} + +fn build_checks( + declared: &[String], + outcomes: &[(String, String)], +) -> Result, ReceiptError> { + let mut supplied = BTreeMap::new(); + for (name, outcome) in outcomes { + if supplied.insert(name, parse_outcome(outcome)?).is_some() { + return Err(ReceiptError::Contract("duplicate check outcome".into())); + } + } + let checks = declared + .iter() + .map(|name| check_from_outcome(name, supplied.get(name).copied())) + .collect::, _>>()?; + let expected: BTreeSet<&String> = declared.iter().collect(); + if supplied.keys().any(|name| !expected.contains(name)) { + return Err(ReceiptError::Contract("unexpected check outcome".into())); + } + Ok(checks) +} + +fn check_from_outcome( + name: &str, + outcome: Option, +) -> Result { + let outcome = + outcome.ok_or_else(|| ReceiptError::Contract(format!("missing outcome for {name}")))?; + Ok(VerificationCheck { + name: name.into(), + outcome, + passed: u64::from(outcome == CheckOutcome::Passed), + failed: u64::from(matches!( + outcome, + CheckOutcome::Failed | CheckOutcome::Cancelled + )), + ignored: u64::from(outcome == CheckOutcome::Skipped), + }) +} + +fn parse_outcome(value: &str) -> Result { + match value { + "success" | "passed" => Ok(CheckOutcome::Passed), + "failure" | "failed" => Ok(CheckOutcome::Failed), + "cancelled" | "canceled" => Ok(CheckOutcome::Cancelled), + "skipped" => Ok(CheckOutcome::Skipped), + _ => Err(ReceiptError::Contract(format!( + "invalid check outcome: {value}" + ))), + } +} diff --git a/src/verification/pipeline_receipt_policy_generator.rs b/src/verification/pipeline_receipt_policy_generator.rs index 587ca3e8..17e4586a 100644 --- a/src/verification/pipeline_receipt_policy_generator.rs +++ b/src/verification/pipeline_receipt_policy_generator.rs @@ -40,6 +40,11 @@ pub fn generate_policy( mut manifest: AggregatePolicyManifest, context: AggregatePolicyContextInput, ) -> Result { + expand_runtime_paths( + &mut manifest.source_root, + &mut manifest.artifact_root, + &mut manifest.lock_paths, + )?; for target in &mut manifest.expected_targets { *target = canonical_target(target.clone())?; } @@ -87,6 +92,25 @@ pub fn generate_policy( }) } +fn expand_runtime_paths( + source_root: &mut std::path::PathBuf, + artifact_root: &mut std::path::PathBuf, + lock_paths: &mut [std::path::PathBuf], +) -> Result<(), ReceiptError> { + let replace = |path: &mut std::path::PathBuf| { + if path.as_os_str() == "$RUNNER_TEMP/sbom-receipt-source" { + let temp = std::env::var("RUNNER_TEMP") + .map_err(|_| ReceiptError::Contract("RUNNER_TEMP is required".into()))?; + *path = std::path::PathBuf::from(&temp).join("sbom-receipt-source"); + } + Ok::<(), ReceiptError>(()) + }; + replace(source_root)?; + replace(artifact_root)?; + let _ = lock_paths; + Ok(()) +} + fn validate_manifest(manifest: &AggregatePolicyManifest) -> Result<(), ReceiptError> { if manifest.schema != AGGREGATE_POLICY_MANIFEST_SCHEMA || manifest.workflow.is_empty() @@ -139,7 +163,7 @@ fn validate_manifest(manifest: &AggregatePolicyManifest) -> Result<(), ReceiptEr Ok(()) } -fn validate_context(context: &AggregatePolicyContextInput) -> Result<(), ReceiptError> { +pub(crate) fn validate_context(context: &AggregatePolicyContextInput) -> Result<(), ReceiptError> { if context.schema != AGGREGATE_POLICY_CONTEXT_SCHEMA || context.repository.is_empty() { return Err(ReceiptError::Contract( "invalid runtime policy context".into(), @@ -174,3 +198,30 @@ pub fn write_policy(path: &Path, policy: &AggregatePolicy) -> Result<(), Receipt source, }) } + +pub fn write_context( + path: &Path, + context: &AggregatePolicyContextInput, +) -> Result<(), ReceiptError> { + validate_context(context)?; + super::pipeline_receipt_generator::derive_trust_context( + context.hosted.as_ref(), + context.local, + )?; + let bytes = serde_json::to_vec_pretty(context).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|source| ReceiptError::Io { + path: path.into(), + source, + })?; + std::io::Write::write_all(&mut file, &bytes).map_err(|source| ReceiptError::Io { + path: path.into(), + source, + }) +} diff --git a/tests/pipeline_receipt_job_tests.rs b/tests/pipeline_receipt_job_tests.rs new file mode 100644 index 00000000..72639e45 --- /dev/null +++ b/tests/pipeline_receipt_job_tests.rs @@ -0,0 +1,259 @@ +use sbom_tools::verification::*; +use std::{fs, path::PathBuf}; + +fn target() -> TargetIdentity { + TargetIdentity { + verification_scope: "job".into(), + os: "linux".into(), + architecture: "x86_64".into(), + toolchain: "stable".into(), + profile: "debug".into(), + features: vec![], + binding_runtime: None, + } +} +fn manifest(root: PathBuf) -> ReceiptJobManifest { + ReceiptJobManifest { + schema: PIPELINE_SHARD_JOB_MANIFEST_SCHEMA.into(), + workflow: "test".into(), + source_root: root.clone(), + lock_paths: vec!["Cargo.lock".into()], + artifact_root: root, + artifacts: vec![], + target: target(), + checks: vec!["producer".into()], + } +} +fn context() -> AggregatePolicyContextInput { + AggregatePolicyContextInput { + schema: AGGREGATE_POLICY_CONTEXT_SCHEMA.into(), + repository: "org/repo".into(), + commit_sha: "a".repeat(40), + hosted: None, + local: true, + } +} +fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Cargo.lock"), b"lock").unwrap(); + dir +} + +#[test] +fn outcomes_and_failure_classification_are_preserved() { + let dir = fixture(); + for (value, classification) in [ + ("success", None), + ("failure", Some("failed")), + ("cancelled", Some("cancelled")), + ("skipped", None), + ] { + let receipt = generate_job_receipt( + manifest(dir.path().into()), + context(), + &[("producer".into(), value.into())], + None, + None, + ) + .unwrap(); + assert_eq!(receipt.failure_classification.as_deref(), classification); + assert_eq!( + receipt.checks[0].outcome, + match value { + "success" => CheckOutcome::Passed, + "failure" => CheckOutcome::Failed, + "cancelled" => CheckOutcome::Cancelled, + _ => CheckOutcome::Skipped, + } + ); + } +} + +#[test] +fn rejects_identity_and_outcome_contract_violations() { + let dir = fixture(); + let m = manifest(dir.path().into()); + assert!( + generate_job_receipt( + m.clone(), + context(), + &[("producer".into(), "success".into())], + Some("Windows"), + Some("X64") + ) + .is_err() + ); + assert!(generate_job_receipt(m.clone(), context(), &[], None, None).is_err()); + assert!( + generate_job_receipt( + m.clone(), + context(), + &[("extra".into(), "success".into())], + None, + None + ) + .is_err() + ); + assert!( + generate_job_receipt( + m, + context(), + &[ + ("producer".into(), "success".into()), + ("producer".into(), "failed".into()) + ], + None, + None + ) + .is_err() + ); +} + +#[test] +fn context_writer_validates_and_does_not_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("context.json"); + write_context(&path, &context()).unwrap(); + assert!(write_context(&path, &context()).is_err()); + let mut invalid = context(); + invalid.commit_sha = "bad".into(); + assert!(write_context(&dir.path().join("invalid.json"), &invalid).is_err()); +} + +#[test] +fn cli_root_dispatches_receipt_context() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("context.json"); + let status = std::process::Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .args([ + "verify", + "receipt-context", + "--repository", + "org/repo", + "--commit-sha", + &"a".repeat(40), + "--event-name", + "push", + "--ref-name", + "refs/heads/main", + "--default-branch", + "main", + "--output", + output.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success()); + assert!(output.exists()); +} + +#[test] +fn source_token_resolves_from_runner_temp() { + let dir = fixture(); + let source = dir.path().join("source"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("Cargo.lock"), b"lock").unwrap(); + // SAFETY: this test runs single-threaded with a private temporary directory. + unsafe { std::env::set_var("RUNNER_TEMP", dir.path()) }; + fs::rename( + dir.path().join("source"), + dir.path().join("sbom-receipt-source"), + ) + .unwrap(); + let mut m = manifest("$RUNNER_TEMP/sbom-receipt-source".into()); + m.artifact_root = m.source_root.clone(); + assert!( + generate_job_receipt( + m, + context(), + &[("producer".into(), "success".into())], + None, + None + ) + .is_ok() + ); +} + +#[test] +fn cli_receipt_job_dispatch_and_operational_errors() { + let dir = fixture(); + let manifest_path = dir.path().join("manifest.json"); + let context_path = dir.path().join("context.json"); + let output_path = dir.path().join("receipt.json"); + fs::write( + &manifest_path, + serde_json::to_vec(&manifest(dir.path().into())).unwrap(), + ) + .unwrap(); + fs::write(&context_path, serde_json::to_vec(&context()).unwrap()).unwrap(); + let binary = env!("CARGO_BIN_EXE_sbom-tools"); + let base = [ + "verify", + "receipt-job", + "--manifest", + manifest_path.to_str().unwrap(), + "--context", + context_path.to_str().unwrap(), + "--outcome", + "producer=success", + "--output", + output_path.to_str().unwrap(), + ]; + assert_eq!( + std::process::Command::new(binary) + .args(base) + .status() + .unwrap() + .code(), + Some(0) + ); + assert!(read_receipt(&output_path).is_ok()); + assert_eq!( + std::process::Command::new(binary) + .args([ + "verify", + "receipt-job", + "--manifest", + manifest_path.to_str().unwrap(), + "--context", + context_path.to_str().unwrap(), + "--outcome", + "producer=success", + "--output", + dir.path().join("bad").to_str().unwrap(), + "--runner-os", + "Windows" + ]) + .status() + .unwrap() + .code(), + Some(1) + ); + assert_eq!( + std::process::Command::new(binary) + .args([ + "verify", + "receipt-job", + "--manifest", + dir.path().join("missing").to_str().unwrap(), + "--context", + context_path.to_str().unwrap(), + "--outcome", + "producer=success", + "--output", + dir.path().join("missing-out").to_str().unwrap() + ]) + .status() + .unwrap() + .code(), + Some(3) + ); + assert_eq!( + std::process::Command::new(binary) + .args(base) + .status() + .unwrap() + .code(), + Some(3) + ); +} From ef3ada2af1f2a62779ac9bdb4e0c2454032d7eec Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 21:47:55 -0400 Subject: [PATCH 06/11] feat(ci): aggregate target-scoped verification receipts Make Rust and Bindings jobs publish one fail-closed receipt per native target, then preserve CI and add a Bindings fan-in gate over checked-in topology and lock contracts. Refs #350 --- .github/actions/emit-receipt/action.yml | 51 +++++++ .../receipts/bindings-policy-manifest.json | 131 ++++++++++++++++++ .github/receipts/jobs/constant-parity.json | 1 + .github/receipts/jobs/dagger-rust-sdk.json | 1 + .github/receipts/jobs/go-bindings-macos.json | 1 + .github/receipts/jobs/go-bindings-ubuntu.json | 1 + .../receipts/jobs/nodejs-bindings-macos.json | 1 + .../receipts/jobs/nodejs-bindings-ubuntu.json | 1 + .../jobs/nodejs-bindings-windows.json | 1 + .../receipts/jobs/python-bindings-macos.json | 1 + .../receipts/jobs/python-bindings-ubuntu.json | 1 + .../jobs/python-bindings-windows.json | 1 + .github/receipts/jobs/rust-audit.json | 1 + .../receipts/jobs/rust-deny-advisories.json | 1 + .../jobs/rust-deny-licenses-sources.json | 1 + .github/receipts/jobs/rust-ffi.json | 1 + .github/receipts/jobs/rust-lint.json | 1 + .github/receipts/jobs/rust-msrv.json | 1 + .../receipts/jobs/rust-test-macos-stable.json | 1 + .../receipts/jobs/rust-test-ubuntu-beta.json | 1 + .../jobs/rust-test-ubuntu-stable.json | 1 + .../jobs/rust-test-windows-stable.json | 1 + .github/receipts/jobs/swift-bindings.json | 1 + .github/receipts/rust-policy-manifest.json | 20 +++ .github/workflows/bindings.yml | 127 +++++++++++++++++ .github/workflows/rust.yml | 99 ++++++++++++- docs/pipeline-receipts.md | 13 +- .../pipeline_receipt_manifest_parity_tests.rs | 52 +++++++ 28 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 .github/actions/emit-receipt/action.yml create mode 100644 .github/receipts/bindings-policy-manifest.json create mode 100644 .github/receipts/jobs/constant-parity.json create mode 100644 .github/receipts/jobs/dagger-rust-sdk.json create mode 100644 .github/receipts/jobs/go-bindings-macos.json create mode 100644 .github/receipts/jobs/go-bindings-ubuntu.json create mode 100644 .github/receipts/jobs/nodejs-bindings-macos.json create mode 100644 .github/receipts/jobs/nodejs-bindings-ubuntu.json create mode 100644 .github/receipts/jobs/nodejs-bindings-windows.json create mode 100644 .github/receipts/jobs/python-bindings-macos.json create mode 100644 .github/receipts/jobs/python-bindings-ubuntu.json create mode 100644 .github/receipts/jobs/python-bindings-windows.json create mode 100644 .github/receipts/jobs/rust-audit.json create mode 100644 .github/receipts/jobs/rust-deny-advisories.json create mode 100644 .github/receipts/jobs/rust-deny-licenses-sources.json create mode 100644 .github/receipts/jobs/rust-ffi.json create mode 100644 .github/receipts/jobs/rust-lint.json create mode 100644 .github/receipts/jobs/rust-msrv.json create mode 100644 .github/receipts/jobs/rust-test-macos-stable.json create mode 100644 .github/receipts/jobs/rust-test-ubuntu-beta.json create mode 100644 .github/receipts/jobs/rust-test-ubuntu-stable.json create mode 100644 .github/receipts/jobs/rust-test-windows-stable.json create mode 100644 .github/receipts/jobs/swift-bindings.json create mode 100644 .github/receipts/rust-policy-manifest.json create mode 100644 tests/pipeline_receipt_manifest_parity_tests.rs diff --git a/.github/actions/emit-receipt/action.yml b/.github/actions/emit-receipt/action.yml new file mode 100644 index 00000000..42d5bdb5 --- /dev/null +++ b/.github/actions/emit-receipt/action.yml @@ -0,0 +1,51 @@ +name: Emit pipeline receipt +description: Generate one target-scoped unsigned receipt and upload it for fan-in. +inputs: + manifest: + required: true + artifact-name: + required: true + outcome: + required: true + receipt-id: + required: true +runs: + using: composite + steps: + - name: Generate receipt + if: always() + shell: bash + env: + RECEIPT_STATUS: ${{ inputs.outcome }} + RECEIPT_REPOSITORY: ${{ github.repository }} + RECEIPT_SHA: ${{ github.sha }} + RECEIPT_EVENT: ${{ github.event_name }} + RECEIPT_REF: ${{ github.ref }} + RECEIPT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RECEIPT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + RECEIPT_RUN_ID: ${{ github.run_id }} + RECEIPT_RUNNER_OS: ${{ runner.os }} + RECEIPT_RUNNER_ARCH: ${{ runner.arch }} + RECEIPT_MANIFEST: ${{ inputs.manifest }} + RECEIPT_ID: ${{ inputs.receipt-id }} + run: | + set -euo pipefail + [[ "$RECEIPT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "invalid receipt-id" >&2; exit 2; } + target="$RUNNER_TEMP/sbom-receipt-source" + test ! -e "$target" + mkdir -p "$target" + git archive "$RECEIPT_SHA" | tar -x -C "$target" + mkdir -p "$RUNNER_TEMP/sbom-receipts" + target="$RUNNER_TEMP/sbom-receipts/$RECEIPT_ID.json" + bin=target/debug/sbom-tools; [[ -x "$bin.exe" ]] && bin="$bin.exe" + "$bin" verify receipt-context --repository "$RECEIPT_REPOSITORY" --commit-sha "$RECEIPT_SHA" --event-name "$RECEIPT_EVENT" --ref-name "$RECEIPT_REF" --default-branch "$RECEIPT_DEFAULT_BRANCH" --head-repository "${RECEIPT_HEAD_REPOSITORY:-}" --output "${RUNNER_TEMP}/sbom-receipt-context.json" + args=(verify receipt-job --manifest "$RECEIPT_MANIFEST" --context "${RUNNER_TEMP}/sbom-receipt-context.json" --output "$target" --outcome "producer=$RECEIPT_STATUS" --runner-os "$RECEIPT_RUNNER_OS" --runner-arch "$RECEIPT_RUNNER_ARCH") + "$bin" "${args[@]}" + - name: Upload receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/sbom-receipts/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/receipts/bindings-policy-manifest.json b/.github/receipts/bindings-policy-manifest.json new file mode 100644 index 00000000..1bea7610 --- /dev/null +++ b/.github/receipts/bindings-policy-manifest.json @@ -0,0 +1,131 @@ +{ + "schema": "aggregate-policy-manifest/v1", + "workflow": "Bindings", + "source_root": "$RUNNER_TEMP/sbom-receipt-source", + "lock_paths": [ + "Cargo.lock", + "rust-toolchain.toml", + "dagger/rust-sdk/Cargo.lock", + "bindings/go/go.mod", + "bindings/python/pyproject.toml", + "bindings/nodejs/package-lock.json", + "bindings/swift/Package.swift" + ], + "artifact_root": "$RUNNER_TEMP/sbom-receipt-source", + "expected_targets": [ + { + "verification_scope": "constant-parity", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": null + }, + { + "verification_scope": "dagger-rust-sdk", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "dagger-rust-sdk" + }, + { + "verification_scope": "rust-ffi", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [ + "ffi" + ], + "binding_runtime": null + }, + { + "verification_scope": "go-bindings-ubuntu", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "go" + }, + { + "verification_scope": "go-bindings-macos", + "os": "macos", + "architecture": "arm64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "go" + }, + { + "verification_scope": "python-bindings-ubuntu", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "python" + }, + { + "verification_scope": "python-bindings-macos", + "os": "macos", + "architecture": "arm64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "python" + }, + { + "verification_scope": "python-bindings-windows", + "os": "windows", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "python" + }, + { + "verification_scope": "swift-bindings", + "os": "macos", + "architecture": "arm64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "swift" + }, + { + "verification_scope": "nodejs-bindings-ubuntu", + "os": "linux", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "nodejs" + }, + { + "verification_scope": "nodejs-bindings-macos", + "os": "macos", + "architecture": "arm64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "nodejs" + }, + { + "verification_scope": "nodejs-bindings-windows", + "os": "windows", + "architecture": "x86_64", + "toolchain": "stable", + "profile": "debug", + "features": [], + "binding_runtime": "nodejs" + } + ], + "required_checks": [ + "producer" + ], + "artifacts": [] +} diff --git a/.github/receipts/jobs/constant-parity.json b/.github/receipts/jobs/constant-parity.json new file mode 100644 index 00000000..9ca4afcb --- /dev/null +++ b/.github/receipts/jobs/constant-parity.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"constant-parity","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/dagger-rust-sdk.json b/.github/receipts/jobs/dagger-rust-sdk.json new file mode 100644 index 00000000..36b541bb --- /dev/null +++ b/.github/receipts/jobs/dagger-rust-sdk.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"dagger-rust-sdk","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"dagger-rust-sdk"},"checks":["producer"]} diff --git a/.github/receipts/jobs/go-bindings-macos.json b/.github/receipts/jobs/go-bindings-macos.json new file mode 100644 index 00000000..8300516c --- /dev/null +++ b/.github/receipts/jobs/go-bindings-macos.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"go-bindings-macos","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"go"},"checks":["producer"]} diff --git a/.github/receipts/jobs/go-bindings-ubuntu.json b/.github/receipts/jobs/go-bindings-ubuntu.json new file mode 100644 index 00000000..29858a2e --- /dev/null +++ b/.github/receipts/jobs/go-bindings-ubuntu.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"go-bindings-ubuntu","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"go"},"checks":["producer"]} diff --git a/.github/receipts/jobs/nodejs-bindings-macos.json b/.github/receipts/jobs/nodejs-bindings-macos.json new file mode 100644 index 00000000..20d2c98d --- /dev/null +++ b/.github/receipts/jobs/nodejs-bindings-macos.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"nodejs-bindings-macos","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"nodejs"},"checks":["producer"]} diff --git a/.github/receipts/jobs/nodejs-bindings-ubuntu.json b/.github/receipts/jobs/nodejs-bindings-ubuntu.json new file mode 100644 index 00000000..b6af433f --- /dev/null +++ b/.github/receipts/jobs/nodejs-bindings-ubuntu.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"nodejs-bindings-ubuntu","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"nodejs"},"checks":["producer"]} diff --git a/.github/receipts/jobs/nodejs-bindings-windows.json b/.github/receipts/jobs/nodejs-bindings-windows.json new file mode 100644 index 00000000..d0e086eb --- /dev/null +++ b/.github/receipts/jobs/nodejs-bindings-windows.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"nodejs-bindings-windows","os":"windows","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"nodejs"},"checks":["producer"]} diff --git a/.github/receipts/jobs/python-bindings-macos.json b/.github/receipts/jobs/python-bindings-macos.json new file mode 100644 index 00000000..a1e9a7ec --- /dev/null +++ b/.github/receipts/jobs/python-bindings-macos.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"python-bindings-macos","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"python"},"checks":["producer"]} diff --git a/.github/receipts/jobs/python-bindings-ubuntu.json b/.github/receipts/jobs/python-bindings-ubuntu.json new file mode 100644 index 00000000..57c659fb --- /dev/null +++ b/.github/receipts/jobs/python-bindings-ubuntu.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"python-bindings-ubuntu","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"python"},"checks":["producer"]} diff --git a/.github/receipts/jobs/python-bindings-windows.json b/.github/receipts/jobs/python-bindings-windows.json new file mode 100644 index 00000000..3847d313 --- /dev/null +++ b/.github/receipts/jobs/python-bindings-windows.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"python-bindings-windows","os":"windows","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"python"},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-audit.json b/.github/receipts/jobs/rust-audit.json new file mode 100644 index 00000000..6f801a46 --- /dev/null +++ b/.github/receipts/jobs/rust-audit.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-audit","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-deny-advisories.json b/.github/receipts/jobs/rust-deny-advisories.json new file mode 100644 index 00000000..ca40c3e1 --- /dev/null +++ b/.github/receipts/jobs/rust-deny-advisories.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-deny-advisories","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-deny-licenses-sources.json b/.github/receipts/jobs/rust-deny-licenses-sources.json new file mode 100644 index 00000000..50981503 --- /dev/null +++ b/.github/receipts/jobs/rust-deny-licenses-sources.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-deny-licenses-sources","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-ffi.json b/.github/receipts/jobs/rust-ffi.json new file mode 100644 index 00000000..4dd4a940 --- /dev/null +++ b/.github/receipts/jobs/rust-ffi.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-ffi","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":["ffi"],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-lint.json b/.github/receipts/jobs/rust-lint.json new file mode 100644 index 00000000..9b0969c8 --- /dev/null +++ b/.github/receipts/jobs/rust-lint.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-lint","os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-msrv.json b/.github/receipts/jobs/rust-msrv.json new file mode 100644 index 00000000..ae4b7715 --- /dev/null +++ b/.github/receipts/jobs/rust-msrv.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-msrv","os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-test-macos-stable.json b/.github/receipts/jobs/rust-test-macos-stable.json new file mode 100644 index 00000000..e42c6de2 --- /dev/null +++ b/.github/receipts/jobs/rust-test-macos-stable.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-test-macos-stable","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-test-ubuntu-beta.json b/.github/receipts/jobs/rust-test-ubuntu-beta.json new file mode 100644 index 00000000..c2056d40 --- /dev/null +++ b/.github/receipts/jobs/rust-test-ubuntu-beta.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-test-ubuntu-beta","os":"linux","architecture":"x86_64","toolchain":"beta","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-test-ubuntu-stable.json b/.github/receipts/jobs/rust-test-ubuntu-stable.json new file mode 100644 index 00000000..bf3338b4 --- /dev/null +++ b/.github/receipts/jobs/rust-test-ubuntu-stable.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-test-ubuntu-stable","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/rust-test-windows-stable.json b/.github/receipts/jobs/rust-test-windows-stable.json new file mode 100644 index 00000000..7050cc40 --- /dev/null +++ b/.github/receipts/jobs/rust-test-windows-stable.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-test-windows-stable","os":"windows","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/jobs/swift-bindings.json b/.github/receipts/jobs/swift-bindings.json new file mode 100644 index 00000000..783ae078 --- /dev/null +++ b/.github/receipts/jobs/swift-bindings.json @@ -0,0 +1 @@ +{"schema":"pipeline-shard-job-manifest/v1","workflow":"Bindings","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml","dagger/rust-sdk/Cargo.lock","bindings/go/go.mod","bindings/python/pyproject.toml","bindings/nodejs/package-lock.json","bindings/swift/Package.swift"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"swift-bindings","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":"swift"},"checks":["producer"]} diff --git a/.github/receipts/rust-policy-manifest.json b/.github/receipts/rust-policy-manifest.json new file mode 100644 index 00000000..83a9a41c --- /dev/null +++ b/.github/receipts/rust-policy-manifest.json @@ -0,0 +1,20 @@ +{ + "schema": "aggregate-policy-manifest/v1", + "workflow": "CI", + "source_root": "$RUNNER_TEMP/sbom-receipt-source", + "lock_paths": ["Cargo.lock", "rust-toolchain.toml"], + "artifact_root": "$RUNNER_TEMP/sbom-receipt-source", + "expected_targets": [ + {"verification_scope":"rust-lint","os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-msrv","os":"linux","architecture":"x86_64","toolchain":"1.88","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-test-ubuntu-stable","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-test-macos-stable","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-test-windows-stable","os":"windows","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-test-ubuntu-beta","os":"linux","architecture":"x86_64","toolchain":"beta","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-deny-advisories","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-deny-licenses-sources","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, + {"verification_scope":"rust-audit","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null} + ], + "required_checks": ["producer"], + "artifacts": [] +} diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml index f2a707a0..e137e65e 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings.yml @@ -15,8 +15,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Check wrapper constants match sbom_tools.h run: ./scripts/check-binding-constants.sh + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/constant-parity.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: constant-parity + outcome: ${{ job.status }} dagger-rust-sdk: name: Dagger CI (workspace check + staticlib + FFI tests + Go bindings) @@ -30,8 +44,20 @@ jobs: toolchain: stable - name: Cache Rust uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Run Dagger Rust SDK pipeline run: ./scripts/run-dagger-ci.sh + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/dagger-rust-sdk.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: dagger-rust-sdk + outcome: ${{ job.status }} rust-ffi: name: Rust FFI @@ -45,6 +71,10 @@ jobs: toolchain: stable - name: Cache uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Build FFI library run: bash ./scripts/build-bindings-mvp.sh - name: Test FFI ABI @@ -58,6 +88,14 @@ jobs: - name: Test FFI regression (PR #113 + multi-format) run: cargo test --locked --features ffi --test ffi_regression + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-ffi.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: rust-ffi + outcome: ${{ job.status }} go-bindings: name: Go Bindings (${{ matrix.os }}) @@ -79,6 +117,10 @@ jobs: go-version: '1.22' - name: Cache Rust uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Build Rust library run: bash ./scripts/build-bindings-mvp.sh - name: Test Go wrapper @@ -86,6 +128,14 @@ jobs: run: go test ./... - name: Test Go dedup helpers run: ./scripts/test-bindings-dedup.sh go + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/go-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || 'macos' }}.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.os }} + receipt-id: go-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || 'macos' }} + outcome: ${{ job.status }} python-bindings: name: Python Bindings (${{ matrix.os }}) @@ -107,6 +157,10 @@ jobs: python-version: '3.12' - name: Cache Rust uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Build Rust library run: cargo build -p sbom-tools-ffi --release - name: Create Python environment @@ -124,6 +178,14 @@ jobs: python -c "import os, pathlib, subprocess; executable = pathlib.Path('.venv') / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python'); subprocess.check_call([str(executable), '-m', 'pytest', '-q'])" + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/python-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || matrix.os == 'macos-latest' && 'macos' || 'windows' }}.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.os }} + receipt-id: python-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || matrix.os == 'macos-latest' && 'macos' || 'windows' }} + outcome: ${{ job.status }} swift-bindings: name: Swift Bindings @@ -135,6 +197,8 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Build Rust library run: bash ./scripts/build-bindings-mvp.sh - name: Build and test Swift wrapper @@ -142,6 +206,14 @@ jobs: run: swift test - name: Test Swift dedup helpers run: ./scripts/test-bindings-dedup.sh swift + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/swift-bindings.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: swift-bindings + outcome: ${{ job.status }} nodejs-bindings: name: Node.js Bindings (${{ matrix.os }}) @@ -165,6 +237,10 @@ jobs: cache-dependency-path: bindings/nodejs/package-lock.json - name: Cache Rust uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Install Node.js dependencies working-directory: bindings/nodejs run: npm ci @@ -173,3 +249,54 @@ jobs: - name: Build and test Node.js wrapper working-directory: bindings/nodejs run: npm test + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/nodejs-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || matrix.os == 'macos-latest' && 'macos' || 'windows' }}.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.os }} + receipt-id: nodejs-bindings-${{ matrix.os == 'ubuntu-latest' && 'ubuntu' || matrix.os == 'macos-latest' && 'macos' || 'windows' }} + outcome: ${{ job.status }} + + bindings-aggregate: + name: Bindings aggregate + if: always() + needs: [constant-parity, dagger-rust-sdk, rust-ffi, go-bindings, python-bindings, swift-bindings, nodejs-bindings] + runs-on: ubuntu-latest + steps: + - name: Checkout + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + if: always() + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - name: Download producer receipts + if: always() + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: receipts-${{ github.run_id }}-* + path: .ci-receipts/receipts + merge-multiple: true + - name: Build receipt CLI + if: always() + run: | + cargo build --locked --bin sbom-tools + test ! -e "$RUNNER_TEMP/sbom-receipt-source"; mkdir -p "$RUNNER_TEMP/sbom-receipt-source" + git archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" + - name: Generate aggregate policy + if: always() + env: + RECEIPT_REPOSITORY: ${{ github.repository }} + RECEIPT_SHA: ${{ github.sha }} + RECEIPT_EVENT: ${{ github.event_name }} + RECEIPT_REF: ${{ github.ref }} + RECEIPT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RECEIPT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + run: | + target/debug/sbom-tools verify receipt-context --repository "$RECEIPT_REPOSITORY" --commit-sha "$RECEIPT_SHA" --event-name "$RECEIPT_EVENT" --ref-name "$RECEIPT_REF" --default-branch "$RECEIPT_DEFAULT_BRANCH" --head-repository "${RECEIPT_HEAD_REPOSITORY:-}" --output "$RUNNER_TEMP/sbom-receipt-context.json" + target/debug/sbom-tools verify receipt-policy-generate --manifest .github/receipts/bindings-policy-manifest.json --context "$RUNNER_TEMP/sbom-receipt-context.json" --output "$RUNNER_TEMP/bindings-policy.json" + - name: Aggregate producer receipts + if: always() + run: target/debug/sbom-tools verify receipt-aggregate .ci-receipts/receipts --policy "$RUNNER_TEMP/bindings-policy.json" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4e2859ab..aaaf1951 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,12 +34,22 @@ jobs: uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Check formatting run: cargo fmt --all -- --check - name: Clippy (default features) run: cargo clippy -- -D warnings - name: Clippy (all features) run: cargo clippy --all-features -- -D warnings + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-lint.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: rust-lint + outcome: ${{ job.status }} # ── MSRV check ───────────────────────────────────────────────── msrv: @@ -57,10 +67,20 @@ jobs: with: shared-key: msrv save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Check (no default features) run: cargo check --locked --no-default-features --features ffi - name: Check (all features) run: cargo check --locked --all-features + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-msrv.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: rust-msrv + outcome: ${{ job.status }} # ── Test matrix ──────────────────────────────────────────────── test: @@ -86,10 +106,20 @@ jobs: with: shared-key: ${{ matrix.toolchain }} save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Test (default features) run: cargo test --locked --verbose - name: Test (all features) run: cargo test --locked --all-features --verbose + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-test-${{ matrix.toolchain == 'beta' && 'ubuntu-beta' || matrix.os == 'ubuntu-latest' && 'ubuntu-stable' || matrix.os == 'macos-latest' && 'macos-stable' || 'windows-stable' }}.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.os }}-${{ matrix.toolchain }} + receipt-id: rust-test-${{ matrix.os }}-${{ matrix.toolchain }} + outcome: ${{ job.status }} # ── Dependency audit ─────────────────────────────────────────── deny: @@ -100,16 +130,30 @@ jobs: checks: - advisories - bans licenses sources - # Advisory failures are informational — don't block PRs on surprise CVEs - continue-on-error: ${{ matrix.checks == 'advisories' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: cargo-deny + id: cargo_deny + # Advisory and license/source checks are required gates (issue #350). uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 with: command: check ${{ matrix.checks }} arguments: --all-features + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-deny-${{ matrix.checks == 'advisories' && 'advisories' || 'licenses-sources' }}.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.checks }} + receipt-id: rust-deny-${{ matrix.checks == 'advisories' && 'advisories' || 'licenses-sources' }} + outcome: ${{ steps.cargo_deny.outcome }} # ── Security audit ──────────────────────────────────────────── audit: @@ -118,10 +162,25 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - name: Build receipt CLI + run: cargo build --locked --bin sbom-tools - name: Install cargo-audit run: cargo install cargo-audit --locked - name: Run cargo-audit + id: cargo_audit run: cargo audit + - name: Emit receipt + if: always() + uses: ./.github/actions/emit-receipt + with: + manifest: .github/receipts/jobs/rust-audit.json + artifact-name: receipts-${{ github.run_id }}-${{ github.job }} + receipt-id: rust-audit + outcome: ${{ steps.cargo_audit.outcome }} # ── Status gate (single required check) ──────────────────────── ci: @@ -130,6 +189,42 @@ jobs: needs: [lint, msrv, test, deny, audit] runs-on: ubuntu-latest steps: + - name: Checkout + if: always() + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + if: always() + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - name: Download producer receipts + if: always() + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: receipts-${{ github.run_id }}-* + path: .ci-receipts/receipts + merge-multiple: true + - name: Build receipt CLI + if: always() + run: | + cargo build --locked --bin sbom-tools + test ! -e "$RUNNER_TEMP/sbom-receipt-source"; mkdir -p "$RUNNER_TEMP/sbom-receipt-source" + git archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" + - name: Generate aggregate policy + if: always() + env: + RECEIPT_REPOSITORY: ${{ github.repository }} + RECEIPT_SHA: ${{ github.sha }} + RECEIPT_EVENT: ${{ github.event_name }} + RECEIPT_REF: ${{ github.ref }} + RECEIPT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RECEIPT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + run: | + target/debug/sbom-tools verify receipt-context --repository "$RECEIPT_REPOSITORY" --commit-sha "$RECEIPT_SHA" --event-name "$RECEIPT_EVENT" --ref-name "$RECEIPT_REF" --default-branch "$RECEIPT_DEFAULT_BRANCH" --head-repository "${RECEIPT_HEAD_REPOSITORY:-}" --output "$RUNNER_TEMP/sbom-receipt-context.json" + target/debug/sbom-tools verify receipt-policy-generate --manifest .github/receipts/rust-policy-manifest.json --context "$RUNNER_TEMP/sbom-receipt-context.json" --output "$RUNNER_TEMP/rust-policy.json" + - name: Aggregate producer receipts + if: always() + run: target/debug/sbom-tools verify receipt-aggregate .ci-receipts/receipts --policy "$RUNNER_TEMP/rust-policy.json" - name: Evaluate results run: | result="${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" diff --git a/docs/pipeline-receipts.md b/docs/pipeline-receipts.md index 1adfb2cb..2814fcb7 100644 --- a/docs/pipeline-receipts.md +++ b/docs/pipeline-receipts.md @@ -42,10 +42,15 @@ invalid artifacts, and duplicate artifact identities. The receipt is an action result, not a dependency cache. Cache keys must remain separate from receipt and artifact identities; cache contents are never accepted -as verification evidence. Workflow wiring and cryptographic signatures are -explicitly deferred until later slices. Workflow integrations should eventually -emit receipts with `if: always()` while preserving native OS execution and -existing status guards. +as verification evidence. Rust and Bindings workflows now emit one unsigned +receipt per producer target with `if: always()`, preserving native OS execution +and the existing Rust CI check name. Bindings fan-in is additive and validates +all producer receipts without filename collisions. Cargo advisories intentionally +transition from informational to fail-closed under issue #350. + +Hosted failure/cancel and native cross-platform proof remain pending acceptance +gates. The wiring adds 21 extra CLI builds (one per producer), which is +unmeasured overhead; no acceleration or savings claim is made. Fingerprinting reads ordinary filesystem paths and is non-atomic: a file can change between enumeration and reading, so this is subject to local TOCTOU diff --git a/tests/pipeline_receipt_manifest_parity_tests.rs b/tests/pipeline_receipt_manifest_parity_tests.rs new file mode 100644 index 00000000..65d0f8c7 --- /dev/null +++ b/tests/pipeline_receipt_manifest_parity_tests.rs @@ -0,0 +1,52 @@ +use serde_json::Value; +use std::{collections::BTreeSet, fs, path::Path}; + +fn read(path: impl AsRef) -> Value { + let path = path.as_ref(); + serde_json::from_slice(&fs::read(path).unwrap_or_else(|e| panic!("{path:?}: {e}"))) + .unwrap_or_else(|e| panic!("{path:?}: {e}")) +} + +fn targets(value: &Value) -> BTreeSet { + value["expected_targets"] + .as_array() + .expect("aggregate expected_targets") + .iter() + .map(|target| serde_json::to_string(target).unwrap()) + .collect() +} + +#[test] +fn job_manifests_are_valid_and_match_aggregate_contracts() { + for (prefix, workflow) in [("rust", "CI"), ("bindings", "Bindings")] { + let aggregate = read(format!(".github/receipts/{prefix}-policy-manifest.json")); + let aggregate_locks = aggregate["lock_paths"].clone(); + let expected = targets(&aggregate); + let mut seen = BTreeSet::new(); + let entries = fs::read_dir(".github/receipts/jobs").unwrap(); + for entry in entries { + let path = entry.unwrap().path(); + let value = read(&path); + if value["workflow"] != workflow { + continue; + } + assert_eq!( + value["schema"], "pipeline-shard-job-manifest/v1", + "{path:?}" + ); + assert_eq!(value["lock_paths"], aggregate_locks, "{path:?}"); + assert_eq!( + value["artifact_root"], aggregate["artifact_root"], + "{path:?}" + ); + let target = serde_json::to_string(&value["target"]).unwrap(); + assert!( + seen.insert(target.clone()), + "duplicate target in {workflow}: {target}" + ); + assert!(expected.contains(&target), "unexpected target in {path:?}"); + assert_eq!(value["checks"], aggregate["required_checks"], "{path:?}"); + } + assert_eq!(seen, expected, "{prefix} target parity"); + } +} From a37bb5fbb61a901c43d666d292d9f1fd4c1c085f Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 22:00:26 -0400 Subject: [PATCH 07/11] fix(ci): normalize Windows receipt paths Git Bash receives Windows-style RUNNER_TEMP values, so normalize them before snapshot and receipt filesystem operations while retaining native artifact upload paths. --- .github/actions/emit-receipt/action.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/actions/emit-receipt/action.yml b/.github/actions/emit-receipt/action.yml index 42d5bdb5..4d457cee 100644 --- a/.github/actions/emit-receipt/action.yml +++ b/.github/actions/emit-receipt/action.yml @@ -31,15 +31,20 @@ runs: run: | set -euo pipefail [[ "$RECEIPT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "invalid receipt-id" >&2; exit 2; } - target="$RUNNER_TEMP/sbom-receipt-source" + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp=$(cygpath -u "$RUNNER_TEMP") + fi + target="$runner_temp/sbom-receipt-source" test ! -e "$target" mkdir -p "$target" git archive "$RECEIPT_SHA" | tar -x -C "$target" - mkdir -p "$RUNNER_TEMP/sbom-receipts" - target="$RUNNER_TEMP/sbom-receipts/$RECEIPT_ID.json" + mkdir -p "$runner_temp/sbom-receipts" + target="$runner_temp/sbom-receipts/$RECEIPT_ID.json" bin=target/debug/sbom-tools; [[ -x "$bin.exe" ]] && bin="$bin.exe" - "$bin" verify receipt-context --repository "$RECEIPT_REPOSITORY" --commit-sha "$RECEIPT_SHA" --event-name "$RECEIPT_EVENT" --ref-name "$RECEIPT_REF" --default-branch "$RECEIPT_DEFAULT_BRANCH" --head-repository "${RECEIPT_HEAD_REPOSITORY:-}" --output "${RUNNER_TEMP}/sbom-receipt-context.json" - args=(verify receipt-job --manifest "$RECEIPT_MANIFEST" --context "${RUNNER_TEMP}/sbom-receipt-context.json" --output "$target" --outcome "producer=$RECEIPT_STATUS" --runner-os "$RECEIPT_RUNNER_OS" --runner-arch "$RECEIPT_RUNNER_ARCH") + context="$runner_temp/sbom-receipt-context.json" + "$bin" verify receipt-context --repository "$RECEIPT_REPOSITORY" --commit-sha "$RECEIPT_SHA" --event-name "$RECEIPT_EVENT" --ref-name "$RECEIPT_REF" --default-branch "$RECEIPT_DEFAULT_BRANCH" --head-repository "${RECEIPT_HEAD_REPOSITORY:-}" --output "$context" + args=(verify receipt-job --manifest "$RECEIPT_MANIFEST" --context "$context" --output "$target" --outcome "producer=$RECEIPT_STATUS" --runner-os "$RECEIPT_RUNNER_OS" --runner-arch "$RECEIPT_RUNNER_ARCH") "$bin" "${args[@]}" - name: Upload receipt if: always() From 54fabfd5c326260f4a8da4940af4901110d4423d Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 22:20:00 -0400 Subject: [PATCH 08/11] fix(ci): canonicalize receipt fingerprint paths Encode source and lock path identities with forward-slash-separated UTF-8 components before hashing. This keeps identical Git archives fingerprint-equivalent across Linux, macOS, and Windows runners. Refs: #350 --- .../pipeline_receipt_fingerprint.rs | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/verification/pipeline_receipt_fingerprint.rs b/src/verification/pipeline_receipt_fingerprint.rs index 461ff4b5..84e60d67 100644 --- a/src/verification/pipeline_receipt_fingerprint.rs +++ b/src/verification/pipeline_receipt_fingerprint.rs @@ -98,18 +98,16 @@ fn collect_source_files( } fn fingerprint_files(root: &Path, mut files: Vec) -> Result { - files.sort_by(|a, b| { - a.strip_prefix(root) - .map_or(Path::new(""), |path| path) - .cmp(b.strip_prefix(root).map_or(Path::new(""), |path| path)) - }); + let mut files = files + .drain(..) + .map(|path| { + let identity = relative_path_identity(root, &path)?; + Ok((identity, path)) + }) + .collect::, ReceiptError>>()?; + files.sort_by(|(a, _), (b, _)| a.cmp(b)); let mut input = Vec::new(); - for path in files { - let rel = path - .strip_prefix(root) - .map_err(|_| ReceiptError::Contract("path escaped root".into()))? - .to_str() - .ok_or_else(|| ReceiptError::Contract("non-UTF8 source path".into()))?; + for (rel, path) in files { let bytes = fs::read(&path).map_err(|source| ReceiptError::Io { path: path.clone(), source, @@ -121,3 +119,37 @@ fn fingerprint_files(root: &Path, mut files: Vec) -> Result Result { + let relative = path + .strip_prefix(root) + .map_err(|_| ReceiptError::Contract("path escaped root".into()))?; + let components = relative + .components() + .map(|component| { + component + .as_os_str() + .to_str() + .ok_or_else(|| ReceiptError::Contract("non-UTF8 source path".into())) + }) + .collect::, ReceiptError>>()?; + if components.is_empty() { + return Err(ReceiptError::Contract("empty relative path".into())); + } + Ok(components.join("/")) +} + +#[cfg(test)] +mod tests { + use super::relative_path_identity; + use std::path::Path; + + #[test] + fn relative_path_identity_uses_forward_slashes() { + assert_eq!( + relative_path_identity(Path::new("/workspace"), Path::new("/workspace/nested/file")) + .unwrap(), + "nested/file" + ); + } +} From 24bd0b6222013843632dcf62bdb833a558644a27 Mon Sep 17 00:00:00 2001 From: Mohamed Chorfa Date: Sat, 29 Aug 2026 22:35:45 -0400 Subject: [PATCH 09/11] fix(ci): normalize receipt archive contents Disable checkout line-ending conversion while creating exact-SHA receipt snapshots. Add an action contract test that rejects environment-dependent archive generation. Refs: #350 --- .github/actions/emit-receipt/action.yml | 4 +++- .../pipeline_receipt_action_contract_tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/pipeline_receipt_action_contract_tests.rs diff --git a/.github/actions/emit-receipt/action.yml b/.github/actions/emit-receipt/action.yml index 4d457cee..9b030d09 100644 --- a/.github/actions/emit-receipt/action.yml +++ b/.github/actions/emit-receipt/action.yml @@ -38,7 +38,9 @@ runs: target="$runner_temp/sbom-receipt-source" test ! -e "$target" mkdir -p "$target" - git archive "$RECEIPT_SHA" | tar -x -C "$target" + # Keep the source snapshot identical on GitHub-hosted Windows and Unix + # runners; checkout autocrlf settings must not alter receipt digests. + git -c core.autocrlf=false -c core.eol=lf archive "$RECEIPT_SHA" | tar -x -C "$target" mkdir -p "$runner_temp/sbom-receipts" target="$runner_temp/sbom-receipts/$RECEIPT_ID.json" bin=target/debug/sbom-tools; [[ -x "$bin.exe" ]] && bin="$bin.exe" diff --git a/tests/pipeline_receipt_action_contract_tests.rs b/tests/pipeline_receipt_action_contract_tests.rs new file mode 100644 index 00000000..818b22de --- /dev/null +++ b/tests/pipeline_receipt_action_contract_tests.rs @@ -0,0 +1,19 @@ +use std::fs; + +#[test] +fn receipt_action_uses_canonical_archive_configuration() { + let action = fs::read_to_string(".github/actions/emit-receipt/action.yml") + .expect("receipt action must be present"); + + assert!( + action.contains( + "git -c core.autocrlf=false -c core.eol=lf archive \"$RECEIPT_SHA\" | tar -x -C \"$target\"" + ), + "receipt source snapshot must be independent of runner autocrlf settings" + ); + assert!( + !action.contains("\ngit archive \"$RECEIPT_SHA\"") + && !action.contains("\n git archive \"$RECEIPT_SHA\""), + "receipt action must not regress to an environment-dependent archive" + ); +} From 797eac432aa41fdc72653d92d0b2c5d97700c7d5 Mon Sep 17 00:00:00 2001 From: Alex Matrosov Date: Sun, 30 Aug 2026 20:55:36 -0700 Subject: [PATCH 10/11] fix(receipts): close review defects in the v1 contract layer Addresses the nine confirmed findings from the maintainer review on issue #350 (see the defect-list comment there): - Root-anchor the source-fingerprint exclusions and make them directory-only; symlinks are rejected before exclusion so an excluded name cannot mask one. Nested vendored target/ dirs and files named "receipts" now stay in the evidence. - Unify exit-code classification: read_receipt and the aggregate policy loader now two-phase parse like the generator paths, so malformed JSON is operational (exit 3) and readable-but-violating documents are gate verdicts (exit 1) on every receipt subcommand. - Align serde strictness with the published schemas: nullable fields the schemas list as required (run_id, dagger_trace, failure_classification, binding_runtime, head_repository, hosted) now require key presence; is_portable_scope matches the schema pattern exactly (segments must start alphanumeric). - Accept the unambiguous github.ref_name short forms (N/merge, default branch) in hosted classification; bare tag names stay rejected with a pointer at the canonical full-ref form. Documented in both schemas. - Publish the two missing contracts: pipeline-shard-job-manifest/v1 gets a schema file, and AggregatePolicy gains a schema discriminator (aggregate-policy/v1) with its own schema file, enforced on load. - Bind schemas to code with a new test suite (pipeline_receipt_schema_binding_tests) covering pattern agreement, property/required parity, presence-strictness, and the checked-in job manifests. - Replace the unsafe env::set_var test (UB under parallel libtest) with a subprocess that injects RUNNER_TEMP via child environment. - Pin core.autocrlf/core.eol on the aggregate jobs' git archive so their snapshot matches emit-receipt byte-for-byte, with a contract test covering both workflows. - Restore the deliberate advisories gating policy: cargo-deny advisories stays informational (continue-on-error) and leaves the receipt evidence set entirely, since a failed receipt would fail the aggregate closed and re-block PRs on surprise CVEs indirectly. - Bound the new build cost: producer and aggregate jobs that build the receipt CLI share a receipt-cli rust-cache. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Lxk48j7VVWwJr3QUZEVm6 --- .../receipts/jobs/rust-deny-advisories.json | 1 - .github/receipts/rust-policy-manifest.json | 1 - .github/workflows/bindings.yml | 15 +- .github/workflows/rust.yml | 35 ++- docs/pipeline-receipts.md | 46 +++- .../aggregate-policy-context/v1.schema.json | 2 +- schemas/aggregate-policy/v1.schema.json | 21 ++ .../v1.schema.json | 22 ++ .../input-v1.schema.json | 2 +- src/cli/verify.rs | 16 +- src/verification/mod.rs | 4 +- src/verification/pipeline_receipt.rs | 52 +++- .../pipeline_receipt_fingerprint.rs | 23 +- .../pipeline_receipt_generator.rs | 25 +- src/verification/pipeline_receipt_io.rs | 14 +- src/verification/pipeline_receipt_policy.rs | 5 + .../pipeline_receipt_policy_generator.rs | 1 + .../pipeline_receipt_action_contract_tests.rs | 24 ++ tests/pipeline_receipt_contract_tests.rs | 20 +- tests/pipeline_receipt_generator_tests.rs | 14 + tests/pipeline_receipt_job_tests.rs | 45 ++-- .../pipeline_receipt_schema_binding_tests.rs | 243 ++++++++++++++++++ 22 files changed, 558 insertions(+), 73 deletions(-) delete mode 100644 .github/receipts/jobs/rust-deny-advisories.json create mode 100644 schemas/aggregate-policy/v1.schema.json create mode 100644 schemas/pipeline-shard-job-manifest/v1.schema.json create mode 100644 tests/pipeline_receipt_schema_binding_tests.rs diff --git a/.github/receipts/jobs/rust-deny-advisories.json b/.github/receipts/jobs/rust-deny-advisories.json deleted file mode 100644 index ca40c3e1..00000000 --- a/.github/receipts/jobs/rust-deny-advisories.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"pipeline-shard-job-manifest/v1","workflow":"CI","source_root":"$RUNNER_TEMP/sbom-receipt-source","lock_paths":["Cargo.lock","rust-toolchain.toml"],"artifact_root":"$RUNNER_TEMP/sbom-receipt-source","artifacts":[],"target":{"verification_scope":"rust-deny-advisories","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null},"checks":["producer"]} diff --git a/.github/receipts/rust-policy-manifest.json b/.github/receipts/rust-policy-manifest.json index 83a9a41c..01fce311 100644 --- a/.github/receipts/rust-policy-manifest.json +++ b/.github/receipts/rust-policy-manifest.json @@ -11,7 +11,6 @@ {"verification_scope":"rust-test-macos-stable","os":"macos","architecture":"arm64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, {"verification_scope":"rust-test-windows-stable","os":"windows","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, {"verification_scope":"rust-test-ubuntu-beta","os":"linux","architecture":"x86_64","toolchain":"beta","profile":"debug","features":[],"binding_runtime":null}, - {"verification_scope":"rust-deny-advisories","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, {"verification_scope":"rust-deny-licenses-sources","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null}, {"verification_scope":"rust-audit","os":"linux","architecture":"x86_64","toolchain":"stable","profile":"debug","features":[],"binding_runtime":null} ], diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml index e137e65e..18096724 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings.yml @@ -19,6 +19,11 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: receipt-cli + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build receipt CLI run: cargo build --locked --bin sbom-tools - name: Check wrapper constants match sbom_tools.h @@ -272,6 +277,12 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Cache + if: always() + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: receipt-cli + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Download producer receipts if: always() uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -284,7 +295,9 @@ jobs: run: | cargo build --locked --bin sbom-tools test ! -e "$RUNNER_TEMP/sbom-receipt-source"; mkdir -p "$RUNNER_TEMP/sbom-receipt-source" - git archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" + # Must match emit-receipt's snapshot byte-for-byte: the policy's + # source fingerprint is compared against every producer receipt. + git -c core.autocrlf=false -c core.eol=lf archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" - name: Generate aggregate policy if: always() env: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index aaaf1951..804a6096 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -130,29 +130,41 @@ jobs: checks: - advisories - bans licenses sources + # Advisory failures are informational — don't block PRs on surprise CVEs. + # The advisories leg therefore emits no receipt and is not part of the + # aggregate evidence set; a failed receipt would fail the gate closed and + # reintroduce the blocking behavior through the back door. + continue-on-error: ${{ matrix.checks == 'advisories' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust + if: matrix.checks != 'advisories' uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Cache + if: matrix.checks != 'advisories' + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: receipt-cli + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build receipt CLI + if: matrix.checks != 'advisories' run: cargo build --locked --bin sbom-tools - name: cargo-deny id: cargo_deny - # Advisory and license/source checks are required gates (issue #350). uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 with: command: check ${{ matrix.checks }} arguments: --all-features - name: Emit receipt - if: always() + if: always() && matrix.checks != 'advisories' uses: ./.github/actions/emit-receipt with: - manifest: .github/receipts/jobs/rust-deny-${{ matrix.checks == 'advisories' && 'advisories' || 'licenses-sources' }}.json + manifest: .github/receipts/jobs/rust-deny-licenses-sources.json artifact-name: receipts-${{ github.run_id }}-${{ github.job }}-${{ matrix.checks }} - receipt-id: rust-deny-${{ matrix.checks == 'advisories' && 'advisories' || 'licenses-sources' }} + receipt-id: rust-deny-licenses-sources outcome: ${{ steps.cargo_deny.outcome }} # ── Security audit ──────────────────────────────────────────── @@ -166,6 +178,11 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: receipt-cli + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build receipt CLI run: cargo build --locked --bin sbom-tools - name: Install cargo-audit @@ -197,6 +214,12 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable + - name: Cache + if: always() + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: receipt-cli + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Download producer receipts if: always() uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -209,7 +232,9 @@ jobs: run: | cargo build --locked --bin sbom-tools test ! -e "$RUNNER_TEMP/sbom-receipt-source"; mkdir -p "$RUNNER_TEMP/sbom-receipt-source" - git archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" + # Must match emit-receipt's snapshot byte-for-byte: the policy's + # source fingerprint is compared against every producer receipt. + git -c core.autocrlf=false -c core.eol=lf archive "${{ github.sha }}" | tar -x -C "$RUNNER_TEMP/sbom-receipt-source" - name: Generate aggregate policy if: always() env: diff --git a/docs/pipeline-receipts.md b/docs/pipeline-receipts.md index 2814fcb7..cfcf711e 100644 --- a/docs/pipeline-receipts.md +++ b/docs/pipeline-receipts.md @@ -13,8 +13,22 @@ sbom-tools verify receipt-aggregate receipts/ --policy aggregate-policy.json sbom-tools verify receipt-generate --input receipt-input.json --output receipt.json sbom-tools verify receipt-policy-generate --manifest policy-manifest.json \ --context policy-context.json --output aggregate-policy.json +sbom-tools verify receipt-context --repository org/repo --commit-sha \ + --event-name push --ref-name refs/heads/main --default-branch main \ + --output context.json +sbom-tools verify receipt-job --manifest job-manifest.json --context context.json \ + --outcome producer=success --output receipt.json ``` +`receipt-context` and `receipt-job` are the two subcommands CI actually runs: +the emit-receipt action writes a strict `aggregate-policy-context/v1` file from +CI-provided values, then generates one receipt from a checked-in +`pipeline-shard-job-manifest/v1` file (schema in +`schemas/pipeline-shard-job-manifest/v1.schema.json`) plus the job outcome. +Hosted `ref_name` canonically carries the full `github.ref` form; the +unambiguous `github.ref_name` short forms (`N/merge`, the default branch name) +are also accepted, and a bare tag name is rejected as ambiguous. + The generator input is the strict JSON contract in `schemas/pipeline-shard-receipt/input-v1.schema.json` (schema identifier `pipeline-shard-receipt-input/v1`). It contains repository/workflow and commit @@ -29,10 +43,14 @@ repository must equal `repository`. The generator cannot authorize promotion. Receipts are unsigned and are non-promotion evidence; this slice rejects `promotable: true`. Slice 5 must introduce signed promotion authority. `source_fingerprint(root)` includes path-qualified file names and contents while -excluding `.git`, `target`, and `receipts` directories. `lock_fingerprint(root)` +excluding only the top-level `.git/`, `target/`, and `receipts/` directories — +the exclusions are root-anchored and directory-only, so a vendored +`foo/target/` or a source file named `receipts` stays in the evidence, and a +symlink is rejected even when it carries an excluded name. Relative paths are +hashed with `/` separators regardless of host OS. `lock_fingerprint(root)` uses the applicable Cargo, toolchain, binding, and Dagger lock inputs. Both functions sort paths before hashing, so the result is reproducible across -filesystems. +filesystems and operating systems. Receipts created from a local working tree must use `trust_context: "local"` and `promotable: false`. Local receipts are useful for reproduction but cannot pass @@ -45,12 +63,16 @@ separate from receipt and artifact identities; cache contents are never accepted as verification evidence. Rust and Bindings workflows now emit one unsigned receipt per producer target with `if: always()`, preserving native OS execution and the existing Rust CI check name. Bindings fan-in is additive and validates -all producer receipts without filename collisions. Cargo advisories intentionally -transition from informational to fail-closed under issue #350. +all producer receipts without filename collisions. The cargo-deny advisories +leg stays informational (`continue-on-error`) by explicit maintainer policy and +emits no receipt: surprise upstream CVEs must not hard-block unrelated PRs, and +a failed receipt in the evidence set would fail the aggregate closed and +reintroduce that blocking behavior indirectly. Hosted failure/cancel and native cross-platform proof remain pending acceptance -gates. The wiring adds 21 extra CLI builds (one per producer), which is -unmeasured overhead; no acceleration or savings claim is made. +gates. Producer and aggregate jobs share a `receipt-cli` build cache to bound +the cost of the per-producer CLI builds; no acceleration or savings claim is +made. Fingerprinting reads ordinary filesystem paths and is non-atomic: a file can change between enumeration and reading, so this is subject to local TOCTOU @@ -60,11 +82,13 @@ The generator is deliberately local/non-promotable; aggregation trusts only the externally supplied expected digests (they are policy inputs, not recomputed by the aggregate command). -The aggregate policy is JSON with exactly `expected_targets`, `context`, -`required_checks`, and `artifacts` fields; each field uses the corresponding -receipt contract shape. Unknown fields are rejected. I/O and malformed JSON are -operational errors; a readable receipt or policy that violates the contract is -a gate verdict and exits 1. Aggregate success, including an explicitly expected +The aggregate policy is JSON with exactly `schema` (`aggregate-policy/v1`, +published in `schemas/aggregate-policy/v1.schema.json`), `expected_targets`, +`context`, `required_checks`, and `artifacts` fields; each field uses the +corresponding receipt contract shape. Unknown fields are rejected. I/O and +malformed JSON are operational errors and exit 3; readable JSON that violates +any contract shape — a receipt, policy, manifest, or generator input — is a +gate verdict and exits 1, uniformly across all receipt subcommands. Aggregate success, including an explicitly expected `trust_context: "local"`, is verification only and never a promotion decision in v1. diff --git a/schemas/aggregate-policy-context/v1.schema.json b/schemas/aggregate-policy-context/v1.schema.json index af6f9fa6..7c4b120f 100644 --- a/schemas/aggregate-policy-context/v1.schema.json +++ b/schemas/aggregate-policy-context/v1.schema.json @@ -3,5 +3,5 @@ "$id":"aggregate-policy-context/v1", "type":"object","additionalProperties":false, "required":["schema","repository","commit_sha","hosted","local"], - "properties":{"schema":{"const":"aggregate-policy-context/v1"},"repository":{"type":"string","minLength":1},"commit_sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"local":{"type":"boolean"},"hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}} + "properties":{"schema":{"const":"aggregate-policy-context/v1"},"repository":{"type":"string","minLength":1},"commit_sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"local":{"type":"boolean"},"hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1,"description":"Canonical full github.ref form (refs/pull/N/merge, refs/heads/, refs/tags/); the unambiguous github.ref_name short forms N/merge and the default branch name are also accepted. Bare tag names are rejected as ambiguous."},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}} } diff --git a/schemas/aggregate-policy/v1.schema.json b/schemas/aggregate-policy/v1.schema.json new file mode 100644 index 00000000..2cc7da25 --- /dev/null +++ b/schemas/aggregate-policy/v1.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "aggregate-policy/v1", + "title": "AggregatePolicy", + "type": "object", + "additionalProperties": false, + "required": ["schema", "expected_targets", "context", "required_checks", "artifacts"], + "properties": { + "schema": {"const": "aggregate-policy/v1"}, + "expected_targets": {"type": "array", "items": {"$ref": "#/$defs/target"}, "minItems": 1}, + "context": {"$ref": "#/$defs/context"}, + "required_checks": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}, + "artifacts": {"type": "array", "items": {"$ref": "#/$defs/artifact"}} + }, + "$defs": { + "digest": {"type":"string","pattern":"^sha256:[0-9a-f]{64}$"}, + "target": {"type":"object","additionalProperties":false,"required":["verification_scope","os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"verification_scope":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$","minLength":1},"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true},"binding_runtime":{"type":["string","null"]}}}, + "context": {"type":"object","additionalProperties":false,"required":["repository","workflow","commit_sha","trust_context","promotable","source_fingerprint","lock_digest"],"properties":{"repository":{"type":"string","minLength":1},"workflow":{"type":"string","minLength":1},"commit_sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"trust_context":{"enum":["pull-request","protected-main","release","local"]},"promotable":{"const":false},"source_fingerprint":{"$ref":"#/$defs/digest"},"lock_digest":{"$ref":"#/$defs/digest"}}}, + "artifact": {"type":"object","additionalProperties":false,"required":["name","path","size","sha256"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1},"size":{"type":"integer","minimum":0},"sha256":{"$ref":"#/$defs/digest"}}} + } +} diff --git a/schemas/pipeline-shard-job-manifest/v1.schema.json b/schemas/pipeline-shard-job-manifest/v1.schema.json new file mode 100644 index 00000000..57c45842 --- /dev/null +++ b/schemas/pipeline-shard-job-manifest/v1.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "pipeline-shard-job-manifest/v1", + "title": "ReceiptJobManifest", + "type": "object", + "additionalProperties": false, + "required": ["schema", "workflow", "source_root", "lock_paths", "artifact_root", "artifacts", "target", "checks"], + "properties": { + "schema": {"const": "pipeline-shard-job-manifest/v1"}, + "workflow": {"type": "string", "minLength": 1}, + "source_root": {"type": "string", "minLength": 1}, + "lock_paths": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}, + "artifact_root": {"type": "string", "minLength": 1}, + "artifacts": {"type": "array", "items": {"$ref": "#/$defs/artifact_input"}}, + "target": {"$ref": "#/$defs/target"}, + "checks": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1} + }, + "$defs": { + "target": {"type":"object","additionalProperties":false,"required":["verification_scope","os","architecture","toolchain","profile","features","binding_runtime"],"properties":{"verification_scope":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$","minLength":1},"os":{"type":"string","minLength":1},"architecture":{"type":"string","minLength":1},"toolchain":{"type":"string","minLength":1},"profile":{"type":"string","minLength":1},"features":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true},"binding_runtime":{"type":["string","null"]}}}, + "artifact_input": {"type":"object","additionalProperties":false,"required":["name","path"],"properties":{"name":{"type":"string","minLength":1},"path":{"type":"string","minLength":1}}} + } +} diff --git a/schemas/pipeline-shard-receipt/input-v1.schema.json b/schemas/pipeline-shard-receipt/input-v1.schema.json index 25afdb61..320d286b 100644 --- a/schemas/pipeline-shard-receipt/input-v1.schema.json +++ b/schemas/pipeline-shard-receipt/input-v1.schema.json @@ -15,7 +15,7 @@ "target": {"$ref":"#/$defs/target"}, "versions":{"type":"object","additionalProperties":{"type":"string"}}, "checks":{"type":"array","items":{"$ref":"#/$defs/check"},"minItems":1}, "started_at":{"type":"string","format":"date-time"}, "completed_at":{"type":"string","format":"date-time"}, "run_id":{"type":["string","null"]}, "dagger_trace":{"type":["string","null"]}, "failure_classification":{"type":["string","null"]}, - "hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}, + "hosted":{"type":["object","null"],"additionalProperties":false,"required":["event_name","ref_name","repository","default_branch","sha","head_repository"],"properties":{"event_name":{"type":"string","minLength":1},"ref_name":{"type":"string","minLength":1,"description":"Canonical full github.ref form (refs/pull/N/merge, refs/heads/, refs/tags/); the unambiguous github.ref_name short forms N/merge and the default branch name are also accepted. Bare tag names are rejected as ambiguous."},"repository":{"type":"string","minLength":1},"default_branch":{"type":"string","minLength":1},"sha":{"type":"string","pattern":"^[0-9a-f]{40,64}$"},"head_repository":{"type":["string","null"]}}}, "local":{"type":"boolean"} }, "$defs": { diff --git a/src/cli/verify.rs b/src/cli/verify.rs index 5eba6ddd..bf85aa68 100644 --- a/src/cli/verify.rs +++ b/src/cli/verify.rs @@ -290,7 +290,16 @@ pub fn run_verify(action: VerifyAction, quiet: bool) -> Result { Ok(exit_codes::SUCCESS) } VerifyAction::ReceiptAggregate { receipts, policy } => { - let policy = read_policy(&policy)?; + // Same exit contract as receipts: unreadable/malformed policy is + // operational (3), readable-but-violating policy is a verdict (1). + let policy = match read_json_contract::(&policy) { + Ok(value) => value, + Err(crate::verification::ReceiptError::Contract(message)) => { + eprintln!("receipt aggregation failed: {message}"); + return Ok(exit_codes::CHANGES_DETECTED); + } + Err(error) => return Err(error.into()), + }; let receipt_paths = receipt_paths(&receipts)?; let mut loaded = Vec::with_capacity(receipt_paths.len()); for path in receipt_paths { @@ -509,11 +518,6 @@ fn read_json_contract( .map_err(|source| crate::verification::ReceiptError::Contract(source.to_string())) } -fn read_policy(path: &std::path::Path) -> Result { - let bytes = std::fs::read(path)?; - Ok(serde_json::from_slice(&bytes)?) -} - fn receipt_paths(path: &std::path::Path) -> Result> { if path.is_file() { return Ok(vec![path.to_path_buf()]); diff --git a/src/verification/mod.rs b/src/verification/mod.rs index 3ba97e02..12ab50ac 100644 --- a/src/verification/mod.rs +++ b/src/verification/mod.rs @@ -23,8 +23,8 @@ pub use model_dir::{ ComponentModelVerification, ModelVerifyReport, ModelVerifyResult, verify_model_dir, }; pub use pipeline_receipt::{ - AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, HostedReceiptMetadata, - PIPELINE_SHARD_JOB_MANIFEST_SCHEMA, PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, + AGGREGATE_POLICY_SCHEMA, AggregatePolicy, AggregateVerification, CheckOutcome, ExpectedContext, + HostedReceiptMetadata, PIPELINE_SHARD_JOB_MANIFEST_SCHEMA, PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, PIPELINE_SHARD_RECEIPT_SCHEMA, PipelineShardReceipt, ReceiptArtifact, ReceiptArtifactInput, ReceiptError, ReceiptGenerationInput, ReceiptInput, Sha256Digest, TargetIdentity, TrustContext, TrustedArtifact, VerificationCheck, aggregate_receipts, validate_receipt, diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs index 3a0586dd..1c5b3f7d 100644 --- a/src/verification/pipeline_receipt.rs +++ b/src/verification/pipeline_receipt.rs @@ -14,6 +14,20 @@ use thiserror::Error; pub const PIPELINE_SHARD_RECEIPT_SCHEMA: &str = "pipeline-shard-receipt/v1"; pub const PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA: &str = "pipeline-shard-receipt-input/v1"; pub const PIPELINE_SHARD_JOB_MANIFEST_SCHEMA: &str = "pipeline-shard-job-manifest/v1"; +pub const AGGREGATE_POLICY_SCHEMA: &str = "aggregate-policy/v1"; + +/// The published JSON Schemas list every nullable field in `required`: an +/// evidence document must carry each key explicitly (as null when absent), so +/// omission is always a contract violation rather than an implicit default. +/// serde makes bare `Option` fields optional; this deserializer restores the +/// required-but-nullable semantics. +fn require_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Sha256Digest(String); @@ -88,6 +102,7 @@ pub struct TargetIdentity { pub toolchain: String, pub profile: String, pub features: Vec, + #[serde(deserialize_with = "require_nullable")] pub binding_runtime: Option, } @@ -125,6 +140,7 @@ pub struct PipelineShardReceipt { pub schema: String, pub repository: String, pub workflow: String, + #[serde(deserialize_with = "require_nullable")] pub run_id: Option, pub commit_sha: String, pub source_fingerprint: Sha256Digest, @@ -135,9 +151,11 @@ pub struct PipelineShardReceipt { pub versions: BTreeMap, pub checks: Vec, pub artifacts: Vec, + #[serde(deserialize_with = "require_nullable")] pub dagger_trace: Option, pub started_at: String, pub completed_at: String, + #[serde(deserialize_with = "require_nullable")] pub failure_classification: Option, } @@ -177,9 +195,13 @@ pub struct ReceiptGenerationInput { pub checks: Vec, pub started_at: String, pub completed_at: String, + #[serde(deserialize_with = "require_nullable")] pub run_id: Option, + #[serde(deserialize_with = "require_nullable")] pub dagger_trace: Option, + #[serde(deserialize_with = "require_nullable")] pub failure_classification: Option, + #[serde(deserialize_with = "require_nullable")] pub hosted: Option, pub local: bool, } @@ -195,10 +217,16 @@ pub struct ReceiptArtifactInput { #[serde(deny_unknown_fields)] pub struct HostedReceiptMetadata { pub event_name: String, + /// Git reference for the hosted run. The canonical value is the full + /// `github.ref` form (`refs/pull/N/merge`, `refs/heads/`, + /// `refs/tags/`); the unambiguous `github.ref_name` short forms + /// (`N/merge`, the default branch name) are also accepted. A bare tag + /// name is indistinguishable from a branch and is rejected. pub ref_name: String, pub repository: String, pub default_branch: String, pub sha: String, + #[serde(deserialize_with = "require_nullable")] pub head_repository: Option, } @@ -216,6 +244,7 @@ pub struct ExpectedContext { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AggregatePolicy { + pub schema: String, pub expected_targets: Vec, pub context: ExpectedContext, pub required_checks: Vec, @@ -309,18 +338,21 @@ pub(crate) fn validate_target(target: &TargetIdentity) -> Result<(), ReceiptErro Ok(()) } +// Must accept exactly what the published schema pattern accepts: +// ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ — each segment +// starts alphanumeric, so ".hidden", "-x", or "..." are rejected here too +// (tests/pipeline_receipt_schema_binding_tests.rs pins the agreement). fn is_portable_scope(value: &str) -> bool { !value.is_empty() - && value.is_ascii() - && !value.contains(char::is_whitespace) - && !value.contains('\\') - && !value.starts_with('/') - && !value - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..") - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/')) + && value.split('/').all(|segment| { + segment + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) } fn validate_commit(value: &str) -> Result<(), ReceiptError> { if !(40..=64).contains(&value.len()) diff --git a/src/verification/pipeline_receipt_fingerprint.rs b/src/verification/pipeline_receipt_fingerprint.rs index 84e60d67..e58b4a13 100644 --- a/src/verification/pipeline_receipt_fingerprint.rs +++ b/src/verification/pipeline_receipt_fingerprint.rs @@ -70,14 +70,8 @@ fn collect_source_files( let rel = path .strip_prefix(root) .map_err(|_| ReceiptError::Contract("path escaped root".into()))?; - if rel.components().any(|component| { - matches!( - component.as_os_str().to_str(), - Some(".git" | "target" | "receipts") - ) - }) { - continue; - } + // Symlinks are rejected before any exclusion so a symlink named after + // an excluded directory cannot slip past the fail-closed check. let metadata = fs::symlink_metadata(&path).map_err(|source| ReceiptError::Io { path: path.clone(), source, @@ -85,6 +79,19 @@ fn collect_source_files( if metadata.file_type().is_symlink() { return Err(ReceiptError::Contract("symlink in source tree".into())); } + // Exclusions are root-anchored and directory-only: only the top-level + // .git/, target/, and receipts/ directories are generated state. A + // nested vendored `foo/target/` or a source FILE named `receipts` is + // evidence and must stay in the fingerprint. + if metadata.is_dir() + && rel.components().count() == 1 + && matches!( + rel.as_os_str().to_str(), + Some(".git" | "target" | "receipts") + ) + { + continue; + } if rel.to_str().is_none_or(|value| value.is_empty()) { return Err(ReceiptError::Contract("non-UTF8 source path".into())); } diff --git a/src/verification/pipeline_receipt_generator.rs b/src/verification/pipeline_receipt_generator.rs index fbf88292..540482bd 100644 --- a/src/verification/pipeline_receipt_generator.rs +++ b/src/verification/pipeline_receipt_generator.rs @@ -74,7 +74,7 @@ fn classify_hosted_event( ) -> Result<(TrustContext, bool), ReceiptError> { match metadata.event_name.as_str() { "pull_request" | "pull_request_target" - if metadata.ref_name.starts_with("refs/pull/") + if is_pull_request_ref(&metadata.ref_name) && metadata .head_repository .as_deref() @@ -82,7 +82,10 @@ fn classify_hosted_event( { Ok((TrustContext::PullRequest, false)) } - "push" if metadata.ref_name == format!("refs/heads/{}", metadata.default_branch) => { + "push" + if metadata.ref_name == format!("refs/heads/{}", metadata.default_branch) + || metadata.ref_name == metadata.default_branch => + { Ok((TrustContext::ProtectedMain, false)) } "push" @@ -94,12 +97,28 @@ fn classify_hosted_event( "pull_request" | "pull_request_target" => Err(ReceiptError::Contract( "ambiguous pull request metadata".into(), )), + // A bare `github.ref_name` for a tag is just the tag name, which is + // indistinguishable from a non-default branch — fail closed and point + // at the canonical form instead of guessing. _ => Err(ReceiptError::Contract( - "unsupported or ambiguous hosted event".into(), + "unsupported or ambiguous hosted event (tag pushes require the full \ + github.ref form, e.g. refs/tags/)" + .into(), )), } } +/// Accept both the full `github.ref` form (`refs/pull/N/merge`) and the +/// `github.ref_name` short form (`N/merge`) for pull-request runs. +fn is_pull_request_ref(ref_name: &str) -> bool { + if ref_name.starts_with("refs/pull/") { + return ref_name.len() > "refs/pull/".len(); + } + ref_name + .strip_suffix("/merge") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + pub fn generate_receipt_from_descriptor( descriptor: ReceiptGenerationInput, ) -> Result { diff --git a/src/verification/pipeline_receipt_io.rs b/src/verification/pipeline_receipt_io.rs index d79a8fe0..90a8e90f 100644 --- a/src/verification/pipeline_receipt_io.rs +++ b/src/verification/pipeline_receipt_io.rs @@ -27,10 +27,16 @@ pub fn read_receipt(path: &Path) -> Result { path: path.into(), source, })?; - let receipt = serde_json::from_slice(&bytes).map_err(|source| ReceiptError::Json { - path: path.into(), - source, - })?; + // Two-phase parse keeps the documented exit split intact: bytes that are + // not JSON are an operational error (Json -> exit 3), while readable JSON + // that violates the receipt shape is a gate verdict (Contract -> exit 1). + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|source| ReceiptError::Json { + path: path.into(), + source, + })?; + let receipt: PipelineShardReceipt = serde_json::from_value(value) + .map_err(|source| ReceiptError::Contract(source.to_string()))?; validate_receipt(&receipt)?; Ok(receipt) } diff --git a/src/verification/pipeline_receipt_policy.rs b/src/verification/pipeline_receipt_policy.rs index d79e1103..e9d57122 100644 --- a/src/verification/pipeline_receipt_policy.rs +++ b/src/verification/pipeline_receipt_policy.rs @@ -4,6 +4,11 @@ use super::pipeline_receipt::{ use std::collections::BTreeSet; pub(crate) fn validate_policy(policy: &AggregatePolicy) -> Result<(), ReceiptError> { + if policy.schema != super::pipeline_receipt::AGGREGATE_POLICY_SCHEMA { + return Err(ReceiptError::Contract( + "unsupported aggregate policy schema".into(), + )); + } if policy.expected_targets.is_empty() || policy.required_checks.is_empty() { return Err(ReceiptError::Contract( "aggregate policy targets and required checks must be nonempty".into(), diff --git a/src/verification/pipeline_receipt_policy_generator.rs b/src/verification/pipeline_receipt_policy_generator.rs index 17e4586a..63615324 100644 --- a/src/verification/pipeline_receipt_policy_generator.rs +++ b/src/verification/pipeline_receipt_policy_generator.rs @@ -77,6 +77,7 @@ pub fn generate_policy( let mut artifacts = artifacts; artifacts.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path))); Ok(AggregatePolicy { + schema: super::pipeline_receipt::AGGREGATE_POLICY_SCHEMA.into(), expected_targets: targets, context: ExpectedContext { repository: context.repository, diff --git a/tests/pipeline_receipt_action_contract_tests.rs b/tests/pipeline_receipt_action_contract_tests.rs index 818b22de..98ceb890 100644 --- a/tests/pipeline_receipt_action_contract_tests.rs +++ b/tests/pipeline_receipt_action_contract_tests.rs @@ -17,3 +17,27 @@ fn receipt_action_uses_canonical_archive_configuration() { "receipt action must not regress to an environment-dependent archive" ); } + +#[test] +fn aggregate_jobs_use_canonical_archive_configuration() { + // The aggregate policy's source fingerprint is compared byte-for-byte + // against every producer receipt, so the aggregate jobs' snapshot must + // use the same canonical archive flags as emit-receipt. + for path in [ + ".github/workflows/rust.yml", + ".github/workflows/bindings.yml", + ] { + let workflow = fs::read_to_string(path).expect("workflow must be present"); + let archives: Vec<&str> = workflow + .lines() + .filter(|line| line.contains("git") && line.contains("archive \"${{ github.sha }}\"")) + .collect(); + assert!(!archives.is_empty(), "{path}: aggregate snapshot missing"); + for line in archives { + assert!( + line.contains("git -c core.autocrlf=false -c core.eol=lf archive"), + "{path}: aggregate snapshot must pin autocrlf/eol: {line}" + ); + } + } +} diff --git a/tests/pipeline_receipt_contract_tests.rs b/tests/pipeline_receipt_contract_tests.rs index 7042d978..5ca698ae 100644 --- a/tests/pipeline_receipt_contract_tests.rs +++ b/tests/pipeline_receipt_contract_tests.rs @@ -48,6 +48,7 @@ fn receipt(source: Sha256Digest, lock: Sha256Digest) -> PipelineShardReceipt { } fn policy(source: Sha256Digest, lock: Sha256Digest) -> AggregatePolicy { AggregatePolicy { + schema: AGGREGATE_POLICY_SCHEMA.into(), expected_targets: vec![target()], context: ExpectedContext { repository: "org/repo".into(), @@ -225,7 +226,24 @@ fn fixture_and_scope_segments_validate() { let fixture = include_str!("fixtures/pipeline-shard-receipt-v1.json"); let receipt: PipelineShardReceipt = serde_json::from_str(fixture).unwrap(); validate_receipt(&receipt).unwrap(); - for scope in ["a//b", ".", "..", "a/./b", "a/../b", "/a", "a\\b"] { + // Includes the segments the published schema pattern rejects but a looser + // validator would accept: leading '.', '-', '_' and dot-only segments. + for scope in [ + "a//b", + ".", + "..", + "a/./b", + "a/../b", + "/a", + "a\\b", + "...", + "-x", + ".hidden", + "_x", + "ok/.hidden", + "a/", + "a b", + ] { let mut receipt = receipt.clone(); receipt.target.verification_scope = scope.into(); assert!(validate_receipt(&receipt).is_err(), "accepted {scope}"); diff --git a/tests/pipeline_receipt_generator_tests.rs b/tests/pipeline_receipt_generator_tests.rs index 4807e100..41dd7088 100644 --- a/tests/pipeline_receipt_generator_tests.rs +++ b/tests/pipeline_receipt_generator_tests.rs @@ -90,6 +90,9 @@ fn hosted_contexts_cover_pr_fork_main_and_release() { ), ("push", "refs/heads/main", TrustContext::ProtectedMain), ("push", "refs/tags/v1.0.0", TrustContext::Release), + // github.ref_name short forms, accepted where unambiguous. + ("pull_request", "7/merge", TrustContext::PullRequest), + ("push", "main", TrustContext::ProtectedMain), ] { let receipt = generate_receipt_from_descriptor(descriptor( dir.path(), @@ -122,6 +125,17 @@ fn hosted_metadata_is_authoritative_and_rejects_mismatch_or_ambiguity() { false, ); assert!(generate_receipt_from_descriptor(unsupported).is_err()); + // A bare github.ref_name for a tag push is indistinguishable from a + // non-default branch name and must fail closed. + let bare_tag = descriptor(dir.path(), Some(hosted("push", "v1.0.0")), false); + assert!(generate_receipt_from_descriptor(bare_tag).is_err()); + // A merge-suffixed but non-numeric short ref is not a PR ref. + let fake_pr = descriptor( + dir.path(), + Some(hosted("pull_request", "feature/merge")), + false, + ); + assert!(generate_receipt_from_descriptor(fake_pr).is_err()); } #[test] diff --git a/tests/pipeline_receipt_job_tests.rs b/tests/pipeline_receipt_job_tests.rs index 72639e45..0fff9c8e 100644 --- a/tests/pipeline_receipt_job_tests.rs +++ b/tests/pipeline_receipt_job_tests.rs @@ -149,29 +149,38 @@ fn cli_root_dispatches_receipt_context() { #[test] fn source_token_resolves_from_runner_temp() { + // RUNNER_TEMP is injected into a child process instead of set_var in this + // one: libtest runs sibling tests on other threads, and mutating the + // process environment races their getenv calls (tempfile reads TMPDIR). let dir = fixture(); - let source = dir.path().join("source"); + let source = dir.path().join("sbom-receipt-source"); fs::create_dir(&source).unwrap(); fs::write(source.join("Cargo.lock"), b"lock").unwrap(); - // SAFETY: this test runs single-threaded with a private temporary directory. - unsafe { std::env::set_var("RUNNER_TEMP", dir.path()) }; - fs::rename( - dir.path().join("source"), - dir.path().join("sbom-receipt-source"), - ) - .unwrap(); let mut m = manifest("$RUNNER_TEMP/sbom-receipt-source".into()); m.artifact_root = m.source_root.clone(); - assert!( - generate_job_receipt( - m, - context(), - &[("producer".into(), "success".into())], - None, - None - ) - .is_ok() - ); + let manifest_path = dir.path().join("manifest.json"); + let context_path = dir.path().join("context.json"); + let output_path = dir.path().join("receipt.json"); + fs::write(&manifest_path, serde_json::to_vec(&m).unwrap()).unwrap(); + fs::write(&context_path, serde_json::to_vec(&context()).unwrap()).unwrap(); + let status = std::process::Command::new(env!("CARGO_BIN_EXE_sbom-tools")) + .env("RUNNER_TEMP", dir.path()) + .args([ + "verify", + "receipt-job", + "--manifest", + manifest_path.to_str().unwrap(), + "--context", + context_path.to_str().unwrap(), + "--outcome", + "producer=success", + "--output", + output_path.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success()); + assert!(read_receipt(&output_path).is_ok()); } #[test] diff --git a/tests/pipeline_receipt_schema_binding_tests.rs b/tests/pipeline_receipt_schema_binding_tests.rs new file mode 100644 index 00000000..5ba0d9a9 --- /dev/null +++ b/tests/pipeline_receipt_schema_binding_tests.rs @@ -0,0 +1,243 @@ +//! Bind the published JSON Schemas to the Rust validators. +//! +//! The schemas under schemas/ are the versioned public contract; the serde +//! structs and hand-rolled validators are the enforcement. Nothing else keeps +//! them aligned, so these tests fail on any one-sided edit: a schema pattern +//! the code does not enforce, a field the code requires but the schema +//! doesn't (or vice versa), or a fixture that drifts from both. + +use regex::Regex; +use sbom_tools::verification::*; +use serde_json::Value; +use std::collections::BTreeMap; + +fn schema_json(path: &str) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +fn digest(byte: u8) -> Sha256Digest { + Sha256Digest::new(format!( + "sha256:{}", + char::from(byte).to_string().repeat(64) + )) + .unwrap() +} + +fn receipt() -> PipelineShardReceipt { + PipelineShardReceipt { + schema: PIPELINE_SHARD_RECEIPT_SCHEMA.into(), + repository: "org/repo".into(), + workflow: "ci".into(), + run_id: None, + commit_sha: "a".repeat(40), + source_fingerprint: digest(b'a'), + trust_context: TrustContext::ProtectedMain, + promotable: false, + target: TargetIdentity { + verification_scope: "unit".into(), + os: "linux".into(), + architecture: "x86_64".into(), + toolchain: "stable".into(), + profile: "release".into(), + features: vec![], + binding_runtime: None, + }, + lock_digest: digest(b'b'), + versions: BTreeMap::new(), + checks: vec![VerificationCheck { + name: "build".into(), + outcome: CheckOutcome::Passed, + passed: 1, + failed: 0, + ignored: 0, + }], + artifacts: vec![], + dagger_trace: None, + started_at: "2026-01-01T00:00:00Z".into(), + completed_at: "2026-01-01T00:01:00Z".into(), + failure_classification: None, + } +} + +fn keys(value: &Value) -> Vec { + let mut keys: Vec = value.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + keys +} + +fn schema_string_list(value: &Value) -> Vec { + let mut list: Vec = value + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_owned()) + .collect(); + list.sort(); + list +} + +#[test] +fn verification_scope_validator_agrees_with_schema_pattern() { + let schema = schema_json("schemas/pipeline-shard-receipt/v1.schema.json"); + let pattern = schema["$defs"]["target"]["properties"]["verification_scope"]["pattern"] + .as_str() + .unwrap(); + let regex = Regex::new(pattern).unwrap(); + // Corpus covering both sides of every historical divergence: dot-only, + // leading-punctuation, and structural (slash/empty/traversal) cases. + let corpus = [ + "ok", + "a/b", + "A1/b.c-d_e", + "rust-lint", + "x.y", + "9start", + "x.", + "a/b/c", + "...", + "-x", + ".hidden", + "_x", + "ok/.hidden", + "a//b", + "/a", + "a/", + "a b", + "a\\b", + "a/./b", + "a/../b", + ".", + "..", + "", + "é", + "a/é", + ]; + for scope in corpus { + let schema_accepts = regex.is_match(scope); + let mut r = receipt(); + r.target.verification_scope = scope.into(); + let code_accepts = validate_receipt(&r).is_ok(); + assert_eq!( + schema_accepts, code_accepts, + "scope {scope:?}: schema={schema_accepts} code={code_accepts}" + ); + } +} + +#[test] +fn receipt_fields_match_schema_properties_and_required() { + let schema = schema_json("schemas/pipeline-shard-receipt/v1.schema.json"); + let serialized = serde_json::to_value(receipt()).unwrap(); + // Every serialized field is a schema property, and vice versa. + assert_eq!(keys(&serialized), keys(&schema["properties"])); + // The receipt schema requires every property, nullable ones included. + assert_eq!( + schema_string_list(&schema["required"]), + keys(&schema["properties"]) + ); + // Nullable-but-required semantics: deleting any key must fail + // deserialization, null for the nullable ones must succeed. + for key in keys(&serialized) { + let mut pruned = serialized.clone(); + pruned.as_object_mut().unwrap().remove(&key); + assert!( + serde_json::from_value::(pruned).is_err(), + "receipt deserialized without required key {key}" + ); + } + let fixture: Value = + serde_json::from_str(include_str!("fixtures/pipeline-shard-receipt-v1.json")).unwrap(); + assert_eq!(keys(&fixture), keys(&schema["properties"])); +} + +#[test] +fn generator_input_required_list_matches_struct_strictness() { + let schema = schema_json("schemas/pipeline-shard-receipt/input-v1.schema.json"); + // The input schema also requires its nullable keys; the struct enforces + // presence via require_nullable, so removing any required key must fail. + let descriptor = serde_json::json!({ + "schema": PIPELINE_SHARD_RECEIPT_INPUT_SCHEMA, + "repository": "org/repo", + "workflow": "ci", + "commit_sha": "a".repeat(40), + "source_root": "src-root", + "lock_paths": ["Cargo.lock"], + "artifact_root": "artifact-root", + "artifacts": [], + "target": serde_json::to_value(receipt().target).unwrap(), + "versions": {}, + "checks": serde_json::to_value(receipt().checks).unwrap(), + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:01:00Z", + "run_id": null, + "dagger_trace": null, + "failure_classification": null, + "hosted": null, + "local": true, + }); + assert!(serde_json::from_value::(descriptor.clone()).is_ok()); + assert_eq!(keys(&descriptor), keys(&schema["properties"])); + for key in schema_string_list(&schema["required"]) { + let mut pruned = descriptor.clone(); + pruned.as_object_mut().unwrap().remove(&key); + assert!( + serde_json::from_value::(pruned).is_err(), + "generator input deserialized without required key {key}" + ); + } +} + +#[test] +fn aggregate_policy_matches_published_schema() { + let schema = schema_json("schemas/aggregate-policy/v1.schema.json"); + let policy = AggregatePolicy { + schema: AGGREGATE_POLICY_SCHEMA.into(), + expected_targets: vec![receipt().target], + context: ExpectedContext { + repository: "org/repo".into(), + workflow: "ci".into(), + commit_sha: "a".repeat(40), + trust_context: TrustContext::ProtectedMain, + promotable: false, + source_fingerprint: digest(b'a'), + lock_digest: digest(b'b'), + }, + required_checks: vec!["build".into()], + artifacts: vec![], + }; + let serialized = serde_json::to_value(&policy).unwrap(); + assert_eq!(keys(&serialized), keys(&schema["properties"])); + assert_eq!( + schema_string_list(&schema["required"]), + keys(&schema["properties"]) + ); + assert_eq!( + keys(&serialized["context"]), + keys(&schema["$defs"]["context"]["properties"]) + ); + // A policy asserting a different schema id is a gate verdict. + let mut wrong = policy; + wrong.schema = "aggregate-policy/v0".into(); + assert!( + aggregate_receipts(&[], &wrong).is_err(), + "policy with wrong schema id accepted" + ); +} + +#[test] +fn job_manifests_validate_against_published_schema_shape() { + let schema = schema_json("schemas/pipeline-shard-job-manifest/v1.schema.json"); + let schema_keys = keys(&schema["properties"]); + assert_eq!(schema_string_list(&schema["required"]), schema_keys); + for entry in std::fs::read_dir(".github/receipts/jobs").unwrap() { + let path = entry.unwrap().path(); + let raw: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(keys(&raw), schema_keys, "{path:?}"); + // Each checked-in manifest must deserialize under the strict struct. + let manifest: ReceiptJobManifest = serde_json::from_value(raw).unwrap(); + assert_eq!( + manifest.schema, PIPELINE_SHARD_JOB_MANIFEST_SCHEMA, + "{path:?}" + ); + } +} From 877d544dc78258a52136fa8b1442fe51ff3bd500 Mon Sep 17 00:00:00 2001 From: Alex Matrosov Date: Sun, 30 Aug 2026 20:56:46 -0700 Subject: [PATCH 11/11] fix(receipts): require hosted key presence in policy context input aggregate-policy-context/v1 lists hosted as required-but-nullable; the struct now enforces key presence like the other nullable-required fields. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Lxk48j7VVWwJr3QUZEVm6 --- src/verification/pipeline_receipt.rs | 2 +- src/verification/pipeline_receipt_policy_generator.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/verification/pipeline_receipt.rs b/src/verification/pipeline_receipt.rs index 1c5b3f7d..388592b4 100644 --- a/src/verification/pipeline_receipt.rs +++ b/src/verification/pipeline_receipt.rs @@ -21,7 +21,7 @@ pub const AGGREGATE_POLICY_SCHEMA: &str = "aggregate-policy/v1"; /// omission is always a contract violation rather than an implicit default. /// serde makes bare `Option` fields optional; this deserializer restores the /// required-but-nullable semantics. -fn require_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +pub(crate) fn require_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, T: Deserialize<'de>, diff --git a/src/verification/pipeline_receipt_policy_generator.rs b/src/verification/pipeline_receipt_policy_generator.rs index 63615324..9dfa786d 100644 --- a/src/verification/pipeline_receipt_policy_generator.rs +++ b/src/verification/pipeline_receipt_policy_generator.rs @@ -32,6 +32,7 @@ pub struct AggregatePolicyContextInput { pub schema: String, pub repository: String, pub commit_sha: String, + #[serde(deserialize_with = "super::pipeline_receipt::require_nullable")] pub hosted: Option, pub local: bool, }