
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-pipelineEvery 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: trueThe 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: trueKeyless 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 --clobberIt 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.
# Native GitHub provenance (the OCI attestation attached to the image)gh attestation verify oci://ghcr.io/stephrobert/secure-python-pipeline:1.0.0 \ --owner stephrobertThe 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.0Loaded 2 attestations from GitHub API
✓ Verification succeeded!
sha256:… was attested by:REPO PREDICATE_TYPE WORKFLOWstephrobert/secure-python-pipeline https://slsa.dev/provenance/v1 .github/workflows/release.yml@refs/tags/v1.0.0The 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.
# Keyless Cosign signature: workflow identity and OIDC issuer are mandatorycosign 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 certificatesOn 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, andcontents: writefor the release asset. - The
sha256:...digest exposed by thebuildstep is the single subject of every attestation: provenance, SBOM and signature all point at the same fingerprint. - The native
attest-build-provenanceaction 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.jsonlattached to the release, distinct from the OCI attestation. - A third party verifies everything with
gh attestation verifyandcosign verify, the latter always with--certificate-identity-regexpand--certificate-oidc-issuer. - A local
no signatures foundoften comes from a Cosign 2.x facing an image signed in 3.x: align the version before concluding.
Next steps
- Lab 5: scoring and hardening: measuring what provenance and signature bring to the OpenSSF Scorecard result.
- Verifying attestations: the command-by-command detail, and how to verify at admission time in Kubernetes.
- Maintaining the pinning: keeping the seven pinned actions of this workflow up to date over time.