Overview
Most homelabs (and plenty of production shops) stop their container security at "the base image scanner in CI found nothing critical." That leaves two gaps: you can't prove what's actually inside the image you shipped without re-scanning it later against a moving vulnerability feed, and you can't prove the image running in production is the exact artifact your pipeline built — nothing stops a compromised registry credential or a MITM'd pull from swapping it out.
This project closes both gaps using three tools from the Anchore/Sigstore ecosystem that have become the de facto open-source standard for container supply chain security:
- Syft generates a Software Bill of Materials (SBOM) — a machine-readable inventory of every package, library, and OS component inside your image.
- Grype scans that SBOM against vulnerability databases, catching known CVEs without re-analyzing the whole image from scratch.
- Cosign signs the image and attaches the SBOM as a cryptographically bound attestation, using short-lived certificates from Sigstore instead of a long-lived private key you'd otherwise have to store and rotate.
By the end, every image your pipeline pushes to a registry carries a verifiable signature and an attached SBOM — and you'll have a cosign verify command that tells you definitively whether an image is the one your CI built, signed by your GitHub Actions workflow specifically, with no keys to leak.
What you'll build:
- A GitHub Actions workflow that builds a container image, generates dual-format SBOMs, fails the build on high/critical CVEs, and signs + attests everything keylessly
- Local Syft/Grype tooling for ad-hoc scans of existing images
- A verification step (and Kyverno policy sketch) that rejects unsigned images at deploy time
This pairs well with an existing Traefik/Docker Compose homelab — the target registry here is GitHub Container Registry (GHCR), but the same flow works against any OCI-compliant registry.
Architecture
┌────────────────────────────────────────────────────────────────┐
│ GitHub Actions Runner │
│ │
│ 1. docker build ──────────► image (local, untagged digest) │
│ │
│ 2. syft <image> ──────────► sbom.spdx.json + sbom.cdx.json │
│ │
│ 3. grype sbom:sbom.cdx.json ──► fail build on High/Critical CVE │
│ │
│ 4. docker push ───────────► ghcr.io/org/app@sha256:... │
│ │
│ 5. cosign sign (keyless) ─┐ │
│ OIDC token ────────────┼──► Fulcio (short-lived cert) │
│ └──► Rekor (transparency log entry) │
│ │
│ 6. cosign attest --type spdxjson ──► SBOM bound to image digest │
└────────────────────────────────────────────────────────────────┘
│
▼
ghcr.io/org/app@sha256:...
├── signature (Rekor-logged)
└── SBOM attestation (Rekor-logged)
│
▼
┌────────────────────────┐
│ Deploy-time verify │
│ cosign verify + policy │
│ (Kyverno / manual) │
└────────────────────────┘
The key design point: nothing here needs a private key checked into a secrets manager. GitHub Actions' OIDC token proves which repo, which workflow, which ref built the image; Sigstore's Fulcio CA issues a certificate valid for about 10 minutes based on that identity; Cosign uses it to sign; and Rekor timestamps the signature in a public, tamper-evident transparency log so the signing event can be independently audited later even after the certificate expires.
Prerequisites
- A GitHub repository with a Dockerfile and Actions enabled
- GitHub Container Registry access (default
GITHUB_TOKENis sufficient — no extra PAT needed) - Docker Engine locally for ad-hoc testing
cosign,syft, andgrypeCLIs for local verification (installed below)
Step 1 — Install the CLIs Locally
# Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin
# Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
| sh -s -- -b /usr/local/bin
# Cosign
curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64"
sudo mv cosign-linux-amd64 /usr/local/bin/cosign
sudo chmod +x /usr/local/bin/cosign
# Verify
syft version && grype version && cosign versionStep 2 — Generate and Inspect an SBOM Locally
Build or pull any image and point Syft at it directly — no need to push first:
docker build -t local/app:test .
syft local/app:test -o spdx-json=sbom.spdx.json
syft local/app:test -o cyclonedx-json=sbom.cdx.json
syft local/app:test -o tableThe table output gives you a quick human-readable inventory:
NAME VERSION TYPE
alpine-baselayout 3.4.3-r2 apk
busybox 1.36.1-r29 apk
openssl 3.1.4-r5 apk
node 20.11.0 binary
express 4.19.2 npm
That last column matters — Syft catalogs OS packages and language-level dependencies (npm, pip, Go modules, Java jars) in the same pass, which is what makes the SBOM useful for both container base-image drift and application dependency audits.
Step 3 — Scan the SBOM with Grype
Scan the SBOM file rather than the image — it's faster because Grype skips re-cataloging and just checks the existing inventory against its vulnerability DB:
grype sbom:sbom.cdx.json -o tableNAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
openssl 3.1.4-r5 3.1.4-r6 apk CVE-2026-XXXXX High
express 4.19.2 4.19.3 npm CVE-2026-YYYYY Medium
Set a severity gate for CI — this exits non-zero (failing the pipeline) if anything High or above is found:
grype sbom:sbom.cdx.json --fail-on highStep 4 — The GitHub Actions Pipeline
Create .github/workflows/supply-chain.yml:
name: Build, Scan, Sign
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # required for Sigstore keyless signing
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-scan-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Install Syft & Grype
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Generate SBOM
run: |
syft ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
-o spdx-json=sbom.spdx.json \
-o cyclonedx-json=sbom.cdx.json
- name: Scan SBOM (fail on High/Critical)
run: grype sbom:sbom.cdx.json --fail-on high
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Sign image (keyless)
run: |
cosign sign --yes \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
- name: Attest SBOM to image
run: |
cosign attest --yes \
--type spdxjson \
--predicate sbom.spdx.json \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.*.jsonThe id-token: write permission is the load-bearing line here — without it, GitHub Actions won't mint the OIDC token that Cosign exchanges with Fulcio for a signing certificate, and the sign step fails outright.
Push a commit to main and watch the run. On success, you'll see Cosign print a Rekor transparency log entry index — that's your public, timestamped proof the signature exists.
Step 5 — Verify the Signature and Attestation
Anyone can verify without your CI, your repo secrets, or a shared key — just the published identity constraints:
cosign verify \
--certificate-identity-regexp "https://github.com/YOUR_ORG/YOUR_REPO/.github/workflows/supply-chain.yml@refs/heads/main" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/YOUR_ORG/YOUR_REPO@sha256:DIGESTA successful verification prints the signing certificate's subject and issuer plus the Rekor log entry. Verify the attached SBOM attestation the same way:
cosign verify-attestation \
--type spdxjson \
--certificate-identity-regexp "https://github.com/YOUR_ORG/YOUR_REPO/.github/workflows/supply-chain.yml@refs/heads/main" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/YOUR_ORG/YOUR_REPO@sha256:DIGEST | jq -r '.payload' | base64 -d | jq .That last command decodes the attestation payload back into the original SPDX JSON — you're reading the exact SBOM Syft generated, cryptographically bound to that specific image digest, not a file that could have been swapped after the fact.
If someone tries to verify against a different workflow file, ref, or repo, cosign verify fails closed — that's the whole point of identity-based verification over key-based: you're checking who signed, not just whether a signature exists.
Testing Your Setup
Confirm the full chain end-to-end:
# 1. Confirm the image exists and has a digest
docker buildx imagetools inspect ghcr.io/YOUR_ORG/YOUR_REPO:latest
# 2. Confirm a signature is attached
cosign tree ghcr.io/YOUR_ORG/YOUR_REPO@sha256:DIGEST
# 3. Confirm verification fails against a bogus identity (negative test)
cosign verify \
--certificate-identity-regexp "https://github.com/some-other-org/other-repo/.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/YOUR_ORG/YOUR_REPO@sha256:DIGEST
# Expected: verification FAILS — this is correct behaviorcosign tree is worth running on any image you're curious about — it lists every signature and attestation attached to a digest without requiring you to already know what to verify against.
Deployment: Enforcing Signatures at Pull Time
Signing is only useful if something checks it before running the image. If you're running Kubernetes (or k3s from the k3s homelab writeup), Kyverno can enforce this as an admission policy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/YOUR_ORG/*"
attestors:
- entries:
- keyless:
subjectRegExp: "https://github.com/YOUR_ORG/YOUR_REPO/.*"
issuer: "https://token.actions.githubusercontent.com"For a plain Docker Compose homelab without Kubernetes, add a cosign verify step as a pre-deploy gate in whatever pulls and restarts the container (a deploy script, a Watchtower alternative, or a manual runbook step) — the verify command exits non-zero on failure, so it's a natural CI/CD gate even without an admission controller.
Extensions and Next Steps
Add provenance attestations (SLSA): Beyond the SBOM, attach build provenance — what source commit, what builder, what build parameters produced this image — using slsa-github-generator alongside Cosign. This is what the SLSA framework's higher levels require and is increasingly asked for by enterprise procurement.
Continuous re-scanning: CVE databases update daily; an image that passed Grype's gate on build day can have a new Critical show up a week later. Schedule a nightly job that pulls the stored SBOM (no need to re-pull the image) and re-runs grype sbom: against it, alerting if the severity picture has changed.
Private Sigstore instance: If you're air-gapped or don't want to depend on public Fulcio/Rekor, Cosign supports pointing at self-hosted instances via COSIGN_FULCIO_URL, COSIGN_REKOR_URL, and COSIGN_MIRROR — useful if this pipeline needs to run somewhere without outbound internet.
Feed DefectDojo: If you're already running DefectDojo for vulnerability management, import Grype's JSON output as a new finding source so container CVEs land in the same triage queue as your other scanners instead of living only in CI logs.
Multi-arch signing: If you build multi-architecture images (linux/amd64 + linux/arm64), sign the manifest list digest, not each per-arch image individually — cosign sign on the manifest list digest covers all platforms in one signature.