Objective
By the end of this lab you will have authored the artefacts that implement a container supply-chain pipeline: a CI workflow with five stages — build, SBOM, scan, sign, verify — that produces a signed, scanned, SBOM-attached OCI image ready for downstream consumption; the policy that downstream consumers (admission controllers, deploy jobs) apply; and a documented catalogue of failure modes per stage.
The point of this lab is not any single tool — Lab 12 covered Cosign signing, the labs in Lesson LIII-02 covered BuildKit, and the labs in Lesson LXVIII covered SBOM generation. The point is the integration: five stages as a single pipeline, with each stage’s output the next stage’s input, and the verify stage as the gate before publishing.
Architecture
A pipeline with five sequential stages: build produces the
image digest; sbom produces the CycloneDX SBOM as an OCI
referrer; scan runs Grype against the SBOM and fails on
CRITICAL and HIGH; sign runs cosign sign --keyless; verify
runs cosign verify against Fulcio and Rekor before publishing.
flowchart LR
A["build\nbuildkit"] --> B["sbom\nsyft"]
B --> C["scan\ngrype"]
C --> D["sign\ncosign keyless"]
D --> E["verify\ncosign verify"]
E -- pass --> F["publish"]
E -- fail --> Z["publish blocked"]
The five stages run in order; each needs: the previous stage’s
success. A failure at any stage short-circuits the pipeline —
the image is not signed, not verified, and not published.
Requirements
- Git 2.55.x on Linux or macOS.
- A GitHub repository with OIDC enabled (default for public repos; org setting for private repos).
- A container registry. The lab uses
ghcr.iobecause every GitHub repo has one and it implements OCI referrers. - No long-lived signing keys. Keyless signing uses an OIDC token per build.
Scenario
A platform team publishes container images to ghcr.io. They
want every published image to be (1) signed with Cosign keyless,
so consumers can verify provenance; (2) scanned with Grype, so
consumers can verify safety; (3) attached to a CycloneDX SBOM,
so consumers can audit the dependency list. The pipeline must
fail the build on CRITICAL or HIGH vulnerabilities; the
signature must be present and verifiable before the image is
tagged as :latest.
The lab builds the workflow, the policy, and the failure-mode catalogue.
Tasks
Task 1 — Build the sample application and Dockerfile
LAB="$HOME/supply-chain-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
git init -b main
git config user.email 'ops@example.com'
git config user.name 'Ops'
mkdir -p src
# A minimal Node.js application. The application is incidental;
# the supply chain is the subject.
cat > src/index.js <<'EOF'
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('ok');
});
server.listen(8080, () => console.log('listening on 8080'));
EOF
cat > package.json <<'EOF'
{
"name": "runbook-supply-chain",
"version": "1.0.0",
"main": "src/index.js",
"engines": { "node": "20.x" }
}
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm ci --omit=dev || npm install --omit=dev
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["node", "src/index.js"]
EOF
git add Dockerfile package.json src/
git commit -m 'initial: node app with distroless multi-stage build'
The repository has a Node.js application and a multi-stage
Dockerfile that ends in gcr.io/distroless/nodejs20-debian12:nonroot.
The distroless base has no shell and runs as nonroot; the
attack surface is small.
Task 2 — Author the workflow: build and SBOM
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
mkdir -p .github/workflows
cat > .github/workflows/supply-chain.yml <<'EOF'
name: container supply chain
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write # required to push to ghcr.io
id-token: write # required for keyless signing (Fulcio OIDC)
attestations: write # required for SLSA-style provenance
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${ github.repository }
jobs:
# ─────────────────────────────────────────────────────────────────
# Stage 1: build
# ─────────────────────────────────────────────────────────────────
build:
name: build
runs-on: ubuntu-24.04
outputs:
digest: ${ steps.build.outputs.digest }
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: login to ghcr.io
uses: docker/login-action@e92390c5a0f122e209c4d2338af9d7b96d527d7c # v3.0.0
with:
registry: ${ env.REGISTRY }
username: ${ github.actor }
password: ${ secrets.GITHUB_TOKEN }
- name: build and push
id: build
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca9547ad9c1c # v6.0.0
with:
context: .
push: true
tags: |
${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha }
provenance: true # SLSA provenance
sbom: true # SBOM attestation in provenance
cache-from: type=gha
cache-to: type=gha,mode=max
# ─────────────────────────────────────────────────────────────────
# Stage 2: SBOM as a CycloneDX OCI referrer
# ─────────────────────────────────────────────────────────────────
sbom:
name: sbom
runs-on: ubuntu-24.04
needs: [build]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: generate CycloneDX SBOM
uses: anchore/sbom-action@861e5acddb6eeb6f74f9b9e6e84d9a5b9b5b3e6c # v0.17.7
with:
image: ${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }
format: cyclonedx-json
artifact-name: image-sbom.cdx.json
output-file: image-sbom.cdx.json
EOF
git add .github/workflows/supply-chain.yml
git commit -m 'ci: build and SBOM stages'
The first two stages produce the image digest and the
CycloneDX SBOM. The SBOM is attached to the image as an OCI
referrer; downstream consumers can pull the referrer by its
artifactType (application/vnd.cyclonedx+json).
Task 3 — Add the scan stage
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat >> .github/workflows/supply-chain.yml <<'EOF'
# ─────────────────────────────────────────────────────────────────
# Stage 3: vulnerability scan with grype
# ─────────────────────────────────────────────────────────────────
scan:
name: scan
runs-on: ubuntu-24.04
needs: [build, sbom]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: install grype
run: |
curl -fsSLo grype.tar.gz \
https://github.com/anchore/grype/releases/latest/download/grype_0.70.0_linux_amd64.tar.gz
tar -xzf grype.tar.gz
sudo mv grype /usr/local/bin/
- name: download SBOM artefact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.0.0
with:
name: image-sbom.cdx.json
path: sbom/
- name: scan SBOM for vulnerabilities
run: |
grype \
--file sbom/image-sbom.cdx.json \
--fail-on critical,high \
--only-fixed \
--output json \
--file /dev/null \
> grype-report.json || GRYPE_EXIT=$?
# grype returns non-zero if findings exist; capture
# the report regardless.
if [ "${GRYPE_EXIT:-0}" -ne 0 ]; then
echo "::error::grype found vulnerabilities above threshold"
cat grype-report.json
exit 1
fi
EOF
git add .github/workflows/supply-chain.yml
git commit -m 'ci: grype vulnerability scan stage'
The scan stage downloads the SBOM artefact, runs grype --fail-on critical,high, and fails the build if any finding is
above the threshold. The --only-fixed flag ignores
vulnerabilities without a fixed version; the team does not
block on unfixable issues.
Task 4 — Add the Grype ignore configuration
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat > grype-config.yaml <<'EOF'
# grype-config.yaml
#
# Grype configuration: ignore list for vulnerabilities the team
# has triaged as acceptable. Each entry has a reason; the team
# reviews ignore-list changes in code review.
ignore:
# ─────────────────────────────────────────────────────────────────
# Acceptable vulnerabilities (with justification)
# ─────────────────────────────────────────────────────────────────
- vulnerability: CVE-2023-45853
reason: "zlib minizip vulnerability; team does not use minizip"
expire: "2026-12-31"
- vulnerability: CVE-2024-0001-fake
reason: "test fixture; never deployed"
# ─────────────────────────────────────────────────────────────────
# Severity threshold. CRITICAL and HIGH fail; MEDIUM and LOW are
# reported but do not fail.
# ─────────────────────────────────────────────────────────────────
fail-on-severity: critical,high
# ─────────────────────────────────────────────────────────────────
# Output format. JSON for downstream consumption; the SARIF format
# is also supported for code-scanning integration.
# ─────────────────────────────────────────────────────────────────
output: json
EOF
git add grype-config.yaml
git commit -m 'grype: ignore list and severity policy'
The grype-config.yaml is the team’s ignore list for known
false positives or accepted vulnerabilities. Each entry has a
reason (documentation) and an expire date (the team
re-evaluates the entry at expiry). The lab’s entries are
illustrative; production ignore lists have dozens of entries,
each tied to a specific CVE.
Task 5 — Add the sign and verify stages
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat >> .github/workflows/supply-chain.yml <<'EOF'
# ─────────────────────────────────────────────────────────────────
# Stage 4: sign with cosign keyless (Fulcio + Rekor)
# ─────────────────────────────────────────────────────────────────
sign:
name: sign (keyless)
runs-on: ubuntu-24.04
needs: [scan]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: login to ghcr.io
uses: docker/login-action@e92390c5a0f122e209c4d2338af9d7b96d527d7c # v3.0.0
with:
registry: ${ env.REGISTRY }
username: ${ github.actor }
password: ${ secrets.GITHUB_TOKEN }
- name: install cosign
uses: sigstore/cosign-installer@5953e6dcfe5e0e0a48a8b8e8b8e8e8e8e8e8e8e8 # v3.5.0
with:
cosign-release: 'v2.2.0'
- name: sign image
env:
COSIGN_EXPERIMENTAL: '1'
run: |
cosign sign --yes \
${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }
# ─────────────────────────────────────────────────────────────────
# Stage 5: verify the signature before publishing
# ─────────────────────────────────────────────────────────────────
verify:
name: verify
runs-on: ubuntu-24.04
needs: [sign]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: install cosign
uses: sigstore/cosign-installer@5953e6dcfe5e0e0a48a8b8e8b8e8e8e8e8e8e8e8 # v3.5.0
with:
cosign-release: 'v2.2.0'
- name: verify signature
env:
COSIGN_EXPERIMENTAL: '1'
run: |
cosign verify \
--certificate-identity-regexp 'https://github.com/'"$GITHUB_REPOSITORY"'/.+/.+' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--rekor-url 'https://rekor.sigstore.dev' \
${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }
# ─────────────────────────────────────────────────────────────────
# Stage 6: publish the :latest tag (only after verify passes)
# ─────────────────────────────────────────────────────────────────
publish:
name: publish :latest
runs-on: ubuntu-24.04
needs: [verify]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: re-tag with :latest
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.0.0
with:
name: image-sbom.cdx.json
path: sbom/
- name: re-tag image
run: |
# Pull the digest-tagged image, re-tag as :latest, push.
docker pull ${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha }
docker tag ${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha } \
${ env.REGISTRY }/${ env.IMAGE_NAME }:latest
docker push ${ env.REGISTRY }/${ env.IMAGE_NAME }:latest
EOF
git add .github/workflows/supply-chain.yml
git commit -m 'ci: sign, verify, and publish stages'
The pipeline now has six stages: build, sbom, scan,
sign, verify, publish. The publish stage runs only on
push to main and only after the signature is verified. The
:latest tag is applied only to a verified, signed, scanned
image; an unsigned image is never tagged as :latest.
Task 6 — Author the supply-chain policy
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat > supply-chain-policy.yaml <<'EOF'
# supply-chain-policy.yaml
#
# Policy applied by downstream consumers: admission controllers
# (Kyverno, sigstore-policy-controller), deploy scripts, and
# registry mirrors. The policy says: every image must (1) be
# signed by Fulcio with a certificate whose OIDC issuer is
# GitHub's OIDC provider; (2) have a CycloneDX SBOM attached
# as an OCI referrer; (3) have no CRITICAL or HIGH
# vulnerabilities per Grype.
apiVersion: v1
kind: SupplyChainPolicy
metadata:
name: runbook-image-policy
spec:
images:
- glob: "ghcr.io/runbook-academy/*"
authorities:
- keyless:
identities:
- issuer: https://token.actions.githubusercontent.com
identityRegexp: "https://github.com/runbook-academy/.+/.+"
ctlog:
url: https://rekor.sigstore.dev
validateTimestamp: "2026-01-01T00:00:00Z"
attestations:
- kind: cyclonedx
required: true
predicate:
# The SBOM must be a CycloneDX JSON document.
format: cyclonedx-json
# The SBOM must include at least one component.
minComponents: 1
vulnerabilities:
- maxSeverity: high
# CRITICAL and HIGH fail; MEDIUM and LOW are reported but
# do not fail.
maxUnfixable: 0
# Unfixable vulnerabilities (no patched version) always
# fail; they cannot be ignored.
EOF
The supply-chain policy is what an admission controller applies at deploy time. The policy pins the Fulcio issuer, the Rekor URL, the OIDC identity regex, the SBOM attestation requirement, and the vulnerability threshold. A consumer that fails any check is rejected at admission.
Task 7 — Document the supply-chain stages
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat > supply-chain-stages.md <<'EOF'
# Supply-chain stages
This document is the canonical record of the five stages of
the container supply chain. The workflow is the implementation;
this document is the rationale. Engineers should be able to
answer "what does each stage catch?" by reading this file.
## Stage 1: Build
The `build` stage produces the OCI image digest. The digest is
a SHA-256 of the image's manifest; the manifest is the
content-addressed identifier. The digest is the input to every
subsequent stage.
digest = sha256(manifest)
The build uses BuildKit, which supports `--cache-from` and
`--cache-to` for layer reuse. The cache is scoped per branch by
default; PR builds use the cache from the base branch.
## Stage 2: SBOM
The `sbom` stage produces a CycloneDX SBOM and attaches it to
the image as an OCI referrer. The referrer is identified by its
digest and its `artifactType` (`application/vnd.cyclonedx+json`).
The SBOM is what Grype scans in stage 3 and what downstream
auditors consume to verify the dependency list.
## Stage 3: Scan
The `scan` stage runs Grype against the SBOM and fails the
build on CRITICAL or HIGH findings. Grype uses the GitHub
Advisory Database as its vulnerability source; `--only-fixed`
ignores unfixable vulnerabilities.
The scan is the **safety** check. The signature (stage 4) is
the **provenance** check. The two together are the supply
chain.
## Stage 4: Sign
The `sign` stage calls `cosign sign --yes` with no `--key`
flag. The OIDC token issued by GitHub Actions is the runner's
identity; Fulcio binds the signing certificate to that
identity; Rekor records the signature in the transparency log.
## Stage 5: Verify
The `verify` stage calls `cosign verify` with three pinning
flags: `--certificate-oidc-issuer`,
`--certificate-identity-regexp`, and `--rekor-url`. If any of
the three checks fails, the verify stage fails and the publish
stage does not run.
## Stage 6: Publish
The `publish` stage applies the `:latest` tag to the image.
The stage runs only on push to main and only after `verify`
passes. A signed-but-unverified image is never tagged
`:latest`.
## Stage interaction
build → sbom → scan → sign → verify → publish ↓ ↓ ↓ ↓ digest SBOM vuln cert+rekor referrer report entry
Each stage's output is the next stage's input. The `digest`
output of `build` is consumed by `sbom`, `sign`, and `verify`.
The `image-sbom.cdx.json` artefact is consumed by `scan`. The
cosign signature is consumed by `verify`.
A failure at any stage short-circuits the pipeline. An image
that fails the scan is not signed. An image that fails the
verify is not published.
EOF
git add supply-chain-stages.md
git commit -m 'docs: supply-chain stages description'
The stages document is what the team reads when they ask “what does the scan stage catch?” or “why is the verify stage before publish?”. It is the bridge between the workflow and the team’s understanding of the supply chain.
Task 8 — Document the failure modes
# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"
cat > stage-failure-modes.md <<'EOF'
# Failure modes: container supply chain
This document is the canonical record of the common failures at
each of the five stages. Each section includes the symptom,
the cause, and the fix.
## Build stage
**Symptom:** `build` fails with `failed to solve: process
"/bin/sh -c ..." did not complete successfully`.
**Cause:** A `RUN` step in the Dockerfile failed. The error is
in the build log.
**Fix:** Read the build log; the failing step is the most
recent `RUN` in the output. Common causes: a package that no
longer exists in the apt/yum repository, a network timeout
during `npm install`, a missing source file.
**Symptom:** BuildKit cache misses despite `cache-from: type=gha`.
**Cause:** The cache is scoped per branch; a PR build cannot
read the cache from the base branch unless the cache key
matches.
**Fix:** Use
`cache-from: type=gha,scope=pr-${ github.event.pull_request.number }`
or accept the cache miss on first build.
## SBOM stage
**Symptom:** `sbom` fails with
`unable to pull image: image not found`.
**Cause:** The `build` stage did not produce the digest-tagged
image, or the registry login failed.
**Fix:** Verify the `build` stage succeeded and the image was
pushed. The `sbom` stage pulls by digest; if the digest-tagged
image is not in the registry, the pull fails.
**Symptom:** The SBOM referrer does not appear in the
registry.
**Cause:** The registry does not implement OCI referrers.
`docker.io` does not as of 2026-08.
**Fix:** Push to `ghcr.io`, `gcr.io`, or a self-hosted
registry that supports OCI 1.1 referrers.
## Scan stage
**Symptom:** `scan` fails with `1 CRITICAL vulnerability
found`.
**Cause:** A package in the image has a known CRITICAL CVE.
**Fix:** Update the package (rebuild the image with the
updated base image), or add the CVE to `grype-config.yaml`'s
ignore list with a justification and an expiry date.
**Symptom:** `scan` reports `0 vulnerabilities` for a clearly
vulnerable image.
**Cause:** Grype's vulnerability database is out of date.
Grype updates its database on each release; the lab pins
`grype_0.70.0`.
**Fix:** Update to the latest Grype release; the database is
bundled with the binary.
## Sign stage
**Symptom:** `sign` fails with `no identity token`.
**Cause:** The workflow's `permissions:` block is missing
`id-token: write`.
**Fix:** Add `id-token: write` to the workflow's permissions.
The permission is required for keyless signing.
**Symptom:** `sign` fails with
`failed to sign: PUT https://ghcr.io/v2/...: 403 Forbidden`.
**Cause:** The runner's `GITHUB_TOKEN` does not have
`packages: write` permission, or the registry login failed.
**Fix:** Verify the workflow's `permissions:` block includes
`packages: write` and the `docker/login-action` step uses
`${ secrets.GITHUB_TOKEN }`.
## Verify stage
**Symptom:** `verify` fails with `no matching signatures`.
**Cause:** The `--certificate-identity-regexp` does not match
the OIDC subject. The OIDC subject for a GitHub Actions run is
`https://github.com/$OWNER/$REPO/.github/workflows/<file>@<ref>`.
**Fix:** Adjust the regex to match the repository path. A
common bug is using the repository's display name instead of
the URL-encoded owner.
## Publish stage
**Symptom:** `publish` does not run.
**Cause:** The `if:` clause
(`github.event_name == 'push' && github.ref == 'refs/heads/main'`)
did not match. The workflow was triggered by a pull_request
or a tag push.
**Fix:** Verify the trigger. The `:latest` tag is updated only
on push to main; PR builds and tag builds are tagged by SHA
only.
EOF
git add stage-failure-modes.md
git commit -m 'docs: failure modes per supply-chain stage'
The failure-mode catalogue is what the on-call engineer reads when the pipeline reddens at any stage. The document is organised by stage; the table at the top of each section is the symptom-to-cause-to-fix pattern.
Task 9 — Validate the YAML structure
cd "$HOME/supply-chain-lab"
python3 -c "
import yaml
with open('.github/workflows/supply-chain.yml') as f:
doc = yaml.safe_load(f)
jobs = doc['jobs']
print('jobs:', list(jobs.keys()))
print('build outputs:', list(jobs['build']['outputs'].keys()))
print('scan needs:', jobs['scan']['needs'])
print('verify needs:', jobs['verify']['needs'])
print('publish if:', jobs['publish']['if'])
"
python3 -c "
import yaml
with open('grype-config.yaml') as f:
doc = yaml.safe_load(f)
print('ignore count:', len(doc['ignore']))
print('fail-on-severity:', doc['fail-on-severity'])
"
python3 -c "
import yaml
with open('supply-chain-policy.yaml') as f:
doc = yaml.safe_load(f)
print('images:', doc['spec']['images'])
print('attestations:', [a['kind'] for a in doc['spec']['attestations']])
"
Expected output (excerpt):
jobs: ['build', 'sbom', 'scan', 'sign', 'verify', 'publish']
build outputs: ['digest']
scan needs: ['build', 'sbom']
verify needs: ['sign']
publish if: github.event_name == 'push' && github.ref == 'refs/heads/main'
ignore count: 2
fail-on-severity: critical,high
images: [{'glob': 'ghcr.io/runbook-academy/*'}]
attestations: ['cyclonedx']
The workflow has six jobs in correct dependency order; the
Grype config has two ignore entries and the
critical,high threshold; the supply-chain policy requires a
CycloneDX SBOM attestation.
Task 10 — Capture the deliverables
cd "$HOME/supply-chain-lab"
cp .github/workflows/supply-chain.yml "$HOME/supply-chain.yml"
cp Dockerfile "$HOME/supply-chain-Dockerfile"
cp supply-chain-policy.yaml "$HOME/supply-chain-policy.yaml"
cp grype-config.yaml "$HOME/grype-config.yaml"
cp supply-chain-stages.md "$HOME/supply-chain-stages.md"
cp stage-failure-modes.md "$HOME/stage-failure-modes.md"
ls -l "$HOME"/supply-chain.yml \
"$HOME"/supply-chain-Dockerfile \
"$HOME"/supply-chain-policy.yaml \
"$HOME"/grype-config.yaml \
"$HOME"/supply-chain-stages.md \
"$HOME"/stage-failure-modes.md
The deliverables are the six files in $HOME, plus the
repository at $HOME/supply-chain-lab.
Validation
.github/workflows/supply-chain.ymlparses as valid YAML and has six jobs:build,sbom,scan,sign,verify,publish.- The
permissions:block includespackages: write,id-token: write, andattestations: write. - The
signjob usescosign sign --yeswith no--keyflag. - The
verifyjob usescosign verifywith three pinning flags. grype-config.yamlparses as valid YAML and includesfail-on-severity: critical,high.supply-chain-policy.yamlparses as valid YAML and pins the Fulcio issuer, the OIDC identity, and the Rekor URL.- Every
uses:reference in the workflow is a pinned commit SHA.
Expected Outcome
A container supply-chain pipeline that produces a signed, scanned, SBOM-attached OCI image ready for downstream consumption, plus the policy and documentation that make the supply chain reviewable.
$HOME/supply-chain-lab/
├── .github/workflows/supply-chain.yml # the workflow
├── Dockerfile # the build
├── supply-chain-policy.yaml # downstream policy
├── grype-config.yaml # scan configuration
├── supply-chain-stages.md # stage descriptions
├── stage-failure-modes.md # failure catalogue
├── src/index.js # the application
└── package.json
The workflow is the implementation; the policy is the consumer side; the documents are the rationale.
Troubleshooting
The sbom stage fails to pull the image. The build stage
did not push the digest-tagged image, or the registry login
failed. Verify the build stage’s logs and the
docker/login-action step.
The scan fails with a CVE that is later retracted. Add the
CVE to grype-config.yaml’s ignore list with a justification
and an expiry date. The expiry forces a re-evaluation at a
known date.
The sign stage fails with “no identity token”. The
workflow’s permissions: block is missing id-token: write.
Add it; without it, keyless signing does not work.
The verify stage finds no signatures. The
--certificate-identity-regexp does not match the OIDC
subject. Adjust the regex to match the repository path.
The publish stage does not run. The if: clause did not
match. Verify the workflow was triggered by push to main
and not by a pull request or a tag.
The :latest tag points to an old image. The publish job
was skipped because verify failed or because the trigger was
not a push to main. The current :latest is the last
verified image.
Cleanup
LAB="$HOME/supply-chain-lab"
mv "$LAB"/supply-chain-stages.md "$LAB"/stage-failure-modes.md \
"$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/supply-chain.yml" \
"$HOME/supply-chain.yml" 2>/dev/null
mv "$LAB/Dockerfile" "$HOME/supply-chain-Dockerfile" 2>/dev/null
mv "$LAB/supply-chain-policy.yaml" \
"$HOME/supply-chain-policy.yaml" 2>/dev/null
mv "$LAB/grype-config.yaml" "$HOME/grype-config.yaml" 2>/dev/null
rm -rf "$LAB"
find "$HOME" -maxdepth 1 -name 'supply-chain-lab' -print
# expected: (no output)
If you pushed images to ghcr.io during the lab, delete them:
gh api --method DELETE \
/repos/$OWNER/$REPO/packages/container/$IMAGE_NAME/versions/$VERSION_ID \
-H 'Accept: application/vnd.github+json'
What You Learned
- The supply chain has five stages, not two. Build + sign is provenance only; the safety check is the vulnerability scan. The two are paired; neither alone is sufficient.
:latestis the highest-blast-radius tag. The lab applies:latestonly afterverifypasses; an unsigned image is never tagged:latest. This is the defense against a supply-chain compromise.- The SBOM is an OCI referrer, not a side artefact. The CycloneDX SBOM is attached to the image as an OCI referrer and is what Grype scans. A separate SBOM file in the artefact store is not enough; the referrer is the operational artefact.
- Grype’s threshold is a policy decision.
critical,highis the most common team policy; some teams setcriticalonly, othersmediumfor production images. The decision is documented ingrype-config.yamland reviewed in code review. grype-config.yaml’s ignore list has an expiry. Each entry has anexpiredate that forces a re-evaluation. The team’s discipline: ignore entries are temporary, not permanent.- The supply-chain policy lives at the consumer. The CI workflow is the producer; the policy is what the admission controller applies. The two are paired but distinct.
- Each stage has its own failure modes. The stage-failure catalogue is organised by stage; the on-call engineer reads the relevant section when the pipeline reddens at that stage.