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)'
What each source of inventory seesA manifest lists direct dependencies. A lockfile adds the transitive graph. Scanning the built image adds the base image's operating-system packages, which is where a large share of advisories actually land.packages discovered, by sourcemanifest only34lockfile412built image690the difference between the last two is the base image, and it is where the CVEs are

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
From build to a verifiable inventoryThe bill of materials is generated from the built image, attested against its digest, and pushed to the registry beside the artefact. A consumer with only the digest can retrieve and verify it without asking anyone.Built imagedigest knownScan the imageOS + app packagesAttestbound to the digestPublishregistry + releasebinding to the digest is what makes the inventory evidence rather than a claim

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
A published file against an attested inventoryA file attached to a release describes an artefact and can be replaced without detection. An attestation is signed and names the artefact's digest, so a substituted inventory fails verification and a genuine one can be retrieved by anyone holding the digest.Unsigned release assetSigned attestationbound to the artefactby filename onlyby digestsubstitution detectablenoyesretrievable from the digestnoyeseffortupload a fileone build stepthe first row is the difference between documentation and evidence

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.