Part 4 — Harden & Sign the Container
Part 3 got Vulnerly's dependencies clean and SBOM'd. Everything up to this point has been about what's inside the image — the code, the packages. This part is about the box itself: Vulnerly still ships as node:18, running as root, unpinned, and — Part 1's threat model named this exactly — completely unsigned, so nothing distinguishes a real CI build from an image anyone with registry credentials happens to push. You'll rewrite the Dockerfile into a pinned, multi-stage, distroless, non-root build, scan it with Trivy until it's clean of criticals, generate an SBOM with Syft, sign the resulting image two different ways — keylessly, the way the pipeline signs it on every build, and against a published key pair, the way anyone outside the pipeline can verify it — and finish by making the cluster itself refuse to run anything that isn't signed.
Right now Vulnerly ships like a moving box nobody taped shut, packed by whoever happened to be in the warehouse that day, with no name on it and no way to tell your box from a stranger's box sitting on the same truck. Today you do three things: pack a much smaller box with only the one tool the app actually needs (nothing left over for a burglar to use), put a wax seal on it that only you can make, and post a copy of the seal's pattern somewhere public so anyone receiving the box can check it themselves without calling you. Then you tell the warehouse's front gate to stop accepting any box that arrives without that exact seal.
Arriving: a vulnerly repo whose SAST, secrets, and SCA gates (Part 2, Part 3) are all green on main, and a Dockerfile nobody has touched since Part 1's original seed: FROM node:18, root, unpinned, never scanned, never signed. No cluster exists yet — this is the first part that needs one. Leaving this page: a rewritten, pinned, distroless, non-root Dockerfile; a required Trivy gate blocking any image with a CRITICAL finding; a Syft SBOM attested to every build; a vulnerly-dev kind cluster with the vulnerly and platform namespaces; an image signed both keylessly (cosign + GitHub Actions' own OIDC identity) and against a published key pair (security/cosign.pub, committed to the repo); and a Kyverno policy that rejects any unsigned image at admission, proven against both a real signed deploy and a deliberately unsigned test pod. Part 5 picks up from here and turns its attention to infra/main.tf.
What this part assumes and what it produces
☺ Like you're 10: Everything Parts 1–3 built stays true — you're not rebuilding the app, just building the box it ships in and the room it runs in.
You need Docker, kind, kubectl, the cosign CLI, and Trivy and Syft installed locally (installers on each tool's own page — Trivy, Syft & Grype, Sigstore & cosign). This is the first capstone part that needs a running cluster at all — Parts 1 through 3 were pure source, pipeline, and dependency work — so if you haven't run kind create cluster yet, the next section is where that happens. Nothing from Parts 1–3 gets rebuilt: the app's routes, its now-parameterized query, its rotated Vault-backed secret, its bumped dependencies, and its Syft-SBOM'd package.json all carry forward untouched.
The world model this part adds
Building on what Parts 1–3 already named, here's what gets born on this page:
| Thing | Name | Introduced |
|---|---|---|
| Local cluster | vulnerly-dev (kind create cluster --name vulnerly-dev), namespaces vulnerly + platform | Part 4 — this page |
| Container registry | ghcr.io/YOU/vulnerly | Part 4 |
| Base images | node:22-bookworm-slim (builder) → gcr.io/distroless/nodejs22-debian12:nonroot (runtime), both pinned by digest | Part 4 |
| Image tag scheme | ghcr.io/YOU/vulnerly:sha-<short commit SHA>, cosign always signs the resolved @sha256:… digest, never the tag | Part 4 |
| Keyless signature | cosign + GitHub Actions' ambient OIDC identity, recorded in Rekor — what the pipeline signs with on every build | Part 4 |
| Published key pair | security/cosign.pub (committed) / security/cosign.key (never committed — lives only as a CI secret) — a second signature anyone outside the pipeline can verify offline | Part 4 |
| Admission policy | k8s/policy/require-signed-images.yaml — a namespaced Kyverno Policy rejecting anything unsigned | Part 4 |
| Base manifest | k8s/deploy.yaml — the Deployment + Service every remaining part reuses or overlays | Part 4 |
Keep this table in mind for the rest of the capstone: Part 6 copies k8s/policy/require-signed-images.yaml verbatim into a new staging namespace rather than writing a second policy, and re-signs the image the exact same two ways every time application code changes.
The baseline: what's actually wrong with today's Dockerfile
☺ Like you're 10: Build the bad box first and watch the scanner light up — you need to see the mess before you can prove you cleaned it.
Part 1 seeded this file and nobody has touched it since:
# Dockerfile — the Part 1 seed, unpinned base, root, no multi-stage
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app/src/index.js"]Read it the way Rocky would in a second threat-modeling pass, not just a Dockerfile-linter pass: FROM node:18 is a mutable tag on a Node line that reached end-of-life in April 2025 — no security patches are coming for it, ever again, no matter when you build. COPY . . copies the entire repository into the image, infra/, security/, and any stray local .env included, not just the application source. Nothing ever sets a USER, so the process runs as UID 0 by default — the Debian userland underneath a full node:18 image ships a shell, a package manager, and everything an attacker who lands inside would want. Build it and scan it once, so the rest of this page has a real before to compare against:
$ docker build -t vulnerly:baseline .
$ docker inspect --format='{{.Config.User}}' vulnerly:baseline
# (empty output — no USER was ever set; the default is root)
$ trivy image --severity CRITICAL,HIGH vulnerly:baselinevulnerly:baseline (debian 11.x)
Total: 214 (CRITICAL: 21, HIGH: 68, MEDIUM: 79, LOW: 46)
┌──────────┬────────────────┬──────────┬────────────────────┬────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├──────────┼────────────────┼──────────┼────────────────────┼────────────────┤
│ glibc │ CVE-2025-xxxxx │ CRITICAL │ 2.31-13+deb11u9 │ (none — EOL) │
│ openssl │ CVE-2024-xxxxx │ CRITICAL │ 1.1.1w-0+deb11u2 │ (none — EOL) │
│ ... │ ... │ ... │ ... │ ... │
└──────────┴────────────────┴──────────┴────────────────────┴────────────────┘(Illustrative — your own scan will show different, current numbers, because the CVE feed updates continuously and this course was written on a specific day. The shape is what matters: dozens of CRITICALs on a base image that stopped receiving security patches over a year before this page was written, with no fixed version available for most of them, because there will never be one.)
Standing up vulnerly-dev and its two namespaces
☺ Like you're 10: One tiny practice cluster, two labeled shelves — one for the app, one for the add-ons that watch it.
Nothing in Parts 1 through 3 needed a live cluster. This part does, because the last section of this page is an admission-control gate, and admission control only means something with something to admit into:
$ kind create cluster --name vulnerly-dev
$ kubectl cluster-info --context kind-vulnerly-dev
$ kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# vulnerly-dev-control-plane Ready control-plane 40s v1.31.x
$ kubectl create namespace vulnerly
$ kubectl create namespace platformThe split is the same one IaC security & policy as code and every other add-on-heavy capstone in this course's family uses: vulnerly holds the application itself; platform holds every piece of shared infrastructure a platform team would own — Kyverno today, later add-ons in Parts 5 and 7. When Part 6 stands up a third namespace, staging, for its DAST target, it's this exact rule that tells you it's an app namespace, not a platform one.
Rewriting the Dockerfile: pinned, multi-stage, distroless, non-root
☺ Like you're 10: Pack one sealed box with only the one tool your app actually needs, and staple shut who's allowed to open it.
Two stages: a builder that has npm and never ships, and a runtime that has neither a shell nor a package manager — the same distroless pattern container & supply-chain security already walked through for a Go binary, applied here to Node. Resolve real digests for both bases before you write them in — a tag can be repointed by its maintainer at any time, so pinning by digest is what guarantees the exact bytes you're about to scan are the exact bytes that ship:
$ docker pull node:22-bookworm-slim
$ docker inspect --format='{{index .RepoDigests 0}}' node:22-bookworm-slim
# node:22-bookworm-slim@sha256:<paste this into the Dockerfile below>
$ docker pull gcr.io/distroless/nodejs22-debian12:nonroot
$ docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/nodejs22-debian12:nonroot
# gcr.io/distroless/nodejs22-debian12:nonroot@sha256:<paste this in too>
# (check distroless's own repo for whichever Debian base is current when you build this —
# the nodejs22-debian12 tag pattern holds regardless of the exact suffix)# Dockerfile — this part's rewrite
# syntax=docker/dockerfile:1
# ---- builder: has npm, never ships ----
FROM node:22-bookworm-slim@sha256:<pin this> AS builder
WORKDIR /build
COPY app/package.json app/package-lock.json ./
RUN npm ci --omit=dev
COPY app/src ./src
# ---- runtime: distroless, nonroot, no shell, no package manager ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:<pin this>
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /build/src ./src
COPY --from=builder --chown=nonroot:nonroot /build/package.json ./package.json
USER nonroot:nonroot
EXPOSE 3000
CMD ["src/index.js"]Three things do the actual hardening work. The multi-stage split means npm, the full node_modules dev tree, and the build context never cross into the shipped image — only the pruned runtime dependencies do. The distroless nonroot base has no shell and no package manager at all, so an attacker who achieves code execution inside the container has nowhere to pivot from — there's no /bin/sh to reach for. And USER nonroot:nonroot is explicit even though the base variant already defaults to it, for the same reason a belt gets worn with suspenders: defense in depth doesn't get thinner just because one of the two layers is already doing its job. Notice, too, what's not here — no COPY . ., and no app/.env anywhere in the build context (the .dockerignore below makes sure of that). dotenv.config() finding no file at runtime isn't a bug; it's the point. In production, DATABASE_URL and the Vault-sourced processor key from Part 2 arrive as real environment variables injected by Kubernetes, never as a file baked into the image.
# .dockerignore — new file, this part's addition
.git
.github
.env
.env.*
node_modules
infra
k8s
security
*.md
DockerfileOne more small addition: npm ci refuses to run without a lockfile, and none has existed until now. Generate one once, commit it, and every future build becomes reproducible instead of merely repeatable:
$ cd app && npm install && cd ..
# app/package-lock.json now exists
$ git add app/package-lock.json .dockerignore Dockerfile
$ git commit -m "part4: pinned, multi-stage, distroless, non-root Dockerfile"Build it, and confirm the default user is what you just asked for — before Trivy, before signing, before anything else:
$ docker build -t vulnerly:hardened .
$ docker inspect --format='{{.Config.User}}' vulnerly:hardened
# nonrootdocker exec into a distroless container to check this by handThere's no shell, so docker run --rm -it vulnerly:hardened sh just fails outright — that's the feature working, not a debugging obstacle you've hit by accident. docker inspect's Config.User field reads the image's baked-in default straight from its metadata without ever needing to start the container or get a shell inside it, which is exactly why it's the right tool here instead of trying to exec in and run whoami.
Scanning until clean of criticals
☺ Like you're 10: Run the recall list against the new box until nothing on it is a five-alarm fire.
Scan the hardened image the same way you scanned the baseline, and compare:
$ trivy image --severity CRITICAL,HIGH --exit-code 1 vulnerly:hardenedvulnerly:hardened (distroless, debian 12)
Total: 2 (CRITICAL: 0, HIGH: 0, MEDIUM: 1, LOW: 1)
No CRITICAL or HIGH vulnerabilities found.
$ echo $?
0That drop isn't a coincidence of good luck — it's what removing the shell, the package manager, and every OS-level convenience tool structurally removes: there's almost nothing left in the image for a CVE to apply to. Whatever survives at MEDIUM or LOW here typically lives inside the Node runtime itself or an npm dependency, and Part 3's SCA gate is already watching that surface on every build; this Trivy pass is specifically an image-level check, catching what a source-level scanner never sees. Wire it into .github/workflows/ci.yml as a required, image-blocking check — the same shape of gate Part 2's secrets-scan and sast jobs already use, run as a raw Docker image rather than a marketplace action for the same reason Part 2 chose that for gitleaks:
# .github/workflows/ci.yml — appended job
# (secrets-scan and sast from Part 2, and Part 3's dependency-scan job, already sit above this one)
container-scan:
name: container-scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t vulnerly:${{ github.sha }} .
- run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity CRITICAL --exit-code 1 vulnerly:${{ github.sha }}Add container-scan to the same branch protection rule Part 2 already turned on for main. A branch that reverts the base image back to node:18 now fails this check automatically — you don't have to remember to notice.
Generating the SBOM with Syft
☺ Like you're 10: Tape a packing slip to the box listing exactly what's inside, so the next recall is a lookup, not a search.
Part 3 already SBOM'd Vulnerly's dependency manifest from source. This is a second, complementary SBOM — generated from the actual built image, which can differ from the source manifest in ways that matter (a base image's own OS packages never show up in a source-level SBOM at all):
$ syft vulnerly:hardened -o cyclonedx-json=sbom-image.cdx.json
$ syft vulnerly:hardened -o tableNAME VERSION TYPE
express 4.19.2 npm
pg 8.11.5 npm
dotenv 16.4.5 npm
lodash 4.17.21 npm <- bumped, Part 3
jsonwebtoken 9.0.2 npm <- bumped, Part 3
node 22.x binary(Illustrative — package counts and exact versions reflect your own package-lock.json, not a fixed list.) This SBOM is what the next section attests to the image directly, so anyone who later verifies the signature can pull the exact inventory alongside it — no separate download, no separate trust decision.
Signing the image with cosign — keylessly for the pipeline, and against a published key for everyone else
☺ Like you're 10: A wax seal only you can make, on a box anyone can check — and a second seal you can hand to someone who'll never have a login to check the first one with.
Sigstore & cosign already covers cosign's headline feature in depth: keyless signing, where an OIDC identity — a GitHub Actions workflow, in this pipeline's case — proves who's signing, Fulcio issues a certificate valid for about ten minutes, cosign signs and immediately discards the key, and a public, permanent transparency log called Rekor is what makes that signature still verifiable years later even after the certificate itself has long expired. That's what this pipeline signs with on every build, and it's what the Kyverno policy at the end of this page checks. But this part's own done-when is more literal than "an identity checks out": a signature that verifies against a published public key — something you can hand to a person who has no GitHub login and no network path to Rekor at all, and have them check it themselves. So the pipeline does both, for two different audiences: keyless for the cluster, which always has live access to verify an identity; a conventional key pair for everyone else.
Signing keylessly — the mechanism the cluster will trust
Build, push, and sign the digest, not the tag — signing a mutable tag only signs whatever it happens to resolve to at that instant:
$ docker build -t ghcr.io/YOU/vulnerly:hardened .
$ docker push ghcr.io/YOU/vulnerly:hardened
$ DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/YOU/vulnerly:hardened)
$ echo $DIGEST
# ghcr.io/YOU/vulnerly@sha256:...
$ cosign sign --yes "$DIGEST"
# Generating ephemeral keys...
# Retrieving signed certificate...
# Your browser will now be opened to: https://oauth2.sigstore.dev/auth/auth?...
# (in CI, this is an ambient token instead of a browser — see the workflow below)
# tlog entry created with index: 145829103
# Pushing signature to: ghcr.io/YOU/vulnerlyVerify it the same way the Kyverno policy will — with the identity flags, not without them. Current cosign refuses an unconstrained keyless verify precisely because it would otherwise accept a valid signature from any keyless signer on the planet, not specifically yours:
$ cosign verify \
--certificate-identity="https://github.com/YOU/vulnerly/.github/workflows/ci.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
"$DIGEST" | jq .Signing against a published key pair — offline, portable, no live dependency
Generate a real key pair once, and treat the private half exactly the way secrets management and Ellie insisted on back in Part 2: it exists in exactly one place, briefly, on its way into a CI secret, and nowhere else:
$ cosign generate-key-pair --output-key-prefix security/cosign
# Enter password for private key: ********
# security/cosign.key written (encrypted)
# security/cosign.pub written
$ cosign sign --yes --key security/cosign.key "$DIGEST"
# Enter password for private key: ******** (the last time you'll ever type this by hand)
$ cosign verify --key security/cosign.pub "$DIGEST" | jq .
# Verification for ghcr.io/YOU/vulnerly@sha256:... --
# The following checks were performed on each of these signatures:
# - The cosign claims were validated
# - The signature was verified using the specified public keyProve it discriminates, not just that it runs — verify the same image against a public key that never signed it:
$ cosign generate-key-pair --output-key-prefix rogue # unrelated, throwaway key pair
$ cosign verify --key rogue.pub "$DIGEST"
# Error: no matching signatures:
# error: no signatures found for the given public key
$ rm rogue.key rogue.pubPublish the public key, and get the private key off your machine immediately:
$ echo "security/cosign.key" >> .gitignore
$ git add security/cosign.pub .gitignore
$ git commit -m "part4: publish the cosign public key for offline image verification"
$ git push
$ gh secret set COSIGN_PRIVATE_KEY < security/cosign.key
$ gh secret set COSIGN_PASSWORD --body "<the same password you just typed>"
$ rm security/cosign.key # the only copy that ever left the CI secret store is gone nowWiring both signatures, plus the SBOM attestation, into CI
# .github/workflows/ci.yml — appended job
sign-and-push:
name: sign-and-push
needs: [container-scan]
runs-on: ubuntu-latest
permissions:
id-token: write # the whole trick — lets GitHub mint the OIDC token cosign uses keylessly
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- name: Build and push
id: build
run: |
docker build -t ghcr.io/YOU/vulnerly:sha-${{ github.sha }} .
docker push ghcr.io/YOU/vulnerly:sha-${{ github.sha }}
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/YOU/vulnerly:sha-${{ github.sha }})
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Sign keylessly (ambient OIDC — zero stored secrets)
run: cosign sign --yes "${{ steps.build.outputs.digest }}"
- name: Generate and attest the SBOM
run: |
syft "${{ steps.build.outputs.digest }}" -o cyclonedx-json=sbom-image.cdx.json
cosign attest --yes --predicate sbom-image.cdx.json --type cyclonedx "${{ steps.build.outputs.digest }}"
- name: Also sign with the published key pair (offline verification)
env:
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key
cosign sign --yes --key cosign.key "${{ steps.build.outputs.digest }}"
rm cosign.keyTwo signatures on the same digest aren't redundant — they answer different questions for different readers. Keyless answers "did exactly this GitHub Actions workflow, on this branch, build this," which is what a cluster with live network access can check for itself in real time. The key pair answers "does this match a specific, published public key," which is what an auditor, a downstream partner, or Part 7's evidence trail months later can check with nothing but a file, no login and no live service required.
Gating admission with Kyverno
☺ Like you're 10: Move the checkpoint from "a robot in CI hopes you signed it" to "the cluster's front door physically won't open without a seal."
Everything so far is a signature nobody's forced to check. That's a compliance artifact, not a control, until something actually refuses to run what isn't signed — the same distinction Sigstore & cosign makes about verification at an admission gate versus verification you merely could run by hand. Install Kyverno into platform:
$ helm repo add kyverno https://kyverno.github.io/kyverno/
$ helm repo update
$ helm install kyverno kyverno/kyverno -n platform --create-namespace
$ kubectl -n platform rollout status deploy/kyverno-admission-controllerWrite the policy as a namespaced Policy, not a cluster-wide ClusterPolicy — deliberately, because Part 6 reuses this exact same file against a second namespace later, and a namespaced object is what makes "copy the same manifest, apply it somewhere else" a clean operation instead of editing a shared cluster-wide rule. It verifies the keyless signature specifically — the one the cluster can check for itself, live, against Fulcio and Rekor, without needing a copy of any key file on the cluster at all:
# k8s/policy/require-signed-images.yaml
apiVersion: kyverno.io/v1
kind: Policy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
background: false
rules:
- name: verify-cosign-signature
match:
any:
- resources: { kinds: [Pod] }
verifyImages:
- imageReferences: ["ghcr.io/YOU/vulnerly*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/YOU/vulnerly/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"$ kubectl -n vulnerly apply -f k8s/policy/require-signed-images.yaml
$ kubectl -n vulnerly get pol
# NAME BACKGROUND VALIDATE ACTION
# require-signed-images false EnforceProve the negative first — build an image, push it, and deliberately never sign it at all:
$ docker build -t ghcr.io/YOU/vulnerly:unsigned-test .
$ docker push ghcr.io/YOU/vulnerly:unsigned-test
# (no cosign sign command runs against this tag — that's the point)
$ kubectl -n vulnerly run signature-test --image=ghcr.io/YOU/vulnerly:unsigned-test --restart=Never
# Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
# resources.spec.containers[0].image: Unverified signature.
# require-signed-images/verify-cosign-signature: image is not signedNow apply the real thing. k8s/deploy.yaml is the base manifest every remaining capstone part reuses or overlays:
# k8s/deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vulnerly
namespace: vulnerly
spec:
replicas: 2
selector:
matchLabels: { app: vulnerly }
template:
metadata:
labels: { app: vulnerly }
spec:
containers:
- name: vulnerly
image: ghcr.io/YOU/vulnerly:sha-<the commit SHA this part signed>
ports:
- containerPort: 3000
securityContext:
runAsNonRoot: true
runAsUser: 65532
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 250m, memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: vulnerly
namespace: vulnerly
spec:
selector: { app: vulnerly }
ports:
- port: 80
targetPort: 3000$ kubectl apply -f k8s/deploy.yaml
$ kubectl -n vulnerly rollout status deploy/vulnerly
$ kubectl -n vulnerly get pods
# vulnerly-7c9d8f6b5-abc12 1/1 Running 0 28s
# vulnerly-7c9d8f6b5-def34 1/1 Running 0 28sRunning, not CreateContainerConfigError, is itself the non-root proof, not just the declared intent sitting in the YAML above: runAsNonRoot: true makes the kubelet actively refuse to start any container whose effective user resolves to UID 0, checked against the image's own baked-in default the same way docker inspect read it earlier. A distroless nonroot image landing on runAsUser: 65532 and coming up Running means both layers — the image and the cluster — independently agree this process never touches root.
What "done" looks like for Part 4
☺ Like you're 10: A box that's small, clean, sealed twice, and checked at the door — every later part just walks through that door.
At the end of this part: a pinned, multi-stage, distroless, non-root Dockerfile; a required Trivy gate in ci.yml blocking any image with a CRITICAL finding; a Syft SBOM attested to every build; a vulnerly-dev cluster with vulnerly and platform namespaces; an image signed keylessly (verified against a pinned GitHub Actions identity) and against a published security/cosign.pub (verified offline, and proven to reject a key that never signed it); and a Kyverno Policy that has, with your own hands, both rejected an unsigned test image and admitted the real, signed one — which is now running, provably non-root, in the vulnerly namespace. Nothing here gets thrown away:
| Part | What it does with Part 4's artifacts |
|---|---|
| 5 — Scan IaC & Enforce Policy | Applies the same audit-then-enforce policy-as-code shape to infra/main.tf that require-signed-images.yaml just applied to Pods |
| 6 — Run DAST Against Staging | Deploys this exact signed image into a new staging namespace, copies k8s/policy/require-signed-images.yaml there verbatim, and re-signs a fixed rebuild the same two ways |
| 7 — Ship Compliance Evidence & Monitor | Imports the Trivy report, the Syft SBOM, and both signatures as evidence that a specific control — "the running image is signed and verified" — is provably true, not just claimed |
Benny: Distroless, nonroot, pinned by digest — Trivy's not finding a single critical in it anymore. About time, honestly.
Timmy: Not finding one today. Where's the gate that stops node:18 from creeping back in next sprint?
Benny: Already required in ci.yml — same shape as your SAST check. Nobody touches that Dockerfile without Trivy weighing in first.
Pip: Builds clean is one thing. I still don't see a signature on it.
Benny: Signing it now — keyless, GitHub's own identity. Four seconds, no key to remember.
Pip: That covers the cluster. What about an auditor eleven months from now with no GitHub login and no path to Rekor?
Benny: ...also generating a key pair for that. Fine, you were right to ask.
Ellie: If that's the private key on your screen, it belongs in a GitHub secret in the next ten seconds — not a terminal scrollback anyone could screenshot.
Benny: Already gone. Public key's committed, published, out where it's supposed to be.
Pip: Then I'll write the policy. Two signatures on this image — one for the cluster, one for everyone else — and nothing gets admitted missing either.
Milestones
☺ Like you're 10: Tick a box only once you've actually watched it happen on your own screen — a step that "sounds right" isn't the same as one you've verified.
Work these in order. Progress saves in this browser.
Dockerfile, then docker inspect --format='{{.Config.User}}' and a Trivy scan against it.vulnerly-dev with the vulnerly and platform namespaceskind create cluster --name vulnerly-dev, then create both namespaces.kubectl get ns lists both, and kubectl get nodes shows one Ready node..dockerignore and the new pinned, multi-stage, distroless Dockerfiledocker build -t vulnerly:hardened . succeeds with no errors.nonroot by defaultdocker inspect --format='{{.Config.User}}' vulnerly:hardened.nonroot, not empty.trivy image --severity CRITICAL --exit-code 1 vulnerly:hardened.0.container-scan as a required check in ci.ymlnode:18 fails this check automatically.syft vulnerly:hardened -o cyclonedx-json=sbom-image.cdx.json.express, pg, and the pinned Node version.cosign sign --yes "$DIGEST", then cosign verify --certificate-identity=... --certificate-oidc-issuer=... "$DIGEST".security/cosign.pub, sign, and verify offlinecosign generate-key-pair --output-key-prefix security/cosign, sign with --key, verify with --key, then commit the public half only.cosign verify --key security/cosign.pub "$DIGEST" succeeds, and the same command with a rogue key's public half fails with "no matching signatures."sign-and-push into CI: keyless sign, SBOM attest, and key-pair signid-token: write and both COSIGN_PRIVATE_KEY/COSIGN_PASSWORD secrets set.cosign tree.platform, apply k8s/policy/require-signed-images.yaml into vulnerly, then try to run a deliberately unsigned test image.kubectl run against the unsigned image is denied by the admission webhook.k8s/deploy.yaml with the signed digest, then confirm both Pods reach Running.kubectl -n vulnerly get pods shows both replicas Running — no CreateContainerConfigError, which is itself the non-root proof — and you can describe this state without looking anything up. It's the exact starting point Part 5 assumes.1. Why does the hardened Dockerfile pin both base images by digest rather than by tag, and why does dropping the shell in the distroless runtime stage matter beyond image size? 2. This part signs the same image digest two different ways. What does each signature prove that the other doesn't, and why does Vulnerly's pipeline do both instead of picking one? 3. Why does security/cosign.key get deleted from the terminal the moment it's uploaded to a CI secret, while security/cosign.pub is committed straight into the repo? 4. The Kyverno policy verifies a keyless signature's identity — a specific GitHub Actions workflow — rather than a public key. Why does that matter more, at admission time, than checking which key was used?
Check your answers
- A tag can be repointed to different bytes at any time by whoever controls it, so pinning by digest guarantees the exact bytes that were scanned and tested are the exact bytes that ship. Dropping the shell removes more than filesize: it removes the tool an attacker who achieves code execution would otherwise reach for to explore, pivot, or exfiltrate — a container with no shell and no package manager has almost nothing left to run even after a successful exploit.
- Keyless signing proves the image came from exactly this GitHub Actions workflow, on this branch, at this commit — an identity claim anchored to Fulcio's certificate and Rekor's public transparency log, with no long-lived secret for anyone to steal. The key-pair signature proves the image matches a specific public key anyone can hold and check completely offline, with no dependency on live access to GitHub, Fulcio, or Rekor. The pipeline does both because they serve different audiences: the cluster, which always has live access to verify an identity, and anyone outside it — an auditor, a partner, Part 7's evidence trail — who might not.
- Asymmetric signing only works if the private key stays private: anyone holding
cosign.keyand its password can forge a signature that verifies perfectly against the matching public key, which defeats the entire point of the check.cosign.pub, by contrast, can only be used to check a signature, never to create one — publishing it as widely as possible is what makes independent verification possible, the opposite of a risk. - A stolen or leaked private key can sign anything indistinguishably from a legitimate build — the key file itself carries no information about who or what actually used it. A keyless signature's certificate embeds the exact workflow, repository, and ref that produced it, so admission can enforce "only this specific CI job, on this branch, may ship into this namespace" — a far narrower and more meaningful guarantee than "signed by whoever currently holds this key file."
Part 4 leaves you with a small, clean, twice-signed image and a cluster that won't run anything else. Continue to Capstone Part 5 — Scan IaC & Enforce Policy, where the same audit-then-enforce shape gets applied to infra/main.tf's public bucket and open security group. Or step back to the full capstone hub to see how this part fits the other six, and revisit container & supply-chain security, Trivy, Syft & Grype, Sigstore & cosign, and Kyverno for the concepts behind what you just built.