server/lib/src/promote/airgap.rs
//! **The airgap: export the approved set, import it verified on the secure side.**
//!
//! ```text
//! online holger secure holger (network: Offline)
//! production: AVAILABLE ──export──▶ signed bundle ──USB──▶ verified import ──▶ production: AVAILABLE
//! + stored evidence (holger-bundle bundle signature, + the online
//! + signed attestations approve/finalize) per-artifact hashes, record, audited
//! + SBOM + intake record evidence attestations
//! ```
//!
//! ## The export
//!
//! [`collect_export`] takes AVAILABLE artifacts from the production repository
//! (all of them, or the ones named), with:
//!
//! * the bytes, read back from the sealed store against the ingested SHA-256;
//! * the newest stored scan evidence about those bytes ([`crate::scanner::EvidenceStore`]);
//! * every signed attestation filed about them (scan evidence and SBOM), and the
//! signer certificate a verifier needs;
//! * the SBOM document, and the artifact's whole intake record.
//!
//! **The verdict is computed, never typed.** The stored evidence is folded again
//! ([`crate::scanner::fold`]); an artifact passes only when a vulnerability
//! scanner actually ran, nothing blocks, the evidence is signed and verifies, and
//! its intake record holds an approval. An artifact that fails any of these is
//! refused BY NAME, and the export writes nothing.
//!
//! ## The import
//!
//! [`verify_import`] runs on the secure side after the bundle's own verifier
//! (release signature, manifest, anti-rollback). It re-checks every payload's
//! SHA-256, BLAKE3 and size against the export index AND the signed `core.json`,
//! verifies every scan-evidence attestation against THIS side's trust roots and
//! enrolled signers, and folds the verified evidence again. Only then does
//! [`super::intake::IntakeChain::land_imported`] land each artifact AVAILABLE.
use serde::{Deserialize, Serialize};
use traits::ArtifactId;
use super::coord_key;
use super::intake::{ArtifactState, EvidenceKind, EvidenceVerdict, IntakeChain, IntakeEntry};
use crate::attest::{ArtifactDigest, AttestStore, CYCLONEDX_BOM, HOLGER_SCAN_EVIDENCE_V1};
use crate::scanner::{EvidenceStore, ScanVerdict, ScannerKind};
/// The export index's schema.
pub const EXPORT_SCHEMA: &str = "holger.airgap-export/v1";
/// `core.json`'s `source_provenance.source_kind` for an export bundle.
pub const SOURCE_KIND: &str = "holger-airgap-export";
/// Where the index lives, relative to the bundle's `evidence/`.
pub const INDEX_PATH: &str = "holger-export.json";
/// What one export carries, as `evidence/holger-export.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExportIndex {
pub schema: String,
/// Unix seconds. Never filled by an export (the decision profile,
/// [`crate::export_profile`]): `core.json`'s `created_at` is the bundle's time.
#[serde(default, skip_serializing_if = "is_zero")]
pub exported_at: i64,
/// Never filled by an export: who exported stays in the online audit log.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub exported_by: String,
/// Never filled by an export: the online box's repository name stays online.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub production: String,
/// `pass`: every artifact in the set passed. Computed.
pub verdict: String,
/// What the verdict was computed from, in words.
pub computed_from: String,
pub artifacts: Vec<ExportedArtifact>,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
/// One artifact of an export.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExportedArtifact {
#[serde(default)]
pub namespace: Option<String>,
pub name: String,
#[serde(default)]
pub version: String,
/// The bytes, relative to the bundle's `payload/`.
pub payload: String,
pub sha256: String,
pub blake3: String,
pub size: u64,
/// `pass` or `warn`, computed from the evidence.
pub verdict: String,
/// Why it is `warn`, one sentence each.
#[serde(default)]
pub reasons: Vec<String>,
/// The scanner kinds that ran.
pub ran: Vec<String>,
/// The scanner kinds no scanner is installed for. Never a pass.
pub not_run: Vec<String>,
/// Scan-evidence records, relative to `evidence/`.
pub evidence: Vec<String>,
/// DSSE envelopes (scan evidence and SBOM), relative to `evidence/`.
pub attestations: Vec<String>,
/// The attestation signer's certificate (DER), relative to `evidence/`.
pub signer_cert: String,
/// The CycloneDX SBOM, relative to `evidence/`.
#[serde(default)]
pub sbom: Option<String>,
/// The online intake record (JSON lines), relative to `evidence/`.
pub intake_record: String,
/// The distinct principals whose approval brought it to APPROVED.
pub approvers: Vec<String>,
/// The provenance material that came with the artifact (lane G3a), relative
/// to `evidence/`. The secure side verifies it again under its own policy.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provenance: Vec<String>,
/// The artifact's index row (lane HOL-PERF-2, a `holger.index-row/v1` document),
/// relative to `evidence/`: what the secure side's indexes render, so it never parses
/// the package itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index_row: Option<String>,
}
impl ExportedArtifact {
pub fn id(&self) -> ArtifactId {
ArtifactId {
namespace: self.namespace.clone(),
name: self.name.clone(),
version: self.version.clone(),
}
}
pub fn label(&self) -> String {
super::artifact_label(&self.id())
}
}
/// What a bundle member is, for the bundle's own evidence kinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Payload,
Sbom,
Scan,
Other,
}
/// One file of an export, at its path relative to `payload/` or `evidence/`.
#[derive(Debug, Clone)]
pub struct ExportFile {
pub path: String,
pub bytes: Vec<u8>,
pub media_type: String,
pub kind: FileKind,
}
/// A collected export: the index and every file.
#[derive(Debug, Clone)]
pub struct ExportSet {
pub index: ExportIndex,
pub payloads: Vec<ExportFile>,
pub evidence: Vec<ExportFile>,
}
/// Why an export or an import did not happen. Nothing was written.
#[derive(Debug)]
pub enum AirgapError {
NotAvailable {
artifact: String,
state: String,
},
NoEvidence {
artifact: String,
why: String,
},
EvidenceFailed {
artifact: String,
reasons: Vec<String>,
},
NotAttested {
artifact: String,
why: String,
},
NotApproved(String),
NothingToExport,
/// Every refusal of one export, each by name.
Refused(Vec<AirgapError>),
IndexInvalid(String),
PayloadMismatch {
artifact: String,
why: String,
},
AttestationInvalid {
artifact: String,
why: String,
},
AuditFailed(String),
Intake(super::intake::IntakeError),
}
impl AirgapError {
pub fn code(&self) -> &'static holger_errcode::ErrCode {
match self {
AirgapError::NotAvailable { .. } => &holger_errcode::AIRGAP_NOT_AVAILABLE,
AirgapError::NoEvidence { .. } => &holger_errcode::AIRGAP_NO_EVIDENCE,
AirgapError::EvidenceFailed { .. } => &holger_errcode::AIRGAP_EVIDENCE_FAILED,
AirgapError::NotAttested { .. } => &holger_errcode::AIRGAP_NOT_ATTESTED,
AirgapError::NotApproved(_) => &holger_errcode::AIRGAP_NOT_APPROVED,
AirgapError::NothingToExport => &holger_errcode::AIRGAP_NOTHING_TO_EXPORT,
AirgapError::Refused(all) => all
.first()
.map(|e| e.code())
.unwrap_or(&holger_errcode::AIRGAP_NOTHING_TO_EXPORT),
AirgapError::IndexInvalid(_) => &holger_errcode::AIRGAP_INDEX_INVALID,
AirgapError::PayloadMismatch { .. } => &holger_errcode::AIRGAP_PAYLOAD_MISMATCH,
AirgapError::AttestationInvalid { .. } => &holger_errcode::AIRGAP_ATTESTATION_INVALID,
AirgapError::AuditFailed(_) => &holger_errcode::AIRGAP_AUDIT_FAILED,
AirgapError::Intake(e) => e.code(),
}
}
/// The message with its code: `[holger-airgap-2] …`.
pub fn tagged(&self) -> String {
match self {
AirgapError::Refused(all) => all
.iter()
.map(|e| e.tagged())
.collect::<Vec<_>>()
.join("\n"),
AirgapError::Intake(e) => e.tagged(),
other => other.code().tag(other),
}
}
/// The artifacts a refusal names.
pub fn artifacts(&self) -> Vec<String> {
match self {
AirgapError::NotAvailable { artifact, .. }
| AirgapError::NoEvidence { artifact, .. }
| AirgapError::EvidenceFailed { artifact, .. }
| AirgapError::NotAttested { artifact, .. }
| AirgapError::PayloadMismatch { artifact, .. }
| AirgapError::AttestationInvalid { artifact, .. } => vec![artifact.clone()],
AirgapError::NotApproved(a) => vec![a.clone()],
AirgapError::Refused(all) => all.iter().flat_map(|e| e.artifacts()).collect(),
_ => Vec::new(),
}
}
}
impl std::fmt::Display for AirgapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AirgapError::NotAvailable { artifact, state } => {
write!(
f,
"'{artifact}' is {state}, not AVAILABLE in production, so it is not exported"
)
}
AirgapError::NoEvidence { artifact, why } => {
write!(f, "'{artifact}' is refused from the export: {why}")
}
AirgapError::EvidenceFailed { artifact, reasons } => write!(
f,
"'{artifact}' is refused from the export: its stored evidence does not pass ({})",
reasons.join("; ")
),
AirgapError::NotAttested { artifact, why } => {
write!(
f,
"'{artifact}' is refused from the export: its evidence is not signed ({why})"
)
}
AirgapError::NotApproved(a) => {
write!(
f,
"'{a}' is refused from the export: its intake record holds no approval"
)
}
AirgapError::NothingToExport => {
write!(f, "there is no AVAILABLE artifact in production to export")
}
AirgapError::Refused(all) => {
write!(
f,
"{}",
all.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ")
)
}
AirgapError::IndexInvalid(m) => write!(f, "the bundle's export index is invalid: {m}"),
AirgapError::PayloadMismatch { artifact, why } => {
write!(f, "'{artifact}' does not match the bundle's record: {why}")
}
AirgapError::AttestationInvalid { artifact, why } => {
write!(
f,
"'{artifact}': an evidence attestation does not verify: {why}"
)
}
AirgapError::AuditFailed(m) => {
write!(
f,
"the audit record could not be written, so nothing happened: {m}"
)
}
AirgapError::Intake(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for AirgapError {}
impl From<super::intake::IntakeError> for AirgapError {
fn from(e: super::intake::IntakeError) -> Self {
AirgapError::Intake(e)
}
}
/// The verdict of one artifact's evidence, folded again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvidenceVerdictSummary {
/// `pass` or `warn`.
pub verdict: String,
pub reasons: Vec<String>,
pub ran: Vec<String>,
pub not_run: Vec<String>,
}
/// ★ **The computed verdict of one evidence record.** Refuses when nothing
/// scanned for vulnerabilities, when the fold blocks, or when the record's own
/// stated verdict disagrees with the fold (a record that says `pass` over
/// records that block was changed).
pub fn judge_evidence(
artifact: &str,
ev: &crate::scanner::Evidence,
) -> Result<EvidenceVerdictSummary, AirgapError> {
let ran: Vec<String> = ev
.records
.iter()
.filter(|r| !r.is_not_configured())
.map(|r| r.kind.as_str().to_string())
.collect();
let not_run: Vec<String> = ScannerKind::ALL
.iter()
.filter(|k| {
!ev.records
.iter()
.any(|r| r.kind == **k && !r.is_not_configured())
})
.map(|k| k.as_str().to_string())
.collect();
if !ev
.records
.iter()
.any(|r| r.kind == ScannerKind::Vulnerability && !r.is_not_configured())
{
return Err(AirgapError::NoEvidence {
artifact: artifact.to_string(),
why:
"no vulnerability scanner ran over it, and an artifact nothing scanned never passes"
.into(),
});
}
let (verdict, reasons) = crate::scanner::fold(&ev.records);
if verdict != ev.verdict {
return Err(AirgapError::EvidenceFailed {
artifact: artifact.to_string(),
reasons: vec![format!(
"the record states {} but its scanners fold to {}",
ev.verdict.as_str(),
verdict.as_str()
)],
});
}
match verdict {
ScanVerdict::Pass => Ok(EvidenceVerdictSummary {
verdict: "pass".into(),
reasons,
ran,
not_run,
}),
ScanVerdict::Warn => Ok(EvidenceVerdictSummary {
verdict: "warn".into(),
reasons,
ran,
not_run,
}),
ScanVerdict::Block | ScanVerdict::Error => Err(AirgapError::EvidenceFailed {
artifact: artifact.to_string(),
reasons,
}),
}
}
/// The approvers on an intake record, and a refusal when there are none or when
/// any slot on the record failed.
fn judge_record(artifact: &str, history: &[IntakeEntry]) -> Result<Vec<String>, AirgapError> {
let failing: Vec<String> = history
.iter()
.filter(|e| e.to != ArtifactState::Blocked)
.flat_map(|e| e.evidence.iter())
.filter(|e| e.verdict == EvidenceVerdict::Fail)
.map(|e| format!("{} evidence from '{}' failed", e.kind.name(), e.source))
.collect();
if !failing.is_empty() {
return Err(AirgapError::EvidenceFailed {
artifact: artifact.to_string(),
reasons: failing,
});
}
let approved = history
.iter()
.rev()
.find(|e| e.to == ArtifactState::Approved)
.ok_or_else(|| AirgapError::NotApproved(artifact.to_string()))?;
let mut approvers: Vec<String> = approved
.evidence
.iter()
.filter(|e| e.kind == EvidenceKind::Approval && e.verdict != EvidenceVerdict::Fail)
.map(|e| e.source.clone())
.collect();
approvers.sort();
approvers.dedup();
if approvers.is_empty() {
return Err(AirgapError::NotApproved(artifact.to_string()));
}
Ok(approvers)
}
fn matches(sel: &str, id: &ArtifactId) -> bool {
let base = id.name.rsplit('/').next().unwrap_or(&id.name);
sel == id.name || sel == base || sel == super::artifact_label(id)
}
/// ★ **Collect the export of the approved set.**
///
/// `select` names artifacts (file name, name, or `name@version`); empty means
/// every AVAILABLE artifact. `signer_cert` is the certificate the evidence was
/// signed with. Every artifact is judged; if any is refused, the result is
/// [`AirgapError::Refused`] naming each, and nothing is exported.
pub fn collect_export(
chain: &IntakeChain,
evidence: &EvidenceStore,
attest: Option<&AttestStore>,
signer_cert: Option<&[u8]>,
select: &[String],
) -> Result<ExportSet, AirgapError> {
let all = chain.all();
let mut chosen: Vec<ArtifactId> = Vec::new();
let mut refusals = Vec::new();
if select.is_empty() {
chosen.extend(
all.iter()
.filter(|(_, s)| s.state == ArtifactState::Available)
.map(|(id, _)| id.clone()),
);
} else {
for sel in select {
match all.iter().find(|(id, _)| matches(sel, id)) {
Some((id, s)) if s.state == ArtifactState::Available => chosen.push(id.clone()),
Some((id, s)) => refusals.push(AirgapError::NotAvailable {
artifact: super::artifact_label(id),
state: s.state.to_string(),
}),
None => refusals.push(AirgapError::NotAvailable {
artifact: sel.clone(),
state: "unknown here (no intake record)".into(),
}),
}
}
}
if chosen.is_empty() && refusals.is_empty() {
return Err(AirgapError::NothingToExport);
}
let mut index = ExportIndex {
schema: EXPORT_SCHEMA.into(),
// The decision profile (HOL-NCSC): who, when and from which repository stay online.
exported_at: 0,
exported_by: String::new(),
production: String::new(),
verdict: String::new(),
computed_from:
"the newest stored scan evidence about each artifact's SHA-256, folded again; \
its signed attestations, verified; the intake record's approvals"
.into(),
artifacts: Vec::new(),
};
let mut payloads = Vec::new();
let mut files = Vec::new();
for id in chosen {
match export_one(chain, evidence, attest, signer_cert, &id) {
Ok((art, payload, ev_files)) => {
index.artifacts.push(art);
payloads.push(payload);
files.extend(ev_files);
}
Err(e) => refusals.push(e),
}
}
if !refusals.is_empty() {
return Err(AirgapError::Refused(refusals));
}
index.verdict = "pass".into();
let bytes = serde_json::to_vec_pretty(&index).expect("the index is plain data");
files.push(ExportFile {
path: INDEX_PATH.into(),
bytes,
media_type: "application/json".into(),
kind: FileKind::Other,
});
Ok(ExportSet {
index,
payloads,
evidence: files,
})
}
fn export_one(
chain: &IntakeChain,
evidence: &EvidenceStore,
attest: Option<&AttestStore>,
signer_cert: Option<&[u8]>,
id: &ArtifactId,
) -> Result<(ExportedArtifact, ExportFile, Vec<ExportFile>), AirgapError> {
let label = super::artifact_label(id);
let bytes = chain.serve(id)?.ok_or_else(|| AirgapError::NotAvailable {
artifact: label.clone(),
state: chain.state(id).to_string(),
})?;
let digest = ArtifactDigest::of_sha256(&bytes);
let sha = digest.hex().to_string();
let history = chain.history(id);
let stored = evidence
.latest(&sha)
.map_err(|e| AirgapError::NoEvidence {
artifact: label.clone(),
why: e.tagged(),
})?
.ok_or_else(|| AirgapError::NoEvidence {
artifact: label.clone(),
why: format!("no scan evidence is stored for sha256 {sha}"),
})?;
let summary = judge_evidence(&label, &stored)?;
let approvers = judge_record(&label, &history)?;
let attest = attest.ok_or_else(|| AirgapError::NotAttested {
artifact: label.clone(),
why: "this holger declares no `attestation:` block".into(),
})?;
let cert = signer_cert.ok_or_else(|| AirgapError::NotAttested {
artifact: label.clone(),
why: "this holger declares no attestation signer".into(),
})?;
let certs = vec![cert.to_vec()];
let scan_envs = attest
.query(&digest, HOLGER_SCAN_EVIDENCE_V1)
.map_err(|e| AirgapError::NotAttested {
artifact: label.clone(),
why: e.tagged(),
})?;
let signed = scan_envs.iter().any(|a| {
crate::scanner::attestation::verify_evidence(attest, &a.envelope_json, &certs, &digest)
.map(|ev| ev == stored)
.unwrap_or(false)
});
if !signed {
return Err(AirgapError::NotAttested {
artifact: label,
why: format!(
"none of the {} scan-evidence attestation(s) filed about sha256 {sha} verifies to its newest evidence",
scan_envs.len()
),
});
}
let sbom_envs = attest
.query(&digest, CYCLONEDX_BOM)
.map_err(|e| AirgapError::NotAttested {
artifact: label.clone(),
why: e.tagged(),
})?;
let key = coord_key(id);
let base = id.name.rsplit('/').next().unwrap_or(&id.name).to_string();
let payload_path = format!("{key}/{base}");
let mut out = Vec::new();
let mut put = |path: String, bytes: Vec<u8>, media: &str, kind: FileKind| {
out.push(ExportFile {
path: path.clone(),
bytes,
media_type: media.into(),
kind,
});
path
};
let ev_path = put(
format!("{key}/scan-evidence.json"),
stored.to_bytes(),
"application/json",
FileKind::Scan,
);
let mut attestations = Vec::new();
for a in scan_envs.iter().chain(sbom_envs.iter()) {
attestations.push(put(
format!("{key}/attestations/{}.dsse.json", &a.envelope_id[..16]),
a.envelope_json.clone(),
"application/vnd.dsse.envelope.v1+json",
FileKind::Other,
));
}
let cert_path = put(
format!("{key}/attestation-signer.der"),
cert.to_vec(),
"application/pkix-cert",
FileKind::Other,
);
let sbom = stored.document_of(ScannerKind::Sbom).map(|doc| {
put(
format!("{key}/sbom.cdx.json"),
serde_json::to_vec_pretty(doc).expect("an SBOM is JSON"),
"application/vnd.cyclonedx+json",
FileKind::Sbom,
)
});
// The decision record (HOL-NCSC, `crate::export_profile`): each line as the secure side
// needs it, the rest of the online record stays online.
let record: String = history
.iter()
.map(|e| {
serde_json::to_string(&crate::export_profile::decision_entry(e))
.expect("an intake entry is plain data")
+ "\n"
})
.collect();
let record_path = put(
format!("{key}/intake.jsonl"),
record.into_bytes(),
"application/jsonl",
FileKind::Other,
);
#[cfg_attr(not(feature = "scanners"), allow(unused_mut))]
let mut provenance = Vec::new();
#[cfg(feature = "scanners")]
if let Some(p) = chain.scanner().and_then(|s| s.provenance()) {
let materials = p
.materials_for(
&sha,
&base,
history
.last()
.map(|e| e.origin.as_str())
.filter(|s| !s.is_empty()),
)
.map_err(|e| AirgapError::NoEvidence {
artifact: label.clone(),
why: format!("its provenance material: {e:#}"),
})?;
for (name, bytes) in materials {
// An APK source's signed index travels too, so the secure side verifies again.
let mt = if name.ends_with(".tar.gz") {
"application/gzip"
} else {
"application/json"
};
provenance.push(put(
format!("{key}/provenance/{name}"),
bytes,
mt,
FileKind::Other,
));
}
}
let index_row = chain.index_row(id).map(|row| {
put(
format!("{key}/index-row.json"),
serde_json::to_vec(row.as_ref()).expect("a row is JSON"),
"application/json",
FileKind::Other,
)
});
let art = ExportedArtifact {
namespace: id.namespace.clone(),
name: id.name.clone(),
version: id.version.clone(),
payload: payload_path.clone(),
sha256: sha,
blake3: crate::digest::blake3_hex(&bytes),
size: bytes.len() as u64,
verdict: summary.verdict,
reasons: summary.reasons,
ran: summary.ran,
not_run: summary.not_run,
evidence: vec![ev_path],
attestations,
signer_cert: cert_path,
sbom,
intake_record: record_path,
approvers,
provenance,
index_row,
};
let payload = ExportFile {
path: payload_path,
bytes,
media_type: media_type_of(&base).into(),
kind: FileKind::Payload,
};
Ok((art, payload, out))
}
fn media_type_of(file: &str) -> &'static str {
let f = file.to_ascii_lowercase();
if f.ends_with(".whl") || f.ends_with(".zip") {
"application/zip"
} else if f.ends_with(".tar.gz") || f.ends_with(".tgz") {
"application/gzip"
} else {
"application/octet-stream"
}
}
// ── the import ──────────────────────────────────────────────────────────────
/// One artifact of a verified import, ready to land.
#[derive(Debug, Clone)]
pub struct VerifiedArtifact {
pub id: ArtifactId,
pub bytes: Vec<u8>,
pub sha256: String,
pub verdict: EvidenceVerdictSummary,
pub evidence: crate::scanner::Evidence,
/// The attestations that verified, as they came in the bundle.
pub envelopes: Vec<Vec<u8>>,
pub signer_cert: Vec<u8>,
pub online_history: Vec<IntakeEntry>,
/// The provenance material the bundle carried, `(file name, bytes)`.
pub provenance: Vec<(String, Vec<u8>)>,
/// The artifact's index row the bundle carried (lane HOL-PERF-2), checked.
pub index_row: Option<holger_index_rows::IndexRow>,
}
/// A bundle member, by its full path (`payload/…`, `evidence/…`).
pub trait Members {
fn member(&self, path: &str) -> Option<&[u8]>;
}
impl Members for std::collections::BTreeMap<String, Vec<u8>> {
fn member(&self, path: &str) -> Option<&[u8]> {
self.get(path).map(|v| v.as_slice())
}
}
/// The recorded digests of one payload in the signed `core.json`.
#[derive(Debug, Clone)]
pub struct CorePayload {
pub path: String,
pub sha256: String,
pub blake3: String,
pub size: u64,
}
/// Read the export index out of a bundle's members.
pub fn read_index(members: &dyn Members) -> Result<ExportIndex, AirgapError> {
let raw = members
.member(&format!("evidence/{INDEX_PATH}"))
.ok_or_else(|| {
AirgapError::IndexInvalid(format!("evidence/{INDEX_PATH} is not in the bundle"))
})?;
let index: ExportIndex = serde_json::from_slice(raw)
.map_err(|e| AirgapError::IndexInvalid(format!("it does not parse: {e}")))?;
if index.schema != EXPORT_SCHEMA {
return Err(AirgapError::IndexInvalid(format!(
"schema {:?}, expected {EXPORT_SCHEMA:?}",
index.schema
)));
}
if index.verdict != "pass" {
return Err(AirgapError::IndexInvalid(format!(
"the export's verdict is {:?}, not pass",
index.verdict
)));
}
if index.artifacts.is_empty() {
return Err(AirgapError::IndexInvalid("it lists no artifact".into()));
}
Ok(index)
}
/// ★ **Verify every artifact of an export bundle** whose release signature the
/// bundle verifier already checked. `core_payloads` are the payload records of
/// the signed `core.json`; `attest` is THIS side's attestation store (its roots
/// and enrolled signers decide). Nothing is written.
pub fn verify_import(
members: &dyn Members,
core_payloads: &[CorePayload],
attest: Option<&AttestStore>,
) -> Result<(ExportIndex, Vec<VerifiedArtifact>), AirgapError> {
let index = read_index(members)?;
let attest = attest.ok_or_else(|| AirgapError::AttestationInvalid {
artifact: index.artifacts[0].label(),
why: "this holger declares no `attestation:` block, so it cannot verify the evidence"
.into(),
})?;
let mut out = Vec::new();
for art in &index.artifacts {
let label = art.label();
let mismatch = |why: String| AirgapError::PayloadMismatch {
artifact: label.clone(),
why,
};
let full = format!("payload/{}", art.payload);
let bytes = members
.member(&full)
.ok_or_else(|| mismatch(format!("{full} is not in the bundle")))?;
let core = core_payloads
.iter()
.find(|p| p.path == full)
.ok_or_else(|| mismatch(format!("{full} is not recorded in the signed core.json")))?;
let sha = ArtifactDigest::of_sha256(bytes);
let blake = crate::digest::blake3_hex(bytes);
for (what, want, got) in [
("sha256", art.sha256.as_str(), sha.hex()),
("blake3", art.blake3.as_str(), blake.as_str()),
("core sha256", core.sha256.as_str(), sha.hex()),
("core blake3", core.blake3.as_str(), blake.as_str()),
] {
if want != got {
return Err(mismatch(format!("{what} is {got}, the record says {want}")));
}
}
if art.size != bytes.len() as u64 || core.size != bytes.len() as u64 {
return Err(mismatch(format!(
"size is {}, the record says {}",
bytes.len(),
art.size
)));
}
let invalid = |why: String| AirgapError::AttestationInvalid {
artifact: label.clone(),
why,
};
let cert = members
.member(&format!("evidence/{}", art.signer_cert))
.ok_or_else(|| invalid(format!("evidence/{} is not in the bundle", art.signer_cert)))?
.to_vec();
let ev_path = art
.evidence
.first()
.ok_or_else(|| invalid("the index names no evidence record".into()))?;
let ev_bytes = members
.member(&format!("evidence/{ev_path}"))
.ok_or_else(|| invalid(format!("evidence/{ev_path} is not in the bundle")))?;
let carried: crate::scanner::Evidence = serde_json::from_slice(ev_bytes)
.map_err(|e| invalid(format!("{ev_path} does not parse: {e}")))?;
let certs = vec![cert.clone()];
let mut verified: Option<crate::scanner::Evidence> = None;
let mut envelopes = Vec::new();
let mut last_err = String::from("the index names no attestation");
for a in &art.attestations {
let env = members
.member(&format!("evidence/{a}"))
.ok_or_else(|| invalid(format!("evidence/{a} is not in the bundle")))?;
match attest.verify(env, &certs, Some(&sha)) {
Ok(stmt) if stmt.predicate_type == HOLGER_SCAN_EVIDENCE_V1 => {
let ev =
crate::scanner::attestation::verify_evidence(attest, env, &certs, &sha)
.map_err(|e| invalid(format!("{a}: {}", e.tagged())))?;
if ev == carried {
verified = Some(ev);
}
envelopes.push(env.to_vec());
}
Ok(_) => envelopes.push(env.to_vec()),
Err(e) => last_err = format!("{a}: {}", e.tagged()),
}
}
if envelopes.len() != art.attestations.len() {
return Err(invalid(last_err));
}
let evidence = verified.ok_or_else(|| {
invalid(
"no scan-evidence attestation verifies to the evidence record the bundle carries"
.into(),
)
})?;
let verdict = judge_evidence(&label, &evidence)?;
let record = members
.member(&format!("evidence/{}", art.intake_record))
.ok_or_else(|| {
invalid(format!(
"evidence/{} is not in the bundle",
art.intake_record
))
})?;
let online_history: Vec<IntakeEntry> = std::str::from_utf8(record)
.map_err(|e| invalid(format!("the intake record is not UTF-8: {e}")))?
.lines()
.filter(|l| !l.trim().is_empty())
.map(serde_json::from_str::<IntakeEntry>)
.collect::<Result<_, _>>()
.map_err(|e| invalid(format!("the intake record does not parse: {e}")))?;
judge_record(&label, &online_history)?;
if online_history.last().map(|e| e.to) != Some(ArtifactState::Available) {
return Err(invalid(
"the online intake record does not end AVAILABLE".into(),
));
}
let mut provenance = Vec::new();
for p in &art.provenance {
let bytes = members
.member(&format!("evidence/{p}"))
.ok_or_else(|| invalid(format!("evidence/{p} is not in the bundle")))?;
let name = p.rsplit('/').next().unwrap_or(p).to_string();
provenance.push((name, bytes.to_vec()));
}
// The row the online side's worker read (lane HOL-PERF-2), checked like any
// other member: about these bytes, within its bounds, its size the payload's.
let index_row = match &art.index_row {
None => None,
Some(p) => {
let raw = members
.member(&format!("evidence/{p}"))
.ok_or_else(|| invalid(format!("evidence/{p} is not in the bundle")))?;
let row = holger_index_rows::StoredRow::parse(raw, &art.sha256)
.map_err(|e| invalid(format!("evidence/{p}: {e}")))?;
if row.size != art.size {
return Err(invalid(format!(
"evidence/{p} is about {} bytes, the payload is {}",
row.size, art.size
)));
}
Some(row.row)
}
};
out.push(VerifiedArtifact {
index_row,
provenance,
id: art.id(),
bytes: bytes.to_vec(),
sha256: sha.hex().to_string(),
verdict,
evidence,
envelopes,
signer_cert: cert,
online_history,
});
}
Ok((index, out))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{fold, ScanEvidence};
fn record(kind: ScannerKind, verdict: ScanVerdict, id: &str) -> ScanEvidence {
ScanEvidence {
scanner_id: id.into(),
scanner_version: "1".into(),
kind,
verdict,
findings: vec![],
inputs: vec![],
document: None,
detail: "d".into(),
required: false,
intel_sequence: None,
hold: false,
}
}
fn evidence(records: Vec<ScanEvidence>) -> crate::scanner::Evidence {
let (verdict, reasons) = fold(&records);
crate::scanner::Evidence {
schema: crate::scanner::EVIDENCE_SCHEMA.into(),
subject: crate::scanner::EvidenceSubject {
file_name: "x.whl".into(),
ecosystem: "pip".into(),
name: None,
version: None,
size: 1,
inputs: vec![],
},
verdict,
reasons,
records,
scanned_at: 0,
}
}
#[test]
fn a_distribution_names_its_pep_503_project() {
use crate::promote::intake::python_project_of as p;
assert_eq!(p("idna-3.20-py3-none-any.whl").as_deref(), Some("idna"));
assert_eq!(
p("Typing_Extensions-4.16.0-py3-none-any.whl").as_deref(),
Some("typing-extensions")
);
assert_eq!(
p("zope.interface-7.0.tar.gz").as_deref(),
Some("zope-interface")
);
assert_eq!(p("firmware.bin"), None);
}
#[test]
fn evidence_where_nothing_scanned_never_passes() {
let ev = evidence(vec![record(
ScannerKind::Sbom,
ScanVerdict::Pass,
"wheel-sbom",
)]);
let e = judge_evidence("x.whl@", &ev).unwrap_err();
assert_eq!(e.code().code, "holger-airgap-2", "{e}");
}
#[test]
fn a_blocking_record_refuses_and_a_changed_verdict_is_caught() {
let ev = evidence(vec![record(
ScannerKind::Vulnerability,
ScanVerdict::Block,
"osv",
)]);
assert_eq!(
judge_evidence("x", &ev).unwrap_err().code().code,
"holger-airgap-3"
);
let mut forged = evidence(vec![record(
ScannerKind::Vulnerability,
ScanVerdict::Block,
"osv",
)]);
forged.verdict = ScanVerdict::Pass;
assert_eq!(
judge_evidence("x", &forged).unwrap_err().code().code,
"holger-airgap-3"
);
let ok = evidence(vec![record(
ScannerKind::Vulnerability,
ScanVerdict::Pass,
"osv",
)]);
let s = judge_evidence("x", &ok).unwrap();
assert_eq!(s.verdict, "pass");
assert!(
s.not_run.contains(&"malware".to_string()),
"{:?}",
s.not_run
);
}
}