Build and supply chain

Mirroring base images with a scan and a signature, the secure way

Every build pulls its base image straight from a public registry, which means your supply chain includes the registry's uptime, its rate limits, and whatever the tag points to this morning. A mirror fixes all three, as long as the mirror checks what it copies.

The short answer

Mirror each upstream image with one job: resolve the tag to a digest, verify the upstream signature against an exact identity, scan that digest with grype and --fail-on, copy it by digest into your registry, and sign the mirrored digest with a mirror key. Builds pull only from the mirror; admission accepts only mirror-signed digests.

Updated Houssam Hammoudi, CTOTested with crane, cosign v3.0.6, grype 0.119.0 (DB built 2026-09-24), registry:2

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Most pipelines pull base images from public registries at build time. That leaves three doors open:

  • The tag moves. debian:12-slim today and last week are different images. A compromised or mistaken push upstream reaches your build without a commit on your side.
  • Nothing checks where the image came from. A typo in the registry host, or a proxy that serves something else, goes unnoticed.
  • Scans happen after the image is already in use, if at all, and nobody owns the result.

A plain pull-through cache fixes rate limits, not trust: it copies whatever upstream serves. The mirror has to be a gate.

What the docs say

When scanning completes, Grype exits with code 2 if it found vulnerabilities at or above the specified severity

Source: Anchore docs, Filter scan results

For example, --fail-on high fails on both high and critical vulnerabilities.

Source: Anchore docs, Filter scan results

Make sure to sign the image by its digest (@sha256:...) rather than by tag (:latest) so that you actually sign what you think you're signing! This prevents race conditions or (worse) malicious tampering.

Source: cosign reference, cosign sign

Each tool documents its own step. None of them says the steps must all act on the same digest. If the scan uses the tag and the copy uses the tag a minute later, you may have scanned one image and mirrored another.

The secure configuration

One script, run on a schedule and on demand, is the only writer to the mirror/ path of your registry.

bash
#!/usr/bin/env bash
# mirror.sh SOURCE_REF DEST_REPO
# Mirror one upstream image into your registry only if:
#   1. the upstream signature verifies against the expected identity,
#   2. grype finds nothing at or above FAIL_ON (default: high),
# then copy it BY DIGEST and sign the mirrored digest with the mirror key.
# Tools (cosign, crane, grype) are functions or binaries on PATH.
set -euo pipefail
SRC=$1; DEST=$2
FAIL_ON=${FAIL_ON:-high}

# 1. Resolve the tag once. Everything after this line uses the digest.
DIGEST=$(crane digest "$SRC")
NAME=${SRC%@*}                                  # drop any @digest
case ${NAME##*/} in *:*) NAME=${NAME%:*} ;; esac   # drop the tag, keep a registry port
SRC_PINNED="${NAME}@${DIGEST}"
echo "resolved  $SRC -> $DIGEST"

# 2. Upstream signature, exact identity (not a regular expression).
if [ -n "${UPSTREAM_IDENTITY:-}" ]; then
  cosign verify \
    --certificate-oidc-issuer="$UPSTREAM_ISSUER" \
    --certificate-identity="$UPSTREAM_IDENTITY" \
    "$SRC_PINNED" >/dev/null
  echo "verified  upstream signature ($UPSTREAM_IDENTITY)"
fi

# 3. Scan the exact digest. grype exits 2 when a finding is >= FAIL_ON.
if ! grype "registry:$SRC_PINNED" --fail-on "$FAIL_ON" -o table > scan.txt 2>&1; then
  echo "REJECTED  findings at or above '$FAIL_ON':"
  grep -E ' (Critical|High) ' scan.txt | head -5 || tail -3 scan.txt
  exit 2
fi
echo "scanned   no findings at or above '$FAIL_ON'"

# 4. Copy by digest, then sign the digest that is now in your registry.
crane copy "$SRC_PINNED" "$DEST@$DIGEST"
cosign sign --yes --key mirror.key --signing-config signing-config.json "$DEST@$DIGEST" >/dev/null
echo "mirrored  $DEST@$DIGEST (signed with mirror.key)"

If the images are checked at admission by the Sigstore policy-controller with a public key, sign them in the classic format instead: the controller (v0.15.1) rejects the new bundle format that a signing config produces. See rolling out the Sigstore policy-controller:

bash
cosign sign --yes --key mirror.key \
  --use-signing-config=false --tlog-upload=false --new-bundle-format=false \
  "$DEST@$DIGEST"

How to run it safely:

  • The mirror job is the only identity with push rights to mirror/. Build jobs can pull from mirror/ and cannot push there.
  • mirror.key is used only by this job. signing-config.json with no services comes from the offline signing page; keyless signing works too if your network allows it.
  • The job keeps scan.txt as an artifact, so every mirrored digest has the scan that let it in.
  • Images whose publisher does not sign (many official images) run without UPSTREAM_IDENTITY. For those, the scan and your review of the source are the only checks; record that decision per image.

Builds then use FROM registry.example.com/mirror/wolfi-base@sha256:..., and the policy-controller accepts registry.example.com/mirror/** only with the mirror key's signature.

Prove it

A signed, clean upstream image goes through:

bash
./mirror.sh cgr.dev/chainguard/wolfi-base:latest registry:5000/mirror/wolfi-base
text
resolved  cgr.dev/chainguard/wolfi-base:latest -> sha256:fac38d12efdb4bf43ac9e599a31db10a27ad5dd71e5f1618790962eda8d66180
verified  upstream signature (https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main)
scanned   no findings at or above 'high'
mirrored  registry:5000/mirror/wolfi-base@sha256:fac38d12efdb4bf43ac9e599a31db10a27ad5dd71e5f1618790962eda8d66180 (signed with mirror.key)

An image with high and critical findings is stopped before anything is copied:

bash
./mirror.sh debian:12-slim registry:5000/mirror/debian
crane ls registry:5000/mirror/debian
text
resolved  docker.io/library/debian:12-slim -> sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251
REJECTED  findings at or above 'high':
libtasn1-6          4.19.0-2+deb12u1        (won't fix)  deb   CVE-2025-13151    High        1.1% (65th)   0.9
perl-base           5.36.0-7+deb12u3                     deb   CVE-2026-82560    High        0.6% (48th)   0.5
libc-bin            2.36-9+deb12u14         (won't fix)  deb   CVE-2026-5450     Critical    0.5% (42nd)   0.5
libc6               2.36-9+deb12u14         (won't fix)  deb   CVE-2026-5450     Critical    0.5% (42nd)   0.5
perl-base           5.36.0-7+deb12u3        (won't fix)  deb   CVE-2026-8376     Critical    0.4% (37th)   0.4
exit code 2

(nothing: the repository was never created)

The mirrored digest carries the mirror's signature:

bash
cosign verify --key mirror.pub --insecure-ignore-tlog=true registry:5000/mirror/wolfi-base@sha256:...
text
1 signature(s) for sha256:fac38d12efdb4bf43ac9e599a31db10a27ad5dd71e5f1618790962eda8d66180

The digest in your registry is the upstream digest: crane copy by digest does not rebuild or re-compress anything, so the upstream signature and the mirror signature both refer to the same bytes.

Mistakes people make

Scanning the tag, copying the tag

Between the scan and the copy, the tag can move. Resolve once, then use only the digest for verify, scan, copy and sign.

A gate nobody can pass

Many general-purpose images fail --fail-on high because of findings the distribution marks "won't fix", as the Debian run above shows. If you lower the gate for them, do it per image with a written reason, or use grype's ignore rules with a justification. Do not remove the gate for everyone.

Letting build jobs push to the mirror

If any CI job can push to mirror/, the mirror's signature means nothing. One identity writes; everything else reads.

A regular expression for the upstream identity

--certificate-identity-regexp '.*' accepts any workflow in any repository that got a Sigstore certificate. Use --certificate-identity with the exact workflow path and branch.

Mirroring once and forgetting

A digest that was clean last month may have critical findings today. Rescan mirrored digests on a schedule, and alert on new findings in digests that are still deployed.

Checklist

  • Builds pull base images only from your registry's mirror/ path.
  • One job with its own identity is the only writer to mirror/.
  • The job resolves the tag once and uses only the digest afterwards.
  • Upstream signatures are verified with an exact identity where the publisher signs.
  • grype scans the exact digest with --fail-on and the result is kept as an artifact.
  • Exceptions to the gate are per image, written, and reviewed.
  • Mirrored digests are signed with a key used only by the mirror job.
  • Admission policy requires the mirror signature for mirror/**.
  • Mirrored digests are rescanned on a schedule.

A mirror that copies everything is a cache. A mirror that says no, and can show why, is a control.

H2-CSDE

Learn it on a live range

Policy gates and scanning, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us