Skip to main content

Your Buildpack Already Wrote the SBOM — Your Cluster Just Isn't Reading It

9 min readDora NodaDora Noda
Share
On this page

Every git push on your platform already produces a Software Bill of Materials. If you build with Cloud Native Buildpacks, the lifecycle quietly assembles a per-layer inventory — every runtime, every system package, every dependency — in CycloneDX, SPDX, and Syft formats, and pack sbom download will hand it to you on demand. Most fleets then do absolutely nothing with it at the one moment it matters: the seconds before the image is scheduled onto a node shared with other tenants.

Here is the design this post owes you, up front. A build artifact is only as useful as the enforcement point that consumes it, so wire four links together: the buildpack emits the SBOM into the image, cosign attest binds that SBOM to the image digest as a signed in-toto attestation, and an admission controller verifies both before any pod is created. The policy itself fits in one table with three rules — reject an image whose base isn't on the approved list, reject one carrying a copyleft license your legal posture forbids, reject one whose SBOM resolves to a critical CVE above your threshold. Everything below is how to build each link and why scanning the image after it lands on a shared node is strictly worse.


SBOM support in Cloud Native Buildpacks is not a plugin or an afterthought; it is part of the platform specification. Since lifecycle v0.13.0, any buildpack speaking Buildpack API 0.7 or newer can emit SBOM files in three media types — application/vnd.cyclonedx+json, application/spdx+json, and application/vnd.syft+json — and the lifecycle merges the per-buildpack documents into the image at /layers/config/<type>/sbom/, per RFC 0095. Paketo buildpacks go further, writing SBOM data at three granularities: per-layer files, plus aggregated launch and build documents, in CycloneDX and Syft formats.

For the operator, the ergonomics are one command:

bash
pack sbom download my-registry.example.com/tenant-app:abc123 \
  --output-dir /tmp/tenant-app-sbom

That yields a layers/sbom/launch/ tree with a directory per buildpack — paketo-buildpacks_bellsoft-liberica, paketo-buildpacks_ca-certificates, and so on — each containing sbom.cdx.json, sbom.spdx.json, and sbom.syft.json. Three formats sounds redundant until you map consumers: CycloneDX feeds vulnerability scanners, SPDX feeds license review, Syft feeds anything in the Anchore ecosystem. Generate once at build time, consume three ways at policy time.

Two properties make this SBOM unusually trustworthy as policy input. First, it is generated by the build process itself, from the exact layers being shipped — not reconstructed later by scanning a flattened filesystem and guessing which package manager owns each file. Second, Paketo rebuilds the documents on every re-build from cached layer metadata, so the SBOM tracks the image instead of drifting from it. A scanner-derived inventory is a second opinion; a builder-emitted SBOM is a birth certificate.

But a birth certificate nobody checks is just paper. An SBOM sitting inside the image, unsigned and unenforced, changes nothing about what runs on your nodes. That is the gap the next two links close.


An unsigned SBOM asserts provenance; a signed attestation proves it. The standard move is Sigstore: attach the SBOM to the image as an in-toto attestation with cosign attest, signed keylessly against Fulcio so no long-lived private key has to live in your build cluster:

bash
cosign attest --yes \
  --type 'https://spdx.dev/Document' \
  --predicate /tmp/tenant-app-sbom/layers/sbom/launch/sbom.spdx.json \
  my-registry.example.com/tenant-app@sha256:<digest>

Note the digest pin, not the tag. Attestations bind to the immutable digest; a policy that verifies tenant-app:abc123 by tag is verifying a mutable pointer, and everything downstream of it is theater.

Keyless signing deserves one honest paragraph, because platform teams hear "no keys to manage" and stop reading. There is still identity to manage: the Fulcio certificate binds the signature to the OIDC identity that ran the build — your CI service account, not "the platform" in the abstract. Your admission policy must check which identity signed, not merely that something signed. A valid signature from an attacker's personal CI account is worse than no signature at all, because it arrives wearing a trust costume. Every policy sketch in the next section pins the expected issuer and subject; treat any example that omits them as incomplete.


This is the architectural decision the whole post exists to argue: verify at admission, inside the cluster's request path, before the kubelet ever pulls the image. Two mature controllers do this today, and they compose rather than compete.

Sigstore policy-controller speaks the native language: a ClusterImagePolicy that requires a signature from your builder identity plus a matching SBOM attestation, enforced by a webhook on namespaces labeled policy.sigstore.dev/include: "true". An image without a valid signature-attestation pair never becomes a pod; kubectl run --image=busybox fails closed with no matching policies. The project's own sample policies walk through exactly this shape, including SBOM attestation enforcement.

Kyverno covers the broader policy surface with verifyImages rules inside a ClusterPolicy: check the cosign signature and the SBOM attestation presence, then — in the same policy object — enforce the three content rules from the opening table (approved base images, license allowlist, digest pinning instead of floating tags). One engine, signature verification plus content policy, no second webhook to operate.

What about Kubernetes' built-in ValidatingAdmissionPolicy with CEL expressions? It cannot do this job, and the reason is structural: an in-process CEL expression cannot reach out to a container registry to fetch signatures and attestations. Signature verification inherently needs a controller with network access and registry credentials — policy-controller, Kyverno, or Ratify behind Gatekeeper/Azure Policy. Use in-tree policies for what they are good at (shape and label checks on the request object itself); outsource image trust to something that can actually phone the registry.

The rejection table from the top, made concrete for a shared-node fleet:

  • Unapproved base. The SBOM names the run image and every layer beneath the app. If a tenant's image rebases onto an unpatched or unknown base, admission rejects it before it shares a kernel with anyone. The OWASP DevSecOps guidance states the rule the way auditors like to hear it: images that fail policy are blocked at admission, not flagged in a dashboard.
  • Forbidden license. SPDX documents carry license expressions per package. If your posture excludes strong-copyleft code from the fleet's base layers, the SBOM is the only place to check mechanically — and admission is the only place where "reject" actually prevents distribution.
  • Critical-CVE threshold. Resolving the SBOM against a vulnerability feed at admission time turns "we scan nightly and file tickets" into "this image does not schedule until the critical is fixed or explicitly waived." Waivers belong in the policy as expiring exceptions, not as silence in a scanner UI.

Why admission beats scan-after-deploy on a shared node

The incumbent pattern — deploy first, scan the running image later, alert somebody — fails on a multi-tenant fleet in three specific ways, and each one is a row in the comparison that matters:

Scan after deployVerify at admission
Blast radiusThe vulnerable image is already sharing a node (and a kernel) with other tenants while the ticket sits in a queueThe image never schedules; the blast radius is a failed deploy event
Time to enforcementHours to days, bounded by scan cadence plus human triageMilliseconds, bounded by webhook latency on the create path
Who actsA human reading a dashboard, per finding, foreverThe policy engine, per deploy, automatically — humans only handle waivers and appeals

The first row is the one that should end the debate for bin-packed fleets. On a dedicated single-tenant node, a vulnerable image mostly endangers its owner. On a shared Hetzner box running unrelated tenants' pods side by side, every hour between "image scheduled" and "someone reads the scan report" is an hour the vulnerable code shares a kernel with neighbors. Shifting the check left of scheduling doesn't just find problems earlier — it changes the failure domain from "incident response across tenants" to "one tenant's deploy was rejected with a reason they can act on."

There is a cost, stated plainly so it doesn't read as a footnote: admission webhooks are load-bearing control-plane components. A webhook that fails closed can block all deploys during an outage; one that fails open silently disables your supply-chain story. Run the controller highly available, monitor its webhook latency as a deploy-path SLO, and keep a break-glass procedure — a labeled exempt namespace or an emergency policy suspension — that is itself audited. A gate with no fire exit gets propped open permanently at the first incident, which is worse than a slower rollout of the gate.

SBOM freshness is the second honest cost. An SBOM is a snapshot of build time; new CVEs are disclosed against old components daily. Admission-time CVE thresholds need a resolver that maps the attested SBOM against current feed data at verify time — not the scan results cached from last Tuesday's build. Design the pipeline so the SBOM is the stable inventory and the vulnerability verdict is computed fresh on every admission request.


What to build this quarter

If this post earns one outcome, it is a four-item backlog a small platform team can actually finish: emit the buildpack SBOM and confirm pack sbom download reproduces it; attest and keylessly sign every production image digest in CI; install one admission controller — policy-controller for signature-first enforcement, Kyverno if you also want content rules in the same object — with a fail-closed webhook and a monitored SLO; and write the three-rule policy above with expiring waivers instead of silent exceptions. Each step is independently useful, and each one compounds: the SBOM is documentation on day one and a deny decision on day thirty.

The deeper point generalizes beyond any single tool. Your builder already tells the truth about what it shipped. The distance between "we have SBOMs" and "unapproved code cannot schedule" is exactly two links — a signature and an admission check — and both are off-the-shelf CNCF components, not a research project. Platforms that close that distance get to answer the auditor, the tenant, and the 3 a.m. page with the same sentence: that image couldn't have been running here, because the cluster would have refused it.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex