Secure a CI/CD Supply Chain with SBOM and Cosign
Build a GitHub Actions pipeline that scans an image, publishes it by digest, adds an SBOM, signs it with Cosign and verifies it before deploy.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a GitHub Actions pipeline that builds a container image once, scans it, publishes it by digest, generates a software bill of materials (SBOM), signs the image with Sigstore Cosign using keyless signing, and then verifies the signature before anything could deploy it. Each step answers one question about the image: what is in it, who built it, and has it been changed since.
You finish with a registry image that carries a signature and an SBOM attestation, plus a verification job that fails if either is missing or comes from a different workflow.
The ideas behind it
- An SBOM (software bill of materials) is a machine-readable list of every package inside the image. When a new CVE appears next month, you can search your SBOMs to find affected images without rescanning.
- A digest (
sha256:...) is a hash of the exact image contents. A tag such aslatestcan be moved to a different image; a digest cannot. - Keyless signing means there is no private key to protect. Sigstore issues a short-lived certificate tied to the GitHub Actions identity of the workflow run, and records the signing event in a public transparency log.
- An attestation is a signed statement about an image. Here the statement is "this SBOM describes this image".
What signing does and does not prove
A valid signature proves that an image was produced by a specific workflow in a specific repository and has not been altered since. It does not prove the image is free of vulnerabilities, and it does not help if the build system itself is compromised, which is why the pipeline also scans and why access to the repository and workflow files must be tightly controlled.
Prerequisites
- A GitHub repository where you can run Actions and publish packages to
ghcr.io - Git, Docker and Python 3.12 locally
- Comfort with the "DevSecOps Pipeline with Security Gates" project; this one builds on it and skips secret and IaC scanning
TipPinned versions in this project were current when it was written: Trivy 0.75.0, Syft 1.54.1, Cosign 3.1.3. Change a version and its checksum together.
Milestone 1: Create the App and a Hardened Image
This milestone creates a minimal service and a Dockerfile that already follows basic hardening, so the later steps focus on the supply chain instead of fixing basics.
mkdir supply-chain-demo && cd supply-chain-demogit init -b mainmkdir -p app scripts .github/workflowstouch app/__init__.pyprintf '.venv/\n__pycache__/\n*.pyc\n' > .gitignore# app/main.pyfrom flask import Flask, jsonify app = Flask(__name__) @app.get("/health")def health(): return jsonify(status="ok")# requirements.txtFlask==3.1.3# DockerfileFROM python:3.12-slimWORKDIR /srvCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt \ && useradd --system --no-create-home appuserCOPY app ./appUSER appuserEXPOSE 8080CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]docker build -t supply-chain-demo:local .docker run --rm -d -p 8080:8080 --name sc supply-chain-demo:localcurl -s http://127.0.0.1:8080/healthdocker stop scgit add . && git commit -m "Add demo service"Expected result: the curl prints {"status":"ok"}.
Common MistakeUsing a floating base image tag and never rebuilding. A base image that is months old accumulates known CVEs. Rebuild on a schedule or when the base changes, and let the image scan decide when the result is no longer acceptable.
Milestone 2: Install Pinned and Verified Tools
This milestone adds an install script for the three tools the pipeline needs. Each download is checked against a SHA-256 checksum so a tampered release fails loudly instead of running inside your pipeline.
#!/usr/bin/env bash# scripts/install-tools.sh# Usage: scripts/install-tools.sh trivy syft cosignset -euo pipefail TRIVY_VERSION="0.75.0"TRIVY_SHA256="c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f"SYFT_VERSION="1.54.1"SYFT_SHA256="c069905b391cc4c20a5ba65ad5c10be2a7ba074f8ea6ad203e24d14e303dad47"COSIGN_VERSION="3.1.3"COSIGN_SHA256="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71" BIN_DIR="${HOME}/.local/bin"TMP_DIR="$(mktemp -d)"mkdir -p "${BIN_DIR}" fetch() { local url="$1" sha="$2" out="$3" curl -fsSL -o "${out}" "${url}" echo "${sha} ${out}" | sha256sum -c -} for tool in "$@"; do case "${tool}" in trivy) fetch "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" "${TRIVY_SHA256}" "${TMP_DIR}/trivy.tgz" tar -xzf "${TMP_DIR}/trivy.tgz" -C "${TMP_DIR}" trivy install -m 0755 "${TMP_DIR}/trivy" "${BIN_DIR}/trivy" ;; syft) fetch "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" "${SYFT_SHA256}" "${TMP_DIR}/syft.tgz" tar -xzf "${TMP_DIR}/syft.tgz" -C "${TMP_DIR}" syft install -m 0755 "${TMP_DIR}/syft" "${BIN_DIR}/syft" ;; cosign) fetch "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" "${COSIGN_SHA256}" "${TMP_DIR}/cosign" install -m 0755 "${TMP_DIR}/cosign" "${BIN_DIR}/cosign" ;; *) echo "unknown tool: ${tool}" >&2; exit 1 ;; esacdone if [ -n "${GITHUB_PATH:-}" ]; then echo "${BIN_DIR}" >> "${GITHUB_PATH}"fiecho "installed: $*"chmod +x scripts/install-tools.shscripts/install-tools.sh trivy syft cosignexport PATH="$HOME/.local/bin:$PATH"trivy --version && syft version && cosign versionSecurityThe script targets Linux on x86-64, matching GitHub's
ubuntu-24.04runners. Never copy a checksum from the same place as the download without checking it; take it from the release's published checksum file.
Milestone 3: Build Once, Scan, and Publish by Digest
This milestone builds the image, scans it locally in the runner, and only then pushes it. The order matters: nothing unscanned ever reaches the registry, and the pipeline records the digest so every later step refers to exactly the same image.
Try it locally first
docker build -t supply-chain-demo:local .trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 supply-chain-demo:localecho "scan exit code: $?"Expected result: exit code 0 when the base image has no fixable HIGH or CRITICAL findings. If it fails, rebuild with docker build --pull to get a newer base.
Why sign the digest, not the tag
Tags are labels that can be moved. If you sign app:latest and someone later pushes a different image under that tag, the signature does not follow the new image. Signing app@sha256:... ties the signature to the exact bytes.
Create the first part of the workflow
# .github/workflows/supply-chain.ymlname: Supply chain on: push: branches: [main] pull_request: branches: [main] permissions: contents: read env: REGISTRY: ghcr.io jobs: build-sign: name: Build, scan, SBOM, sign runs-on: ubuntu-24.04 permissions: contents: read packages: write id-token: write outputs: image: ${{ steps.meta.outputs.image }} digest: ${{ steps.push.outputs.digest }} identity: ${{ steps.meta.outputs.identity }} steps: - uses: actions/checkout@v4 - name: Install tools run: scripts/install-tools.sh trivy syft cosign - name: Compute image name and signer identity id: meta run: | IMAGE="${REGISTRY}/${GITHUB_REPOSITORY,,}" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "identity=https://github.com/${GITHUB_WORKFLOW_REF}" >> "$GITHUB_OUTPUT" - name: Build image once run: docker build -t "${{ steps.meta.outputs.image }}:${{ github.sha }}" . - name: Scan image before publishing run: trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "${{ steps.meta.outputs.image }}:${{ github.sha }}" - name: Log in to the registry if: github.event_name == 'push' run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: Push and record the digest id: push if: github.event_name == 'push' run: | IMAGE="${{ steps.meta.outputs.image }}:${{ github.sha }}" docker push "${IMAGE}" DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "${IMAGE}" | cut -d@ -f2)" echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" echo "Pushed ${{ steps.meta.outputs.image }}@${DIGEST}" >> "$GITHUB_STEP_SUMMARY"The id-token: write permission lets the job request an identity token from GitHub, which Cosign uses in the next milestone. Pull requests build and scan but never log in or push.
Common MistakeGranting
packages: writeandid-token: writeat the top of the file. Every job would then be able to publish and sign. Grant them on the single job that needs them, as above.
Milestone 4: Generate and Check the SBOM
This milestone generates a CycloneDX SBOM from the image that was just pushed. Generating it from the image, not from the source folder, captures operating system packages as well as Python libraries.
Add the SBOM steps
Append these steps to the build-sign job.
- name: Generate SBOM from the pushed image if: github.event_name == 'push' run: | syft "${{ steps.meta.outputs.image }}@${{ steps.push.outputs.digest }}" \ -o cyclonedx-json=sbom.cdx.json python3 - << 'EOF' import json bom = json.load(open("sbom.cdx.json")) print("SBOM format:", bom["bomFormat"], bom["specVersion"]) print("Components:", len(bom.get("components", []))) assert bom.get("components"), "SBOM has no components" EOF - name: Scan the SBOM if: github.event_name == 'push' run: trivy sbom --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 sbom.cdx.json - uses: actions/upload-artifact@v4 if: github.event_name == 'push' with: name: sbom path: sbom.cdx.json retention-days: 90Why scan the SBOM as well
Trivy can read an SBOM instead of an image. That means you can re-run vulnerability checks months later against the stored list, without pulling the image, whenever the vulnerability database updates.
| Format | Use it when |
|---|---|
| CycloneDX | Security tooling and vulnerability matching |
| SPDX | Licence compliance and some procurement requirements |
TipThe component count is a sanity check, not a target. If it is zero, the SBOM tool could not read the image, and every later step would be attesting to an empty list.
Common MistakeGenerating the SBOM before the image exists, from the source directory. That misses the operating system layer, which is where many CVEs live.
Milestone 5: Sign the Image and Attach the SBOM
This milestone signs the image by digest with keyless Cosign and attaches the SBOM as a signed attestation. Both are stored in the registry next to the image, and both are recorded in the public Sigstore transparency log.
Add the signing steps
Append these to the build-sign job.
- name: Sign the image by digest if: github.event_name == 'push' run: cosign sign --yes "${{ steps.meta.outputs.image }}@${{ steps.push.outputs.digest }}" - name: Attest the SBOM if: github.event_name == 'push' run: | cosign attest --yes --type cyclonedx --predicate sbom.cdx.json \ "${{ steps.meta.outputs.image }}@${{ steps.push.outputs.digest }}"What happens during keyless signing
- The job asks GitHub for an OIDC token that says which repository, workflow and branch it is running as.
- Sigstore's certificate authority exchanges that token for a short-lived certificate containing that identity.
- Cosign signs with a one-time key, records the event in the transparency log, and discards the key.
There is no long-lived signing key to leak, rotate or lose. Anyone verifying the image checks the identity inside the certificate instead.
SecurityThe transparency log is public. The log entry contains your repository and workflow identity and the image digest, so do not sign images from a private repository if the repository name itself is confidential.
Common MistakeSigning a tag. If you run
cosign sign repo:tagthe signature is attached to whatever digest the tag points to at that moment, and Cosign warns about it. Always sign the digest.
Milestone 6: Verify Before You Deploy
A signature that nobody checks protects nothing. This milestone adds a separate job that verifies the signature and the SBOM attestation, and fails if either is missing or was produced by a different workflow. In a real pipeline this job is the gate in front of deployment.
Add the verify job
verify: name: Verify signature and SBOM runs-on: ubuntu-24.04 needs: [build-sign] if: github.event_name == 'push' permissions: contents: read packages: read steps: - uses: actions/checkout@v4 - name: Install Cosign run: scripts/install-tools.sh cosign - name: Log in to the registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: Verify the signature run: | cosign verify \ --certificate-identity "${{ needs.build-sign.outputs.identity }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${{ needs.build-sign.outputs.image }}@${{ needs.build-sign.outputs.digest }}" - name: Verify the SBOM attestation run: | cosign verify-attestation --type cyclonedx \ --certificate-identity "${{ needs.build-sign.outputs.identity }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${{ needs.build-sign.outputs.image }}@${{ needs.build-sign.outputs.digest }}" > /dev/null echo "Signature and SBOM verified"What the identity check means
The --certificate-identity value is the exact workflow file and branch that was allowed to sign, for example https://github.com/YOUR-USER/supply-chain-demo/.github/workflows/supply-chain.yml@refs/heads/main. An image signed from a fork, a different workflow or a different branch has a different identity and fails verification. That is the whole point: the check proves where the image came from, not just that someone signed it.
Enforce it in the cluster
A verify job in CI protects your pipeline. To protect the cluster itself, use an admission controller that verifies signatures when a pod is created, such as the Sigstore policy-controller or Kyverno's image verification policy. Both take the same two inputs you used above: the expected identity and the OIDC issuer. Consult their current documentation for the policy format, since it changes between releases.
git add .git commit -m "Add supply chain pipeline"git remote add origin git@github.com:YOUR-USER/supply-chain-demo.gitgit push -u origin mainCommon MistakeVerifying with
--certificate-identity-regexp '.*'to make errors go away. That accepts a signature from anyone's workflow, which makes verification meaningless. Keep the identity exact, and loosen it only to a narrow pattern you can defend.
Validation & Testing
Confirm that the pipeline produced what it claims, and prove the verification fails when it should.
Check the pieces on your machine
export PATH="$HOME/.local/bin:$PATH"IMAGE="ghcr.io/YOUR-USER/supply-chain-demo"DIGEST="sha256:PASTE-FROM-THE-RUN-SUMMARY"IDENTITY="https://github.com/YOUR-USER/supply-chain-demo/.github/workflows/supply-chain.yml@refs/heads/main" cosign verify --certificate-identity "$IDENTITY" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "$IMAGE@$DIGEST" cosign tree "$IMAGE@$DIGEST"Expected result: verification succeeds and prints the signature details, and cosign tree lists a signature and an SBOM attestation attached to the digest.
Prove verification can fail
Run the same command with a wrong identity.
cosign verify --certificate-identity "https://github.com/someone-else/repo/.github/workflows/x.yml@refs/heads/main" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "$IMAGE@$DIGEST"echo "exit code: $?"Expected result: the command fails with a certificate identity mismatch and a non-zero exit code. If it succeeds, your verification is not checking identity.
Checklist
- The
build-signjob pushed an image and printed its digest in the run summary. - The SBOM artifact exists and the component count printed in the log is greater than zero.
cosign verifyandcosign verify-attestationboth pass with the exact identity.- A pull request builds and scans but does not push, sign or verify.
Reflection questions
- Why sign the digest and not the tag?
- What does keyless signing remove, and what new thing must a verifier trust instead?
- Name one attack that signing stops and one it does not.
- Why scan the SBOM after scanning the image?
Portfolio deliverable
Your README should contain the pipeline diagram, the cosign tree output, the failed verification with the wrong identity, and a paragraph on what the signature does and does not prove.
TipStretch goals: add SLSA build provenance, add a scheduled job that rescans stored SBOMs, and write the admission policy that rejects unsigned images in a kind cluster.