Skip to content
Français
CI/CD & Automatisation medium

Verifiable build: SLSA provenance, SBOM and signature

55 min de lecture

Read this page in French

GitHub logo

The CI pipeline guarantees the code is tested and hardened. What remains is making the published image verifiable: proving where it comes from, what it holds, and that it has not been substituted in the registry. This guide builds, step by step, the release.yml workflow of the reference repository: build and push by digest to GHCR, native SLSA provenance, an attested CycloneDX SBOM and a keyless Cosign signature. It then shows how a third party verifies the whole chain, and why a misaligned Cosign version makes a release look broken when it is not. The underlying rule: an image with no provenance and no signature is indistinguishable from a compromised one.

What you will learn

  • Build and push an image to the GitHub registry (GHCR) by digest
  • Generate a SLSA provenance attestation with the native GitHub action
  • Attest a CycloneDX SBOM and sign the image with Cosign in keyless mode
  • Verify the provenance and the signature as a third party, without the version trap

Prerequisites

The hardened CI pipeline is in place: it is where the base workflow hardening is introduced (SHA pinning, permissions: {}, harden-runner, a checkout without credentials). This guide does not explain it again, it applies it. On the machine side, the gh CLI is authenticated and Docker is available for the local checks. All the code shown here comes from the public reference repository github.com/stephrobert/secure-python-pipeline: you can clone it, inspect the workflows and replay the checks against the real published image.

Workflow anatomy and permissions

The workflow fires when a release is published and chains, inside a single job, the build, the attestations and the signature. The release: [published] trigger guarantees you only sign versions that were explicitly tagged, never every push. The sensitive part is the permissions: block: it starts from zero at workflow level, then opens only the strict minimum at job level.

name: Release
on:
release:
types: [published]
# No permission by default.
permissions: {}
jobs:
build-and-attest:
name: Build, push, attest and sign
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write # attach the provenance to the release (Signed-Releases)
packages: write # push to GHCR
id-token: write # keyless OIDC (provenance + cosign)
attestations: write # native GitHub attestations
env:
IMAGE: ghcr.io/stephrobert/secure-python-pipeline

Every right has a single justification. packages: write allows the push to GHCR. id-token: write issues the OIDC token that Cosign and the provenance action need to sign without a long-lived key. attestations: write allows publishing the native attestations. Finally contents: write serves only to attach the provenance as a release asset, a requirement of the Scorecard check covered below. Nothing more is requested.

Build and push by digest to GHCR

Before attesting anything, you need an image pushed and addressable by digest. The sha256:... digest is the immutable fingerprint of the image content: it, not the mutable tag, is the subject of every attestation. You chain the runner hardening, the registry login, the Buildx setup, the tag computation, then the build.

steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login GHCR
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Metadata (tags and labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- name: Build and push
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Two details carry everything else. The login step uses the GITHUB_TOKEN as its password: no long-lived secret travels. And the Build and push step carries an id: build, which exposes its steps.build.outputs.digest output. That digest is the value you will pass, unchanged, to every attestation step. docker/metadata-action also generates a semver tag (the version number) and the latest tag, so the image can be found either way.

Native SLSA provenance

Provenance answers the question "which workflow, on which commit, produced this image?". Since late 2024, GitHub has provided a native action that generates a signed SLSA provenance attestation, with no third-party generator to deploy and no hand-written in-toto layout. You place it right after the build and hand it the digest of the pushed image.

- name: SLSA provenance attestation (native)
id: attest
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

The push-to-registry: true option attaches the attestation to the image in GHCR: it travels with it and stays verifiable by anyone. The step carries an id: attest because it exposes a bundle-path, the local path of the attestation bundle, which you reuse for the release. Technically, this action gives SLSA level L2 directly, and L3 through a reusable workflow; it is simpler and safer than a third-party generator, and it is the recommended practice today.

An attested CycloneDX SBOM

An SBOM (Software Bill of Materials, the exhaustive inventory of the image components) answers "what is inside?". You generate it with Syft in CycloneDX JSON format, then attest it by tying it to the same digest. The modern practice is to attach the SBOM to the image in the registry, not to leave it lying around as a detached file nobody connects to anything.

- name: Generate the SBOM (Syft, CycloneDX)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
# The SBOM is attested to the registry (attest-sbom): no need to
# attach it to the release, which would require contents: write.
upload-release-assets: false
- name: Attest the SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cdx.json
push-to-registry: true

Keyless Cosign signature

Keyless signing (with no long-lived key) rests on the OIDC identity of the workflow. Cosign requests a short-lived certificate from the Fulcio authority, signs the digest, then records the operation in the public Rekor transparency log. There is no private key to store or rotate: the workflow identity is the proof, and Rekor makes the signature auditable by anyone.

- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign the image (keyless Sigstore)
env:
IMAGE: ${{ env.IMAGE }}
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes "${IMAGE}@${DIGEST}"

Two points of vigilance. The digest never travels straight into the run: as a ${{ }} interpolation: it goes through an env: block, and the command uses ${DIGEST}. That is the countermeasure against template injection, applied systematically whenever dynamic data enters a run:. The other point is the --yes flag, which skips the interactive confirmation (impossible in CI). The installer version, meanwhile, hides a trap covered at the end of the guide.

Attaching the provenance to the release

One last step often escapes attention. The Signed-Releases check of OpenSSF Scorecard does not look at the OCI attestation attached to the image: it looks for a *.intoto.jsonl file attached as a release asset. So you copy the bundle produced by the attest step under that name, and attach it to the release with gh.

- name: Attach the provenance to the release (Scorecard Signed-Releases)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
cp "$BUNDLE" provenance.intoto.jsonl
gh release upload "$TAG" provenance.intoto.jsonl --clobber

It is precisely that step which unlocks Signed-Releases 10 in Scorecard; the details of the calculation (an average over the last five releases, floored by integer division) are covered in Scoring and hardening. Here, remember that the OCI attestation and the release asset are two distinct artifacts: one serves third-party verification, the other serves the scoring.

Verifying the chain as a third party

An attestation that cannot be verified is worth nothing. On a public repository, with Rekor open, anyone can check the provenance and the signature with no particular access to the repository. Two commands are enough: gh attestation verify for the native provenance, and cosign verify for the keyless signature.

Fenêtre de terminal
# Native GitHub provenance (the OCI attestation attached to the image)
gh attestation verify oci://ghcr.io/stephrobert/secure-python-pipeline:1.0.0 \
--owner stephrobert

The output confirms the loaded digest and the validation of the attestations found:

Loaded digest sha256:… for oci://ghcr.io/stephrobert/secure-python-pipeline:1.0.0
Loaded 2 attestations from GitHub API
✓ Verification succeeded!
sha256:… was attested by:
REPO PREDICATE_TYPE WORKFLOW
stephrobert/secure-python-pipeline https://slsa.dev/provenance/v1 .github/workflows/release.yml@refs/tags/v1.0.0

The Cosign signature is verified next. Both certificate options are mandatory: without them, cosign verify would accept any identity, which would drain the verification of its meaning.

Fenêtre de terminal
# Keyless Cosign signature: workflow identity and OIDC issuer are mandatory
cosign verify ghcr.io/stephrobert/secure-python-pipeline:1.0.0 \
--certificate-identity-regexp "https://github.com/stephrobert/secure-python-pipeline/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"

A successful verification lists the checks that passed and returns the signature payload:

Verification for ghcr.io/stephrobert/secure-python-pipeline:1.0.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates

On the repository's v1.0.0 image, those checks return three attestations tied to the same digest: the SLSA v1 provenance, the Cosign signature, and the CycloneDX SBOM. The command-by-command detail is explored in Verifying attestations.

Automating the verification

Verifying by hand is useful to understand; in practice, you tool the verification so a maintainer can replay it without memorising the options. The verify-slsa.yml workflow does exactly that. It fires manually (workflow_dispatch) with the tag as an input, which avoids racing the image push of a fresh release, and requires read permissions only.

name: Verify SLSA
# Verifies the provenance and the signature of the published image. Manual
# trigger (after a release) to avoid any race with the image push.
on:
workflow_dispatch:
inputs:
tag:
description: "Tag of the image to verify (for example 1.0.0 or latest)"
required: true
default: "latest"
permissions: {}
jobs:
verify:
name: Verify provenance and signature
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: read
env:
IMAGE: ghcr.io/stephrobert/secure-python-pipeline
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Verify the provenance attestation (native GitHub)
env:
IMAGE: ${{ env.IMAGE }}
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh attestation verify "oci://${IMAGE}:${TAG}" --owner stephrobert
- name: Verify the Cosign signature (keyless)
env:
IMAGE: ${{ env.IMAGE }}
TAG: ${{ inputs.tag }}
run: |
cosign verify "${IMAGE}:${TAG}" \
--certificate-identity-regexp "https://github.com/stephrobert/secure-python-pipeline/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"

This workflow installs the same Cosign as the release pipeline, which rules out the version trap described below from the start. The tag and the image travel through env:, never interpolated straight into the run:, consistent with the injection rule applied throughout the series.

The Cosign version trap

This trap costs hours because it produces a credible false negative. The sigstore/cosign-installer@v4 installer lays down Cosign 3.x, whose signature and bundle format differs from 2.x. If your machine still has a Cosign 2.x installed, a local cosign verify returns no signatures found while the image is perfectly signed. The problem is not the release: it is the local tool, unable to read the new format.

The countermeasure is one line of discipline: install Cosign the same way as in CI (the Sigstore installer, pinned by SHA), and confirm the version at the slightest doubt. It is also why verify-slsa.yml reuses the installer rather than a pre-provisioned binary: the verification workflow and the release workflow stay aligned by construction.

Key points

  • The release workflow starts from permissions: {} and opens only four justified rights: packages, id-token, attestations, and contents: write for the release asset.
  • The sha256:... digest exposed by the build step is the single subject of every attestation: provenance, SBOM and signature all point at the same fingerprint.
  • The native attest-build-provenance action produces a signed SLSA provenance (L2 directly, L3 through a reusable workflow), with no third-party generator.
  • The CycloneDX SBOM is attested to the registry; its redundant attachment to the release is switched off with upload-release-assets: false.
  • The keyless Cosign signature anchors on the workflow's OIDC identity, through Fulcio and Rekor, with no long-lived key.
  • Scorecard's Signed-Releases requires a *.intoto.jsonl attached to the release, distinct from the OCI attestation.
  • A third party verifies everything with gh attestation verify and cosign verify, the latter always with --certificate-identity-regexp and --certificate-oidc-issuer.
  • A local no signatures found often comes from a Cosign 2.x facing an image signed in 3.x: align the version before concluding.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn