DevSecOps (AppSec in the pipeline) · lesson 7 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
Also part of: Docker
Signing Container Images with Cosign: Verify the Digest, Not the Tag
Sign an image in a local registry with a Cosign key pair, verify it, then push different content under the same tag and watch verification refuse it.
By SkillByExample · Published
Every check so far has looked at code before it’s built. Signing protects what comes after: it
lets a deploy step prove that the image it’s about to run is the exact one your pipeline produced.
This lesson signs an image in a local registry with a Cosign key pair, verifies it, and then swaps
the content behind the tag to show why you verify, and deploy, by digest. Signing and verifying use no
Sigstore services, though cosign sign still tries to fetch Sigstore’s TUF trust root and only
warns if it can’t. Tested with Cosign 3.1.3 (ghcr.io/sigstore/cosign/cosign:v3.1.3), the CNCF
Distribution registry 3.1.2 (registry:3.1.2) and Docker Engine 29.8.2.
A registry and an image
A local registry, reachable from the host on 127.0.0.1:5000 and from other containers as
registry:5000 on a private network.
$ docker network create signnet
$ docker run -d --name registry --network signnet -p 127.0.0.1:5000:5000 registry:3.1.2
The image is a one-line Python program.
# Dockerfile
FROM python:3.12-slim
CMD ["python", "-c", "print(\"shop 1.0\")"]
Build it, push it, and record its digest: the hash of the manifest the registry now stores.
$ docker build -q -t localhost:5000/shop:1.0 . && docker push -q localhost:5000/shop:1.0
sha256:66924c158acc122753e960075b56f3923f496f9579b84bd5857161f0f232591b
localhost:5000/shop:1.0
$ DIGEST=$(docker inspect --format "{{index .RepoDigests 0}}" localhost:5000/shop:1.0 | cut -d@ -f2)
$ echo $DIGEST
sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d
Two different hashes. The first is the local image ID; the second is the registry digest, and it’s the one that matters, because it’s what other machines pull and what Cosign signs. Your digest will differ, because the image records when it was built.
Generate a key pair
Cosign runs from its own image, on the same network as the registry. The private key is encrypted
with the password in COSIGN_PASSWORD; -e COSIGN_PASSWORD passes it from your shell.
$ export COSIGN_PASSWORD=lab-only
$ alias cosign='docker run --rm --network signnet -v "$PWD":/w -w /w -e COSIGN_PASSWORD ghcr.io/sigstore/cosign/cosign:v3.1.3'
$ cosign generate-key-pair
Private key written to cosign.key
Public key written to cosign.pub
cosign.key belongs in a secret store, never in git; the secrets lesson
would rightly flag it. cosign.pub is published, so anyone who deploys the image can verify it.
Sign the digest
Cosign 3 signs with a signing config that lists the Sigstore services to use: a transparency log and a timestamp authority. To use no log or timestamp service, create an empty one. (On a machine with no internet access, signing still succeeds after a warning that it couldn’t fetch the TUF root.)
$ cosign signing-config create --out offline.json
$ cat offline.json
{"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json","rekorTlogConfig":{},"tsaConfig":{}}
Now sign. --allow-http-registry is needed because the lab registry has no TLS, and --yes skips
the confirmation prompt.
$ cosign sign --key cosign.key --signing-config offline.json --allow-http-registry --yes registry:5000/shop@$DIGEST
Signing artifact...
Pushing signature to: registry:5000/shop
The image is referenced by digest, not tag, so the signature is attached to exactly that content. “Pushing signature to: registry:5000/shop” shows where it went: into the same repository, as an extra artifact linked to the digest. There’s no separate signature server.
Verify it
$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop@$DIGEST
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Verification for registry:5000/shop@sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The signatures were verified against the specified public key
{"critical":{"identity":{"docker-reference":"registry:5000/shop@sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d"},"image":{"docker-manifest-digest":"sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}
$ echo $?
0
Verified, exit 0. The JSON at the end is the signed payload, and its docker-manifest-digest is the
digest you signed. Two lines need care. The warning is expected: this lab has no transparency log,
so --insecure-ignore-tlog is required. The middle check line still says the transparency log was
“verified offline” even though it was skipped; with --insecure-ignore-tlog, read that line as
skipped, not passed.
Move the tag to different content
Anyone with push access can repoint a tag. Build a different image and push it as shop:1.0.
# Dockerfile.other
FROM python:3.12-slim
CMD ["python", "-c", "print(\"something else\")"]
$ docker build -q -f Dockerfile.other -t localhost:5000/shop:1.0 . && docker push -q localhost:5000/shop:1.0
sha256:6aff6e18fd70b992779a9cbdc95a73daf9d03958f9b10452949b2c92c16aceef
localhost:5000/shop:1.0
$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop:1.0
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Error: no signatures found
error during command execution: no signatures found
$ echo $?
10
The push succeeded with no complaint: the registry doesn’t know or care about signatures. But
verification by tag now fails with exit 10, because Cosign resolves shop:1.0 to the new digest,
and nothing was ever signed for that digest. A deploy step that verifies before it runs would stop
here.
The original image hasn’t gone anywhere. Verify by its digest and it still passes.
$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop@$DIGEST >/dev/null 2>&1
$ echo $?
0
That’s the rule this lesson exists for: sign the digest, deploy the digest. A tag tells you what someone meant to deploy; a digest tells you what you will actually run.
When you’re done, clean up with docker rm -f registry && docker network rm signnet.
Practice
-
Generate a second key pair in a folder called
other, and verify the signed digest withother/cosign.pub. What happens?Solution
Run
cosign generate-key-pairfrom insideother(or mount it), then verify with--key other/cosign.pub. Verification fails with exit 1 andaccepted signatures do not match threshold, Found: 0, Expected 1. The signature exists but was made by a different key, so it doesn’t count. That’s what stops an attacker who can push to the registry, but doesn’t have your private key, from signing their own image. -
Re-sign so that the new
shop:1.0passes verification by tag. Should a pipeline be allowed to do that?Solution
Get the new digest with the same
docker inspectcommand, then runcosign signonregistry:5000/shop@<new digest>. Verification by tag then passes. That’s why the private key is the thing to protect: only the build pipeline that produced an image should hold it, never a person or job that can also push arbitrary content. -
Write a one-line deploy guard that runs the image only if verification passes.
Solution
cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry registry:5000/shop@$DIGEST >/dev/null 2>&1 && docker run --rm localhost:5000/shop@$DIGEST&&runs the image only when verify exits 0. Note that both commands use the same digest, so there’s no window in which the tag could move between checking and running. In Kubernetes, an admission controller such as the Sigstore policy-controller does the same check before a pod starts.
Where this fits
This completes the pipeline: threat model, code, secrets, dependencies, the running app, the gate, and finally the artifact itself. The borrowed lessons below add container hardening, CI secrets handling and infrastructure secrets.