server/lib/tests/an_image_carries_holgers_signed_cyclonedx_sbom.rs
//! **An image gets Holger's own CycloneDX SBOM** (lane HOL-OCI-EVIDENCE item 1, owner
//! 2026-10-31). HOL-REFERRERS served Holger's evidence as OCI referrers, but its SBOM slot
//! was the wheel scanner: an image carried a scan verdict and no bill of materials.
//!
//! An image is pulled through the intake chain from a fake upstream (built in the test: a
//! Wolfi apk database, a Python package, an npm package and a Java archive in two layers),
//! approved, and AVAILABLE. Then, through Holger's registry door:
//!
//! * the referrers API lists Holger's CycloneDX statement about the image's manifest digest
//! (a Sigstore bundle, signed with the key `/-/evidence-key` serves);
//! * the statement's predicate is a CycloneDX 1.5 document whose components are the image's
//! packages by purl, read from its layers in the scan worker (`holger-image-sbom`);
//! * the cosign `.att` tag of the image carries the same envelope, so an older cosign reads it;
//! * the evidence record that made it names the scanner.
#![cfg(all(feature = "supply-chain", feature = "jobs", feature = "sign"))]
mod oci_evidence;
use holger_server_lib::promote::intake::ArtifactState;
use oci_evidence::*;
#[test]
fn a_pulled_image_carries_holgers_signed_cyclonedx_sbom_as_a_referrer() {
let up = Upstream::start();
let img = demo_image("pulled");
up.put_image("demo/app", "1", &img);
let r = start(
&Boot {
pull_through: Some(
r#"sources: [ (name: "up", kind: oci, url: "{up}") ], retry_after_secs: Some(1)"#
.into(),
),
..Default::default()
},
Some(&up),
);
let path = "/v2/production/up/demo/app/manifests/1";
let (s, _, _) = request(r.http, "GET", path, &[("Accept", MANIFEST)], b"");
assert_eq!(s, 503, "first: not yet, Holger is pulling it");
let chain = r.chain();
let id = layout_in(&chain, "up/demo/app/", ArtifactState::PolicyChecked);
approve(&chain, &id, &["bob"]);
let (s, h, body) = wait_for("AVAILABLE", || {
let got = request(r.http, "GET", path, &[("Accept", MANIFEST)], b"");
(got.0 == 200).then_some(got)
});
assert_eq!(s, 200);
assert_eq!(body, img.manifest);
assert_eq!(
header(&h, "docker-content-digest").as_deref(),
Some(img.digest.as_str())
);
let evidence = holger_evidence(&r, "production/up/demo/app", &img.digest);
let kinds: Vec<&str> = evidence.iter().map(|(t, _)| t.as_str()).collect();
assert!(kinds.contains(&SCAN_EVIDENCE), "{kinds:?}");
let (_, st) = evidence
.iter()
.find(|(t, _)| t == CYCLONEDX)
.unwrap_or_else(|| panic!("Holger's CycloneDX SBOM is a referrer of the image: {kinds:?}"));
// The statement is about the image as a client pulls it.
let subjects: Vec<String> = st["subject"]
.as_array()
.unwrap()
.iter()
.filter_map(|s| {
s["digest"]["sha256"]
.as_str()
.map(|h| format!("sha256:{h}"))
})
.collect();
assert!(subjects.contains(&img.digest), "{subjects:?}");
let doc = &st["predicate"];
assert_eq!(doc["bomFormat"], "CycloneDX", "{doc}");
assert_eq!(doc["metadata"]["component"]["type"], "container", "{doc}");
assert_eq!(
doc["metadata"]["component"]["bom-ref"],
img.digest.as_str(),
"{doc}"
);
let purls: Vec<&str> = doc["components"]
.as_array()
.unwrap()
.iter()
.filter_map(|c| c["purl"].as_str())
.collect();
for want in [
"pkg:apk/wolfi/openssl@3.0.13-r2?distro=wolfi",
"pkg:apk/wolfi/busybox@1.36.1-r5?distro=wolfi",
"pkg:pypi/idna@3.7",
"pkg:npm/lodash@4.17.20",
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
] {
assert!(purls.contains(&want), "{want} not in {purls:?}");
}
// The `.att` tag (cosign's older scheme) carries the same envelope.
let tag = format!("sha256-{}.att", img.digest.trim_start_matches("sha256:"));
let (s, _, att) = request(
r.http,
"GET",
&format!("/v2/production/up/demo/app/manifests/{tag}"),
&[("Accept", MANIFEST)],
b"",
);
assert_eq!(s, 200, "{}", String::from_utf8_lossy(&att));
let att: serde_json::Value = serde_json::from_slice(&att).unwrap();
assert!(
att["layers"]
.as_array()
.unwrap()
.iter()
.any(|l| l["annotations"]["predicateType"] == CYCLONEDX),
"the .att manifest names the CycloneDX layer: {att}"
);
// Made by the image SBOM scanner, in the worker's slot.
let (s, _, ev) = request(
r.http,
"GET",
&format!(
"/-/intake/evidence?sha256={}",
chain.status(&id).unwrap().sha256
),
&[("Authorization", &bearer("bob"))],
b"",
);
assert_eq!(s, 200, "{}", String::from_utf8_lossy(&ev));
assert!(
String::from_utf8_lossy(&ev).contains("holger-image-sbom"),
"the evidence names the scanner"
);
}