server/lib/src/scanner/image_sbom.rs
//! **The CycloneDX SBOM of a container image** (lane HOL-OCI-EVIDENCE item 1, owner
//! 2026-10-31).
//!
//! The SBOM slot held only the wheel generator ([`super::wheel`]): an image passed the
//! chain with a scan verdict and no bill of materials. [`ImageSbomScanner`] fills the slot
//! for an image layout (`.oci.tar`, what a pull-through or a scanned push keeps): it reads
//! the image's packages from its layers with the image cataloger
//! ([`holger_inspect::image::catalog`]: the apk, dpkg and rpm databases, Python, npm, Java,
//! Ruby, PHP and .NET manifests, Go build info, `cargo auditable`) and writes a CycloneDX
//! 1.5 document with one component per package, by purl.
//!
//! It runs in the scan worker ([`crate::sandbox::worker::scanners`]), in the sandbox; the
//! server's set holds only its proxy, so the server never unpacks a layer to make it. The
//! document reaches the evidence record, and [`super::attestation`] signs it as a DSSE
//! in-toto statement (predicate `https://cyclonedx.org/bom`) about the layout AND its image
//! digests, which [`crate::referrers`] serves as an OCI referrer and an `.att` layer.
//!
//! The document is deterministic (no clock, a derived serial number, sorted components),
//! so the same image folds to the same bytes wherever it is scanned.
//!
//! | Field | Value |
//! |---|---|
//! | `metadata.component` | `type: container`, the image's name, `bom-ref` and `version` = the image digest a client pulls (`index.json`'s first), `hashes` its SHA-256; properties: the platform manifest, the layout's SHA-256, the OS |
//! | `components[]` | the OS (`type: operating-system`), then each package: `type: library`, `purl`, `bom-ref` = purl, property `holger:source` (where in the image it was read) |
//! | `properties` | `holger:not-read`: package databases present that the cataloger does not read |
use std::sync::{Arc, OnceLock};
use serde_json::{json, Value};
use super::{ScanOutcome, ScanSubject, Scanner, ScannerKind};
/// One catalog per worker run, shared by the SBOM record and the vulnerability slot's
/// package list (the layers are read once).
pub type SharedCatalog = Arc<OnceLock<Result<holger_inspect::image::Catalog, String>>>;
/// The SBOM generator for container images.
#[derive(Debug, Clone, Default)]
pub struct ImageSbomScanner {
catalog: SharedCatalog,
}
impl ImageSbomScanner {
pub const ID: &'static str = "holger-image-sbom";
/// A scanner that shares `catalog` with its caller (the worker's run).
pub fn sharing(catalog: SharedCatalog) -> Self {
Self { catalog }
}
}
/// Is this subject an image layout?
pub fn is_image(file_name: &str, format_name: &str) -> bool {
let file = file_name.rsplit('/').next().unwrap_or(file_name);
file_name.ends_with(".oci.tar")
|| holger_inspect::Format::declared(file, format_name) == holger_inspect::Format::OciImage
}
impl Scanner for ImageSbomScanner {
fn id(&self) -> &str {
Self::ID
}
fn version(&self) -> String {
format!(
"holger-server-lib {}; CycloneDX 1.5",
env!("CARGO_PKG_VERSION")
)
}
fn kind(&self) -> ScannerKind {
ScannerKind::Sbom
}
fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
if !is_image(subject.file_name, subject.ecosystem) {
return Ok(ScanOutcome::pass(format!(
"{:?} is not an image layout; this generator only builds SBOMs for container images",
subject.file_name
)));
}
let catalog = self
.catalog
.get_or_init(|| holger_inspect::image::catalog(subject.bytes));
let c = match catalog {
Ok(c) => c,
Err(e) => {
return Ok(ScanOutcome::error(format!(
"the image's layers cannot be read: {e}"
)))
}
};
let layout = crate::attest::ArtifactDigest::of_sha256(subject.bytes);
let doc = image_sbom(c, subject.file_name, layout.hex(), &self.version());
let detail = format!(
"CycloneDX SBOM of {} with {} package(s){}",
c.name.as_deref().unwrap_or(subject.file_name),
c.packages.len(),
c.os.as_deref()
.map(|os| format!(" (os {os})"))
.unwrap_or_default()
);
Ok(ScanOutcome::pass(detail).with_document(doc))
}
}
/// The purl of one cataloged package (`os` is the image's `os-release` ID).
pub fn purl_of(p: &holger_inspect::image::Package, os: Option<&str>) -> String {
use modgunn::sbom::purl;
let distro = os.unwrap_or("unknown");
match p.ecosystem.as_str() {
"wolfi" | "chainguard" | "apk" => {
let ns = match p.ecosystem.as_str() {
"apk" => os
.filter(|o| *o != "wolfi" && *o != "chainguard")
.unwrap_or("alpine"),
e => e,
};
format!(
"{}?distro={distro}",
purl("apk", &format!("{ns}/{}", p.name), &p.version)
)
}
"deb" => format!(
"{}?distro={distro}",
purl(
"deb",
&format!("{}/{}", os.unwrap_or("debian"), p.name),
&p.version
)
),
"rpm" => {
let (epoch, version) = match p.version.split_once(':') {
Some((e, v)) if e.bytes().all(|b| b.is_ascii_digit()) => (Some(e), v),
_ => (None, p.version.as_str()),
};
let base = purl(
"rpm",
&format!("{}/{}", os.unwrap_or("redhat"), p.name),
version,
);
match epoch {
Some(e) => format!("{base}?distro={distro}&epoch={e}"),
None => format!("{base}?distro={distro}"),
}
}
"pypi" => purl(
"pypi",
&crate::scanner::wheel::normalize_name(&p.name),
&p.version,
),
"go" => purl("golang", &p.name, &format!("v{}", p.version)),
// modgunn writes `group:artifact` as `group/artifact`.
eco @ ("npm" | "cargo" | "maven" | "gem" | "composer" | "nuget") => {
purl(eco, &p.name, &p.version)
}
other => purl(other, &p.name, &p.version),
}
}
/// Render 16 bytes of `seed` as an RFC 4122 version-4 `urn:uuid:` string.
fn urn_uuid(seed: &[u8]) -> String {
let h = blake3::hash(seed);
let mut b = [0u8; 16];
b.copy_from_slice(&h.as_bytes()[..16]);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let x = hex::encode(b);
format!(
"urn:uuid:{}-{}-{}-{}-{}",
&x[0..8],
&x[8..12],
&x[12..16],
&x[16..20],
&x[20..32]
)
}
/// **The CycloneDX 1.5 document** for a cataloged image whose layout hashes to `layout_hex`.
pub fn image_sbom(
c: &holger_inspect::image::Catalog,
file_name: &str,
layout_hex: &str,
tool_version: &str,
) -> Value {
let image = c
.top
.clone()
.or_else(|| c.manifest.clone())
.unwrap_or_else(|| format!("sha256:{layout_hex}"));
let name = c.name.clone().unwrap_or_else(|| file_name.to_string());
let mut props = vec![json!({ "name": "holger:layout:sha256", "value": layout_hex })];
if let Some(m) = &c.manifest {
props.push(json!({ "name": "holger:image:platform-manifest", "value": m }));
}
if let Some(os) = &c.os {
props.push(json!({ "name": "holger:image:os", "value": os }));
}
let mut components: Vec<Value> = Vec::new();
if let Some(os) = &c.os {
components
.push(json!({ "type": "operating-system", "name": os, "bom-ref": format!("os:{os}") }));
}
let mut seen = std::collections::BTreeSet::new();
let mut libs: Vec<Value> = c
.packages
.iter()
.filter_map(|p| {
let purl = purl_of(p, c.os.as_deref());
seen.insert(purl.clone()).then(|| {
json!({
"type": "library",
"name": p.name,
"version": p.version,
"purl": purl,
"bom-ref": purl,
"properties": [{ "name": "holger:source", "value": p.source }, { "name": "holger:ecosystem", "value": p.ecosystem }],
})
})
})
.collect();
libs.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str()));
components.extend(libs);
let mut doc = json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": {
"tools": { "components": [{ "type": "application", "name": ImageSbomScanner::ID, "version": tool_version }] },
"component": {
"type": "container",
"name": name,
"version": image,
"bom-ref": image,
"hashes": [{ "alg": "SHA-256", "content": image.trim_start_matches("sha256:") }],
"properties": props,
},
},
"components": components,
});
if !c.not_read.is_empty() {
doc["properties"] = Value::Array(
c.not_read
.iter()
.map(|w| json!({ "name": "holger:not-read", "value": w }))
.collect(),
);
}
let seed = serde_json::to_vec(&doc).unwrap_or_default();
doc["serialNumber"] = Value::String(urn_uuid(&seed));
doc
}
#[cfg(test)]
mod tests {
use super::*;
use holger_inspect::image::Package;
fn p(eco: &str, name: &str, version: &str) -> Package {
Package {
ecosystem: eco.into(),
name: name.into(),
version: version.into(),
source: "t".into(),
}
}
#[test]
fn purls_are_the_canonical_ones_and_read_back_to_the_same_package() {
for (pkg, os, want) in [
(
p("wolfi", "openssl", "3.0.13-r2"),
Some("wolfi"),
"pkg:apk/wolfi/openssl@3.0.13-r2?distro=wolfi",
),
(
p("apk", "musl", "1.2.5-r0"),
Some("alpine"),
"pkg:apk/alpine/musl@1.2.5-r0?distro=alpine",
),
(
p("deb", "bash", "5.2.15-2+b2"),
Some("debian"),
"pkg:deb/debian/bash@5.2.15-2%2Bb2?distro=debian",
),
(
p("rpm", "glibc", "2.34-283.el9_8"),
Some("rhel"),
"pkg:rpm/rhel/glibc@2.34-283.el9_8?distro=rhel",
),
(
p("rpm", "shadow-utils", "2:4.9-9.el9"),
Some("rhel"),
"pkg:rpm/rhel/shadow-utils@4.9-9.el9?distro=rhel&epoch=2",
),
(p("pypi", "Demo_Pkg", "1.0"), None, "pkg:pypi/demo-pkg@1.0"),
(
p("npm", "@scope/x", "1.2.3"),
None,
"pkg:npm/%40scope/x@1.2.3",
),
(
p("go", "golang.org/x/net", "0.17.0"),
None,
"pkg:golang/golang.org/x/net@v0.17.0",
),
(
p("maven", "org.apache.logging.log4j:log4j-core", "2.14.1"),
None,
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
),
(
p("composer", "monolog/monolog", "3.5.0"),
None,
"pkg:composer/monolog/monolog@3.5.0",
),
(
p("nuget", "Newtonsoft.Json", "13.0.1"),
None,
"pkg:nuget/Newtonsoft.Json@13.0.1",
),
] {
assert_eq!(purl_of(&pkg, os), want);
}
// Holger's own advisory labels read back from its purls.
let back = holger_inspect::image::from_purl("pkg:golang/golang.org/x/net@v0.17.0").unwrap();
assert_eq!(
back,
("go".into(), "golang.org/x/net".into(), "0.17.0".into())
);
let back = holger_inspect::image::from_purl("pkg:apk/wolfi/openssl@3.0.13-r2?distro=wolfi")
.unwrap();
assert_eq!(back, ("wolfi".into(), "openssl".into(), "3.0.13-r2".into()));
}
}