Skip to main content

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.

flowchart TD A[Push to main] --> B[Build image once] B --> C[Trivy image scan] C --> D[Push and get digest] D --> E[Syft SBOM] E --> F[Cosign sign by digest] F --> G[Cosign attest SBOM] G --> H[Verify job] H --> I{Signature and SBOM valid?} I -->|Yes| J[Safe to deploy] I -->|No| K[Fail]

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 as latest can 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
Tip

Pinned 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.

Bash
mkdir supply-chain-demo && cd supply-chain-demo
git init -b main
mkdir -p app scripts .github/workflows
touch app/__init__.py
printf '.venv/\n__pycache__/\n*.pyc\n' > .gitignore
Python
# app/main.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/health")
def health():
return jsonify(status="ok")
Bash
# requirements.txt
Flask==3.1.3
Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --no-create-home appuser
COPY app ./app
USER appuser
EXPOSE 8080
CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]
Bash
docker build -t supply-chain-demo:local .
docker run --rm -d -p 8080:8080 --name sc supply-chain-demo:local
curl -s http://127.0.0.1:8080/health
docker stop sc
git add . && git commit -m "Add demo service"

Expected result: the curl prints {"status":"ok"}.

Common Mistake

Using 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.

Bash
#!/usr/bin/env bash
# scripts/install-tools.sh
# Usage: scripts/install-tools.sh trivy syft cosign
set -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 ;;
esac
done
if [ -n "${GITHUB_PATH:-}" ]; then
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
fi
echo "installed: $*"
Bash
chmod +x scripts/install-tools.sh
scripts/install-tools.sh trivy syft cosign
export PATH="$HOME/.local/bin:$PATH"
trivy --version && syft version && cosign version
Security

The script targets Linux on x86-64, matching GitHub's ubuntu-24.04 runners. 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

Bash
docker build -t supply-chain-demo:local .
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 supply-chain-demo:local
echo "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

YAML
# .github/workflows/supply-chain.yml
name: 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 Mistake

Granting packages: write and id-token: write at 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.

YAML
- 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: 90

Why 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
Tip

The 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 Mistake

Generating 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.

YAML
- 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.

Security

The 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 Mistake

Signing a tag. If you run cosign sign repo:tag the 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

YAML
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.

Bash
git add .
git commit -m "Add supply chain pipeline"
git remote add origin git@github.com:YOUR-USER/supply-chain-demo.git
git push -u origin main
Common Mistake

Verifying 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

Bash
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.

Bash
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-sign job 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 verify and cosign verify-attestation both 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.

Tip

Stretch 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.

Found a mistake, an outdated step or a broken link?