Publishing a signed SBOM with each release Jump to heading
A software bill of materials answers “what is inside this artefact” — which becomes urgent the day an advisory names a library and nobody can say whether it is in production. Generating one is easy; the parts that get skipped are attesting it against the artefact’s digest, so it cannot be substituted, and publishing it somewhere consumers will look. An unsigned inventory file in a release page is a claim about an artefact rather than a statement bound to it. This recipe closes both gaps, within build provenance and attestations.
When to use this approach Jump to heading
- A customer, auditor or regulation requires a bill of materials.
- Advisory response currently involves grepping lockfiles across repositories.
- You publish artefacts that other teams or organisations consume.
- You already generate provenance and want the companion attestation.
- If nothing consumes the inventory, generate it anyway but do not invest in distribution yet.
Step 1 — Generate it from the artefact, not from the manifest Jump to heading
A bill of materials produced from package.json describes what you asked for. One produced from the built image describes what is actually there, including the base image’s contents.
# From the built image — the authoritative source
syft "ghcr.io/acme/app@${DIGEST}" -o spdx-json > /tmp/sbom.spdx.json
jq -r '.packages | length' /tmp/sbom.spdx.json # Compare with a manifest-derived one, to see what the base image contributes
syft dir:. -o spdx-json | jq -r '.packages | length' # Verification: the image inventory includes OS packages the manifest never mentions
jq -r '.packages[].name' /tmp/sbom.spdx.json | grep -cE '^(libc|openssl|zlib)' Step 2 — Attest it against the digest Jump to heading
An attestation binds the inventory to the exact artefact, which is what makes substitution detectable.
cosign attest --yes \
--predicate /tmp/sbom.spdx.json \
--type spdxjson \
"ghcr.io/acme/app@${DIGEST}" # Or, with the forge's native action
- uses: actions/attest-sbom@v1
with:
subject-name: ghcr.io/acme/app
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: /tmp/sbom.spdx.json
push-to-registry: true # Verification: the attestation retrieves and its subject is the right digest
cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '^https://github\.com/acme/app/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"ghcr.io/acme/app@${DIGEST}" | jq -r '.payload' | base64 -d | jq -r '.subject[0].digest.sha256' SAFETY WARNING — publishing an inventory as an unsigned file alongside a release lets anyone substitute a different one, and consumers have no way to detect it. That matters most in exactly the situation an inventory exists for: an advisory response, where a substituted file showing a patched version is indistinguishable from the truth. Attest it, or do not claim it is authoritative.
Step 3 — Publish it where consumers will look Jump to heading
# In the registry, alongside the image — discoverable from the digest alone
cosign download attestation "ghcr.io/acme/app@${DIGEST}" \
| jq -r 'select(.payloadType) | .payload' | base64 -d | jq -r '.predicateType' # And as a release asset, for consumers without registry access
gh release upload "v${VERSION}" /tmp/sbom.spdx.json --clobber # Verification: both routes return the same document
sha256sum /tmp/sbom.spdx.json
gh release download "v${VERSION}" -p 'sbom.spdx.json' -O - | sha256sum Step 4 — Use it when an advisory lands Jump to heading
This is the step that justifies the other three, and it is worth rehearsing before it is needed.
# Is the named package present, and at which version?
gh attestation verify "oci://ghcr.io/acme/app@${DIGEST}" --owner acme --format json \
| jq -r '.[0].verificationResult.statement.predicate.packages[]
| select(.name == "libxml2") | "\(.name) \(.versionInfo)"' # Across every deployed artefact, which is the question actually being asked
for d in $(cat deployments.tsv | awk -F'\t' '{print $4}' | sort -u); do
printf '%s ' "$d"
cosign download attestation "$d" 2>/dev/null | jq -r '.payload' | base64 -d \
| jq -r '.predicate.packages[] | select(.name=="libxml2") | .versionInfo' | head -1
echo
done # Verification: the answer comes back in seconds, not in an afternoon Step 5 — Keep the inventory honest as the artefact changes Jump to heading
An inventory generated once and reused is a claim about a different artefact.
# Generate and attest on every build, not only on releases
- run: syft "ghcr.io/acme/app@${{ steps.push.outputs.digest }}" -o spdx-json > sbom.json
- uses: actions/attest-sbom@v1
with:
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: sbom.json
push-to-registry: true # A check that every deployed digest has an inventory attestation
awk -F'\t' '{print $4}' deployments.tsv | sort -u | while read -r d; do
cosign download attestation "$d" >/dev/null 2>&1 || echo "MISSING SBOM: $d"
done # Verification: no deployed artefact lacks one Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
SPDX or CycloneDX? Jump to heading
Either; consumers generally accept both and tooling converts between them. Pick whichever your largest consumer asks for and be consistent, because publishing both doubles the maintenance for a difference that matters to almost nobody in practice.
Does an inventory replace vulnerability scanning? Jump to heading
No — it is the input to it. A scanner matches the inventory against advisory databases, and keeping the two separate means the inventory stays a factual record while the vulnerability picture changes daily as new advisories are published. An artefact’s inventory is fixed; its vulnerability status is not.
What about dependencies resolved at runtime? Jump to heading
They are outside the inventory by definition, which is a genuine limitation and an argument for not resolving dependencies at runtime. Where it is unavoidable — a plugin system, a downloaded model — record what the artefact is capable of fetching, and treat the gap as a known one rather than an invisible one.
Related Jump to heading
- Build Provenance & Attestations — the parent topic and the companion attestation.
- Verifying Attestations Before Deploy — the gate that checks both kinds.
- Auditing Vendored Dependencies for Tampering — the same question for code committed directly.