Skip to main content

Cloud Native Buildpacks Ship SBOMs by Default: What a Build-Time Bill of Materials Gets You That a Scanner Never Can

9 min readDora NodaDora Noda
Share
On this page

On September 11, 2026 — two months from now — every manufacturer shipping software with digital elements into the EU has to be able to report an actively exploited vulnerability to ENISA and the relevant national CSIRT within 24 hours. That's a reporting-obligation deadline under the Cyber Resilience Act, arriving more than a year before the CRA's main December 2027 compliance date, and it's not optional homework — non-compliance can trigger fines up to €15 million or 2.5% of global annual turnover, whichever is higher. The 24-hour clock doesn't start with the vulnerability disclosure, either. It starts with knowing, precisely, which of your images contain the affected component, at which version, in which layer. You cannot answer that question in 24 hours from a Slack thread and a hunch. You answer it from a bill of materials that already exists, before the CVE ever drops.

Most teams don't have one that's actually reliable. Omdia's 2026 supply-chain security report found 73% of organizations that generate SBOMs say the practice measurably speeds up vulnerability response — the payoff is real — but 86% still call the generation process genuinely hard. That gap is usually a tooling problem, not a discipline problem: the SBOM gets bolted on after the image is built, produced by a scanner guessing at a filesystem it never watched get assembled. Cloud Native Buildpacks close that gap differently. They generate the SBOM as a side effect of the build itself, because the buildpack is the thing that resolved and installed every dependency in the first place. It doesn't have to guess.

What's Actually in a Buildpack SBOM​

When a Cloud Native Buildpack (Paketo's Node.js, Python, Java, or Go buildpacks are the common case) installs a dependency, it doesn't just drop files into a layer and move on. It writes a structured bill-of-materials entry for that exact dependency, at build time, as part of the buildpack lifecycle contract — not as an afterthought bolted onto CI.

Each entry — Paketo calls it a BOM entry, expressed per the CNB buildpack.toml/launch.toml spec — carries fields a filesystem scan has to reconstruct or infer:

FieldWhat it captures
PURLPackage URL identifying the exact package and ecosystem
CPECommon Platform Enumeration identifier, for CVE matching
VersionThe version actually resolved and installed, not the version declared in a manifest
ChecksumHash of both the source and the compiled/installed artifact
LicenseLicense(s) associated with the dependency
SourceWhich buildpack installed it, and from where

You pull this out directly: pack build myapp --sbom-output-dir /tmp/sbom writes the generated documents to disk, in whichever of three interoperable formats the buildpack declares support for — CycloneDX (*.sbom.cdx.json), SPDX (*.sbom.spdx.json), or Syft's own format (*.sbom.syft.json), enumerated in the buildpack's buildpack.toml under sbom-formats. No separate scan step, no separate tool to run, no separate pipeline stage that can silently fall out of a CI config during a refactor. The SBOM ships because the build shipped.

A single CycloneDX entry for one resolved dependency looks roughly like this — trimmed, but every field below came from the buildpack's own install step, not a guess:

json
{
  "type": "library",
  "name": "express",
  "version": "4.21.2",
  "purl": "pkg:npm/express@4.21.2",
  "licenses": [{ "license": { "id": "MIT" } }],
  "hashes": [{ "alg": "SHA-256", "content": "a1b2c3..." }]
}

Multiply that by every package the buildpack actually installed — direct and transitive — and you have a document that answers "do we have this component, and at what version" without anyone having to open a shell into a running container to check.

Because the spec itself (not one vendor's roadmap) governs the format, this isn't a Paketo-specific trick either — any CNB-compliant buildpack that declares sbom-formats in its buildpack.toml produces the same structured output, so the guarantee travels with the standard, not with a single implementation a company could deprecate out from under a tenant.

Why a Post-Hoc Scan Structurally Can't Match It​

Compare that to the nearest equivalent for a Dockerfile-first pipeline: Docker BuildKit's own SBOM attestation, available since BuildKit 0.11 via docker buildx build --sbom=true (shorthand for --attest type=sbom). It's a real feature, and it's the right default for a Dockerfile-based build to reach for. But look at what it's actually doing: by default it runs BuildKit's Syft scanner plugin against the finished image — a filesystem inspection after the fact, producing an SPDX document wrapped in an in-toto attestation.

A finished-image scan has three specific, well-documented blind spots a build-time SBOM doesn't:

  • Build-stage-only dependencies vanish. In a multi-stage Dockerfile, whatever your builder stage installed — compilers, dev headers, a full npm install before npm prune --production — never appears in the final image's filesystem, so a scan of the final image can't see it. If a build-time dependency turns out to have a vulnerable version, you have no record that it was ever there, even though it touched your build.
  • Statically linked binaries get undercounted. A scanner reading a compiled Go binary or a statically linked C library sees one opaque executable, not the dependency graph that went into it. The buildpack that built it knew the graph; the scanner reading the output doesn't.
  • Declared versus resolved versions drift. A scanner can usually read a lockfile if one's present in the image, but it's reading a declaration. A buildpack's BOM entry records the version it actually resolved and installed at build time — the two aren't guaranteed to match once semver ranges, private registries, or vendored patches are in play.

None of this makes BuildKit's attestation useless — it's the right tool for a Dockerfile pipeline, and it's a real improvement over having no SBOM at all. But it's answering a different question than a buildpack SBOM answers. One says "here's what a scanner found by reading the box after it was sealed." The other says "here's exactly what I put in the box, because I'm the one who packed it."

The 24-Hour Test​

Run the CRA's own deadline through both pipelines and the gap stops being theoretical. Say a critical CVE lands in a transitive logging dependency three layers deep in a Node.js app's dependency tree — the kind of disclosure that shows up on a Friday afternoon.

On a buildpack-built fleet, the check is a query: pull every image's stored CycloneDX/SPDX document (already generated at build time, already sitting in the registry or artifact store next to the image), grep for the package's PURL, and you have a list of affected tenants in minutes — version numbers included, no need to even pull the image. That list is what goes into the 24-hour report.

On a Dockerfile-first fleet running BuildKit's attestation, the same query works cleanly if the vulnerable package survived into the final image. If it was a build-stage-only dependency — a transitive package pulled in during npm install before a multi-stage copy left it behind in the builder layer — the attestation on the shipped image won't mention it at all, and the team has no record it was ever there to check against. The honest answer, in that case, isn't "we're clear." It's "we can't tell from the SBOM we have, so someone has to go pull build logs or rebuild the image to find out" — the exact scramble a pre-generated bill of materials exists to prevent, arriving on the one day a 24-hour clock is actually running.

What a Dockerfile-First Pipeline Has to Bolt On​

If your build pipeline is Dockerfile-first — which describes most self-hosted PaaS build layers, bex's included, for tenants who bring their own Dockerfile instead of relying on buildpack auto-detection — SBOM parity with a buildpack pipeline isn't free. It's an explicit step:

  1. Require BuildKit 0.11+ on every build node (a Cluster-API-provisioned fleet controls this directly, unlike a rented CI runner where the BuildKit version is whatever the vendor shipped).
  2. Add --sbom=true (or the equivalent --attest type=sbom flag) to the build invocation, storing the resulting attestation alongside the image in the registry rather than discarding it.
  3. Document the three gaps above for tenants who ask why their Dockerfile-built image's SBOM doesn't show a dependency they know was installed mid-build — because a support ticket asking "where did my build-time dependency go" is a worse way to discover this limitation than a docs page.
  4. Accept that a multi-stage Dockerfile with a genuinely disposable builder stage will always under-report relative to a buildpack build of the equivalent app, and that this is a structural property of scanning a sealed box, not a bug in BuildKit's scanner.

That's a real cost, but it's a bounded one — a flag, a storage location, and a documentation section, not a new pipeline stage or a third-party vendor contract. The gap is honest, not catastrophic.

What This Means for a Self-Hosted PaaS's Build Step​

For a git-push platform, the practical split is exactly the fork above: buildpack-first tenants get build-time SBOM provenance for free, populated by the same detect-and-build step that already produces their running image. Dockerfile-first tenants get BuildKit's attestation as the documented fallback — real, useful, and worth defaulting on, but explicitly not equivalent, with the specific gap (build-stage dependencies, static binaries, version drift) stated rather than glossed over.

That distinction is the actual answer to a tenant asking "can you help me get ready for the EU CRA's September deadline." Not a checkbox that says "SBOMs: yes," but a straight explanation of which of their two build paths already gives them a complete, build-native bill of materials, and which one gives them a good-but-partial one they should know the shape of before an auditor — or a market surveillance authority — asks to see 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