Everything we have built so far in this module protects the cluster at runtime. RBAC decides who can do what (08-01). The securityContext limits what a container can do (08-02). Admission policies prevent deploying anything that breaks the rules (08-03). And network policies control what talks to what and what can get out (08-04).

But all of that rests on an assumption we have never questioned: that the images we run are the ones we think they are.

And that assumption is fragile. registry.rutasnorte.example/bookings-api:2.7.1 is a tag, and a tag can be reassigned: the image pulled yesterday may not be today's. The base image it was built on contains hundreds of packages nobody has reviewed. The dependencies were installed from public repositories during the build. And as things stand, nothing stops somebody with registry access from pushing a modified image under the same name and having the cluster run it without complaint, with all our securityContext settings perfectly applied... to a container that does something different from what we think.

This lesson deals with the supply chain of images: where they come from, how they are built, how they are identified unambiguously, how they are signed and how to get the cluster to reject anything that has not been verified.

Important warning. The image policy of a production platform must be designed and reviewed by a security professional. When the images process personal data —bookings-api, bookings-postgres and occupancy-reports at Rutas Norte— the compliance officer must know what controls exist over the software that handles that data, because a compromised dependency is a direct route to it. This lesson's approach is exclusively defensive: understanding where an image can be poisoned in order to close each point and detect the attempts.

Contents

  1. The supply chain of an image
  2. Building minimal images
  3. A non-root user in the Dockerfile
  4. Identifying the image unambiguously: tags and digests
  5. imagePullPolicy and the reused-tag trap
  6. The private registry
  7. Signing and verification with Cosign
  8. Verifying in the cluster with an admission policy
  9. SBOM and provenance attestations
  10. Base image policy and periodic rebuilds
  11. Rutas Norte's complete image policy
  12. Common mistakes and tips
  13. Exercises
  14. Conclusion

  1. The supply chain of an image

A container image does not appear out of nowhere. It is the result of a chain of steps, and every link is a point where something unwanted can be introduced.

flowchart TB
    A["1. Base image<br/>node:22-alpine"] --> B["2. Dependencies<br/>npm install, apt-get"]
    B --> C["3. Build process<br/>docker build in CI"]
    C --> D["4. Registry<br/>registry.rutasnorte.example"]
    D --> E["5. Pull<br/>the kubelet pulls the image"]
    E --> F["6. Execution<br/>the container runs on the node"]

    A -.->|"compromised or<br/>abandoned base image"| X1["Risk"]
    B -.->|"malicious package,<br/>typosquatting,<br/>abandoned dependency"| X2["Risk"]
    C -.->|"compromised CI,<br/>secrets in layers,<br/>non-reproducible build"| X3["Risk"]
    D -.->|"stolen credentials,<br/>reassigned tag"| X4["Risk"]
    E -.->|"mutable tag,<br/>no verification"| X5["Risk"]

    style X1 fill:#f9d5d5,stroke:#c33
    style X2 fill:#f9d5d5,stroke:#c33
    style X3 fill:#f9d5d5,stroke:#c33
    style X4 fill:#f9d5d5,stroke:#c33
    style X5 fill:#f9d5d5,stroke:#c33

The five risk points

Link How it gets compromised Defence (section)
Base image An abandoned public image, with unpatched vulnerabilities, or from an unknown author Minimal images from a known origin (2), base policy (10)
Dependencies An npm or PyPI package with malicious code; a name similar to the legitimate one Lock files, SBOM (9), scanning (08-06)
Build A compromised CI injects something; secrets left behind in a layer Provenance (9), multi-stage builds (2)
Registry Stolen credentials: somebody pushes an image under the same name Access control, immutable tags (6), signing (7)
Pull The tag now points at a different image Digest (4), signature verification at admission (8)

Notice that the defences reinforce one another. The digest guarantees that you pull exactly the content you expect, but it does not say whether that content is good. The signature says somebody trusted approved it, but not whether it contains vulnerabilities. The scan says which vulnerabilities it has, but not whether somebody modified it. You need all of them.

Why this matters concretely at Rutas Norte

Think about bookings-api. It is a Node.js application with perhaps 400 transitive dependencies: packages installed by a package installed by a package you asked for. Every one of them is code that will run with access to the bookings-postgres connection, that is, with access to the customer table.

No kernel bug and no privilege escalation is needed. A compromised dependency is already inside, with the application's legitimate permissions. Not even the most restrictive securityContext prevents it, because it is not doing anything forbidden: it is using the database connection the application needs.

That is the reason this lesson exists.

  1. Building minimal images

The principle is simple: what is not in the image cannot have vulnerabilities and cannot be used by anybody.

The problem with bulky images

An image based on a full distribution brings hundreds of packages your application does not use: compilers, package managers, curl, wget, a complete shell, network tools. Each one is attack surface and a potential source of vulnerability alerts you have to manage.

Multi-stage builds

This is the fundamental technique: use an image with tooling to build, and copy only the result into a clean image.

# bookings-api Dockerfile — multi-stage build

# ---------- Stage 1: build ----------
# Compilers, package managers and tools live here.
# This stage does NOT end up in the final image.
FROM node:22.7.0-alpine AS builder

WORKDIR /build

# Copy ONLY the dependency manifests first:
# this layer is then reused as long as the dependencies do not change.
COPY package.json package-lock.json ./

# npm ci (not npm install) installs EXACTLY what the lock file
# says. It is reproducible and updates nothing on its own.
RUN npm ci --omit=dev

COPY src/ ./src/
RUN npm run build

# Remove caches that contribute nothing at runtime
RUN npm cache clean --force

# ---------- Stage 2: final image ----------
# distroless: only the Node.js runtime and the indispensable system
# libraries. No shell, no package manager, no utilities.
FROM gcr.io/distroless/nodejs22-debian12:nonroot

WORKDIR /app

# Copy ONLY what is needed from the build stage
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /build/dist ./dist

# Non-root user, declared by NUMBER (see section 3)
USER 65532

EXPOSE 8080

# Standard OCI labels: they document the image's origin
LABEL org.opencontainers.image.title="bookings-api" \
      org.opencontainers.image.description="Rutas Norte bookings API" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/platform/bookings-api" \
      org.opencontainers.image.licenses="Proprietary"

# distroless has no shell, so the exec form is mandatory
CMD ["dist/server.js"]

Points worth highlighting:

  • npm ci instead of npm install. ci installs exactly what package-lock.json says and fails if the lock file is out of sync. install may resolve different versions on each build, which means two builds of the same code produce different images. Reproducibility and security go hand in hand.
  • Copying the manifests before the code. It takes advantage of the layer cache: as long as the dependencies do not change, they are not reinstalled.
  • --omit=dev: development dependencies (test frameworks, linters) must not end up in production. They are usually half the tree.
  • --chown on the COPY: the files end up with the correct owner without needing a RUN chown that would double the layer's size.
  • OCI labels: they leave a trace of where the image came from. When somebody finds this image in the registry a year from now, they will know which repository it came out of.

An important detail that is constantly forgotten: secrets used during the build stay in the layers even if you delete them afterwards. If you do RUN echo $TOKEN > /tmp/token && ... && rm /tmp/token, the token is still in the intermediate layer and anybody who pulls the image can extract it. The correct solution is RUN --mount=type=secret, which mounts the secret during the command without persisting it:

# The correct way to use a secret during the build
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci --omit=dev

A real comparison of base images

Base Typical size System packages Shell Package manager Debugging Usual vulnerabilities
node:22 (full Debian) ~1.1 GB ~400 Yes Yes (apt) Very easy Dozens
node:22-slim ~250 MB ~120 Yes Yes Easy Considerably fewer
node:22-alpine ~180 MB ~30 Yes (busybox) Yes (apk) Easy Few
distroless/nodejs22 ~150 MB ~10 No No Hard Very few
scratch (static binary) ~15 MB 0 No No Very hard Practically none

And with nginx, the web-store case:

Image Size Notes
nginx:1.27 ~190 MB Runs as root in order to open port 80
nginx:1.27-alpine ~50 MB Still starts as root
nginxinc/nginx-unprivileged:1.27-alpine ~50 MB No root, port 8080: the one we adopted in 08-02

The honest trade-off: debugging

This is the part the enthusiastic articles do not tell you. With distroless or scratch:

kubectl exec -n rutas-norte-pro deploy/bookings-api -- sh
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknown

There is no shell. That is exactly what we want from a security standpoint —whoever manages to run code has no tools— but it means the usual diagnostic techniques do not work.

The solution is the one we saw in 07-06: ephemeral containers.

kubectl debug -n rutas-norte-pro -it deploy/bookings-api \
  --image=registry.rutasnorte.example/utilities:1.4.2 \
  --target=api -- sh
Defaulting debug container name to debugger-x7k2m.
/ # ps aux
PID   USER     COMMAND
    1 65532    /nodejs/bin/node dist/server.js
   15 65532    sh
/ # ls /proc/1/root/app
dist  node_modules

The ephemeral container brings the tools and, with --target, shares the target container's process namespace. You can inspect the process, its file system through /proc/1/root and its network, without the production image containing a single utility.

An important security note: kubectl debug requires the pods/ephemeralcontainers subresource, which in 08-01 we classified as very high risk and did not grant to support or to development. It is a platform operation. And in 08-03 the mandatory-registry Kyverno policy included ephemeralContainers precisely so that nobody could inject an arbitrary image by this route.

The selection criteria for Rutas Norte

Component Chosen base Reason
bookings-api distroless/nodejs22 Our own application, internet-facing, processes personal data. Maximum surface reduction
web-store nginx-unprivileged:alpine A standard web server; alpine is enough and makes tuning the configuration easier
notifications-worker distroless/nodejs22 Same as the API; no interactive access needed
occupancy-reports distroless/nodejs22 A CronJob that reads personal data
bookings-postgres postgres:16.4-alpine (official) A database: we do not build it. The official one is used, mirrored into our registry
redis-cache redis:7.4-alpine (official) The same
utilities (debugging) alpine with tools Only for ephemeral containers; never in a deployed pod

A useful principle: scratch for static binaries (Go, Rust), distroless for languages with a runtime (Node.js, Python, Java), alpine when you need a bit of operating system, and a full distribution only if you have a concrete reason you can write down.

  1. A non-root user in the Dockerfile

In 08-02 we configured runAsNonRoot: true and runAsUser in the manifests. Here we do the same from the other side: declaring the user in the image.

Why both

# In the Dockerfile
USER 65532
# In the manifest
securityContext:
  runAsNonRoot: true
  runAsUser: 65532

It looks redundant and it is not. They reinforce each other:

Scenario Only USER in the Dockerfile Only runAsUser in the manifest Both
The image runs outside Kubernetes (docker run, local tests) Protected Unprotected Protected
Somebody deploys the image with no securityContext Protected Unprotected Protected
Somebody rebuilds the image forgetting the USER Unprotected Protected Protected
Kubernetes verifies before starting It cannot: it does not know which user the image carries Yes Yes

The most valuable case is the second: if tomorrow somebody copies this image into a new manifest without the complete securityContext, the Dockerfile's USER still protects. And in the other direction, runAsNonRoot: true in the manifest detects if somebody rebuilt the image without the USER and rejects the pod instead of silently starting it as root.

Declaring the user by NUMBER

This is critical and we already flagged it in 08-02:

# BAD: a user name
RUN adduser -D -u 10001 application
USER application

# GOOD: a number
RUN adduser -D -u 10001 application
USER 10001

With a name, Kubernetes cannot verify runAsNonRoot: true, because to resolve it, it would have to read the image's /etc/passwd before starting it:

Error: container has runAsNonRoot and image has non-numeric user (application),
cannot verify user is non-root

The pod does not start. A baffling failure whose cause lies in the Dockerfile, not in the manifest.

Creating a user in an image with no user manager

In distroless there is no adduser. Images with the :nonroot suffix already have user 65532 created, which is the most convenient. For scratch, you copy in an /etc/passwd fabricated in the build stage:

FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# A static binary: no system dependencies
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o exporter ./cmd/exporter

# Fabricate a minimal /etc/passwd with a single user
RUN echo "exporter:x:10005:10005::/:/sbin/nologin" > /minimal-passwd

FROM scratch
# Root certificates: without them HTTPS is impossible
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /minimal-passwd /etc/passwd
COPY --from=builder /build/exporter /exporter
USER 10005
ENTRYPOINT ["/exporter"]

The resulting image contains three files: the binary, the certificates and the /etc/passwd. Nothing else. There is no shell to open, no curl to use, no package manager to install anything. It is the smallest attack surface possible.

A detail that surprises people: without the root certificates, every HTTPS connection fails with a certificate verification error. It is the most common problem when moving to scratch.

Checking

docker run --rm registry.rutasnorte.example/bookings-api:2.7.1 id

With distroless this fails because there is no id. The alternative is to inspect the metadata:

docker inspect registry.rutasnorte.example/bookings-api:2.7.1 \
  --format '{{.Config.User}}'
65532

And a check that can be automated in the pipeline:

#!/usr/bin/env bash
# ci/verify-image-user.sh
IMAGE="$1"
user=$(docker inspect "$IMAGE" --format '{{.Config.User}}')

if [[ -z "$user" || "$user" == "root" || "$user" == "0" ]]; then
  echo "FAIL: $IMAGE runs as root (USER='$user')"
  exit 1
fi
if ! [[ "$user" =~ ^[0-9]+$ ]]; then
  echo "FAIL: $IMAGE declares the user by name ('$user'), not by number."
  echo "      runAsNonRoot will not be able to verify it and the pod will not start."
  exit 1
fi
echo "OK: $IMAGE runs as UID $user"

  1. Identifying the image unambiguously: tags and digests

This is probably the part of the lesson with the most practical consequences.

Why latest is unacceptable

image: registry.rutasnorte.example/bookings-api:latest   # NEVER

latest does not mean "the latest one": it is simply the tag used by default when you do not specify one. There is no guarantee whatsoever that it points at anything in particular.

The problems, one by one:

Problem Consequence
You do not know which version is running Impossible to correlate an error with a version of the code
Different replicas may have different versions If a replica is recreated, it pulls whatever is there now. You can have three replicas with three versions
Rollbacks do not work kubectl rollout undo (02-04) reverts to the previous Deployment, whose image was... latest
It is not reproducible The same manifest applied today and tomorrow gives different results
There is no change review The content changes without any file in Git being modified

That second point is especially insidious because it produces incidents that are impossible to diagnose: half the requests work and the other half do not, depending on which replica they land on.

In 08-03 we wrote a ValidatingAdmissionPolicy that forbids latest cluster-wide. That policy is what turns this recommendation into a guarantee.

Immutable tags

The next step up is to use tags that, by convention and by registry configuration, are never reassigned:

image: registry.rutasnorte.example/bookings-api:2.7.1

Common schemes:

Scheme Example Advantages
Semantic version 2.7.1 Readable; it communicates the scope of the change
Commit hash a3f9c1d Direct traceability to the code
Combined 2.7.1-a3f9c1d The best of both: the recommendation
Date and build 2026.08.06-142 Useful if there is no formal versioning

The key word is immutable: once 2.7.1 is published, that tag is never reassigned to different content. If something has to be fixed, 2.7.2 is published. Many registries let you enforce that rule server-side (section 6), and at that point it stops being a convention and becomes a guarantee.

The digest: the only genuinely reproducible reference

Even with tags that are immutable by convention, a tag is a mutable pointer: if somebody with registry access reassigns it, the cluster will pull something else.

The digest is the SHA-256 hash of the image manifest. It is cryptographically immutable: if the content changes, the digest changes. It cannot be reassigned because it is not a name, it is a fingerprint of the content.

image: registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
Reference Can the content change? Readable Recommendation
image:latest Yes, at any moment Yes Never
image:2.7 Yes (reassigned with every patch) Yes Avoid in production
image:2.7.1 Only if somebody reassigns it Yes Acceptable with registry immutability
image@sha256:... Impossible No The right one for production

The practical objection is obvious: a digest is unreadable. The solution is to write both:

containers:
  - name: api
    # bookings-api v2.7.1 (commit a3f9c1d, built 2026-08-04)
    image: registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

Or, in modern practice, let a tool manage it. Kustomize (10-04) resolves the digest automatically:

# k8s/environments/pro/kustomization.yaml
images:
  - name: registry.rutasnorte.example/bookings-api
    newTag: "2.7.1"
    digest: "sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0"

And tools such as Renovate or Dependabot update the digests with a pull request when new versions appear, so that an image change goes through code review like any other change. That is exactly the goal.

Obtaining the digest

# Of an image in the registry, without pulling it
crane digest registry.rutasnorte.example/bookings-api:2.7.1
sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# Of the image running in the cluster right now
kubectl get pods -n rutas-norte-pro -l app=bookings-api \
  -o jsonpath='{.items[*].status.containerStatuses[*].imageID}{"\n"}'
registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

That second command is pure gold when investigating an incident: it tells you exactly what content is running, not which tag was requested. If the digest does not match the one you expected, you have a problem to investigate urgently.

A very useful check: verifying that all the replicas are running the same digest.

kubectl get pods -n rutas-norte-pro -l app=bookings-api \
  -o json | jq -r '.items[].status.containerStatuses[] | "\(.name) \(.imageID)"' | sort -u
api registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b...
payments-ambassador registry.rutasnorte.example/payments-ambassador@sha256:3c8e1f5a...

Two lines for two containers. If three lines appeared for api, there are replicas with different versions.

  1. imagePullPolicy and the reused-tag trap

imagePullPolicy determines when the kubelet pulls the image from the registry.

Value Behaviour Security implication
Always Queries the registry on every container start Detects tag reassignment; requires the registry to be available
IfNotPresent Uses the node's local copy if it exists A node can keep an old version indefinitely
Never Only uses the local copy; fails if it is missing Only for local development with preloaded images

The defaults, which surprise people

If you do not specify imagePullPolicy:

Image reference Default value
The :latest tag Always
Any other tag IfNotPresent
A digest (@sha256:...) IfNotPresent

That behaviour has a logic to it: with a digest, IfNotPresent is perfectly safe because the local copy is necessarily the correct content (if the digest matches, the content matches).

The reused-tag trap

Here is the scenario you have to understand:

  1. bookings-api:2.7.1 is deployed with imagePullPolicy: IfNotPresent.
  2. Nodes A, B and C pull the image and cache it.
  3. Somebody reassigns the 2.7.1 tag in the registry to different content.
  4. A pod is recreated on node D, which did not have the image: it pulls the new one.
  5. The pods on A, B and C carry on running the old one.

The result: replicas of the same Deployment running different content, under the same image name. And no Kubernetes tool will tell you: kubectl get pods shows the same image for all of them.

Only by looking at the imageID (the effective digest) does the discrepancy show up. That is why the check in the previous section is so valuable.

And that is why the underlying solution is using digests, not tweaking imagePullPolicy:

Approach Does it solve the problem?
imagePullPolicy: Always with a tag Partly: it detects the change, but it runs the new content without anybody having approved it
Digest Yes: it is impossible for the content to differ

Note the nuance in the first row. Always does not protect you from a maliciously reassigned tag: it guarantees that all the pods run the new content, which is exactly what whoever reassigned it wanted.

Other Always considerations

Aspect Effect
Availability If the registry does not respond, the pods do not start. A registry failure becomes a platform failure
Start-up latency It adds a registry query on every start (fast if the image is already cached, but not free)
Authorization It checks that the imagePullSecret is still valid, which revokes access on nodes that already had the image

That last point is a real security benefit: with IfNotPresent, a node that already has the image cached keeps using it even if the registry credentials have been revoked.

Rutas Norte's recommendation

containers:
  - name: api
    # Digest: the content is cryptographically the expected one
    image: registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
    imagePullPolicy: IfNotPresent   # safe with a digest, and independent of the registry

With a digest, IfNotPresent is both safe and robust. It is the best of both worlds.

Exception: in rutas-norte-dev, where you iterate quickly with mutable tags, Always is reasonable. And a warning from 08-02 that applies here: on minikube with locally built images, Always makes the kubelet try to pull from the registry and fail; that is where Never or IfNotPresent make sense.

  1. The private registry

Why your own registry

Reason Detail
Access control You decide who publishes and who pulls
A single control point Everything that runs passes through one place where it can be scanned and signed
Availability You do not depend on a public registry being reachable
Mirroring public images A public image can disappear or change; your copy cannot
Auditing A record of who pushed and pulled what, and when

In 08-03 we wrote the Kyverno policy that requires every image to come from registry.rutasnorte.example. The registry stops being a convenience and becomes the point where all the controls are applied.

imagePullSecrets on the ServiceAccount

We already saw imagePullSecrets in 03-02. The important improvement is declaring them on the ServiceAccount instead of on each pod:

# Create the secret with the registry credentials
apiVersion: v1
kind: Secret
metadata:
  name: rutasnorte-registry
  namespace: rutas-norte-pro
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64 of the Docker configuration file>
---
# Declare it on the ServiceAccount: it applies to ALL the pods that use it
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
imagePullSecrets:
  - name: rutasnorte-registry
automountServiceAccountToken: true   # from 03-06: it reads a ConfigMap through the API
kubectl create secret docker-registry rutasnorte-registry \
  --namespace rutas-norte-pro \
  --docker-server=registry.rutasnorte.example \
  --docker-username=pull-pro \
  --docker-password="$(cat /secure/path/pull-password)" \
  [email protected]

Advantages of declaring it on the ServiceAccount:

  • It does not have to be repeated in every Deployment: it gets forgotten less.
  • Changing the credentials is a single object.
  • You can have one ServiceAccount with access to one repository in the registry and another with access to a different one.

And a warning from 08-01 that applies directly: this Secret contains registry credentials. Anyone with list on secrets in the namespace can read them. In our design, only platform.

Registry access control

The principle is the separation between pushing and pulling:

Identity Permission Scope
ci-rutasnorte (pipeline) Push Only rutasnorte/*
pull-pro (production nodes) Pull only Only rutasnorte/*
pull-dev Pull only rutasnorte/*
platform (people) Push and delete Everything, with a second factor
development (people) Push Only rutasnorte/dev/*

Production nodes must never hold push credentials. If somebody compromises a node and the imagePullSecret allows pushing, they can upload poisoned images to the registry the whole platform draws from. It is an escalation from one node to the entire cluster.

Server-side tag immutability

Most modern registries let you configure a tag so that, once published, it cannot be reassigned:

# Example with Harbor: an immutability rule in the project
# Configuration > Tag immutability
#   Repositories: **
#   Tags: ** (except dev-*)

This turns "we use immutable tags by convention" into a technical guarantee. An attempt to reassign a tag fails:

Error response from daemon: unknown: The tag 2.7.1 is immutable and cannot be overwritten

Using digests is still recommended, because immutability protects against mistakes and against reassignment, but a registry compromised at the storage level could get around it. The digest is verified by the client itself.

Mirroring the public images

Rutas Norte uses postgres:16.4, redis:7.4-alpine and nginx-unprivileged. All of them are excellent public images, and even so it is worth copying them into our own registry:

#!/usr/bin/env bash
# ci/mirror-public-images.sh
# Copies the public images into the company registry, by DIGEST.
set -euo pipefail

REGISTRY=registry.rutasnorte.example

# Format: source[@digest] target
IMAGES=(
  "docker.io/library/postgres:16.4-alpine    $REGISTRY/external/postgres:16.4-alpine"
  "docker.io/library/redis:7.4-alpine        $REGISTRY/external/redis:7.4-alpine"
  "docker.io/nginxinc/nginx-unprivileged:1.27-alpine $REGISTRY/external/nginx-unprivileged:1.27-alpine"
)

for line in "${IMAGES[@]}"; do
  read -r source target <<<"$line"
  echo "== $source -> $target"

  # Resolve the source digest and log it: it records WHAT was copied
  digest=$(crane digest "$source")
  echo "   source digest: $digest"

  # Copy by digest, not by tag: it guarantees we copy what we verified
  crane copy "${source%%:*}@${digest}" "$target"

  # Scan before accepting it (see 08-06)
  trivy image --severity HIGH,CRITICAL --exit-code 1 "$target" || {
    echo "   WARNING: serious vulnerabilities. Review before using in production."
  }

  # Sign the copy (see section 7)
  cosign sign --yes "$target"
done

What this solves:

Problem How mirroring solves it
The public image disappears or is deleted You have your copy
The public registry is unavailable Your deployments keep working
The public image changes without warning Your copy is the one you verified
Public registry pull limits You do not hit them
You do not know which public images you use They are all in external/

That last point has an inventory value that gets underestimated: when a serious vulnerability appears in a library, the question will be "which of our images contain it?", and having everything in your own registry turns the answer into a query rather than an investigation.

  1. Signing and verification with Cosign

So far we have guaranteed what content runs (the digest). What is missing is guaranteeing that somebody trusted approved that content.

The problem signing solves

A digest tells you the image has not changed. It does not tell you whether that image was built by your pipeline or by somebody who stole the registry credentials. The cryptographic signature answers that question: who vouches for this image.

Sigstore and Cosign

Sigstore is a project that makes software artefact signing accessible. Cosign is its tool for signing container images.

Installation:

curl -sSL -o cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign && sudo mv cosign /usr/local/bin/
cosign version

Signing with a key pair

The classic model:

# 1. Generate the key pair (once)
cosign generate-key-pair
Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub
# 2. Sign an image BY DIGEST (never by tag)
cosign sign --key cosign.key \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# 3. Verify
cosign verify --key cosign.pub \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b... | jq .
[
  {
    "critical": {
      "identity": { "docker-reference": "registry.rutasnorte.example/bookings-api" },
      "image": {
        "docker-manifest-digest": "sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0"
      },
      "type": "cosign container image signature"
    },
    "optional": {
      "commit": "a3f9c1d",
      "built-by": "ci-rutasnorte",
      "date": "2026-08-04T09:14:22Z"
    }
  }
]

Always sign by digest, never by tag. If you sign image:2.7.1, Cosign resolves the digest at that moment; but if somebody reassigns the tag afterwards, the signature does not correspond to the new content and verification fails, which is correct but confusing. Signing by digest makes it clear what is being vouched for.

The problem with a key pair is where the private key lives. If it is in the pipeline, whoever compromises the pipeline can sign anything. It can be mitigated with a KMS:

cosign sign --key awskms:///alias/rutasnorte-image-signing \
  registry.rutasnorte.example/bookings-api@sha256:...

Keyless signing with an OIDC identity

This is the model Sigstore recommends and it solves the key custody problem.

It works like this:

  1. Cosign obtains an OIDC token from an identity provider (the pipeline itself, GitHub Actions, GitLab CI, Google, and so on).
  2. It asks Fulcio (Sigstore's certificate authority) for a short-lived certificate, valid for ten minutes, binding the identity to an ephemeral key.
  3. It signs the image with that key.
  4. It publishes the signature and the certificate in Rekor, a public, immutable, append-only transparency log.
  5. It discards the private key.
# In the pipeline: with no key to look after
COSIGN_EXPERIMENTAL=1 cosign sign --yes \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b...

Verification by identity, not by key:

cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/platform/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b...
Verification for registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b... --
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 code-signing certificate was verified using trusted certificate authority certificates

What you are verifying is not "this image was signed by key X", but something far more useful: "this image was signed by the pipeline of the platform/* repository at git.rutasnorte.example".

Comparison:

Key pair Keyless (OIDC)
Key custody A real problem: it must be stored and rotated There is no key to store
If the key is stolen Anything can be signed until it is detected The key lives for 10 minutes
What it verifies That it was signed by whoever holds the key Which identity and from where
Transparency log Optional Yes: every signature is logged publicly
Works offline Yes It needs Fulcio and Rekor (or your own instances)
Initial complexity Low Medium

Recommendation for Rutas Norte: keyless with OIDC, using the pipeline's identity provider. It is more secure and it removes the work of looking after keys.

A privacy note: Sigstore's public transparency log makes the names of signed images public. If that is a problem (it reveals internal project names), you can deploy a private instance of Fulcio and Rekor.

Signing in the pipeline

# .gitlab-ci.yml (fragment) — build, scan and sign
build-and-sign:
  stage: publish
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    - VERSION="${CI_COMMIT_TAG:-0.0.0-${CI_COMMIT_SHORT_SHA}}"
    - IMAGE="registry.rutasnorte.example/bookings-api"

    # 1. Build
    - docker build -t "${IMAGE}:${VERSION}" .

    # 2. Scan BEFORE publishing: if there are serious vulnerabilities, it stops (08-06)
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMAGE}:${VERSION}"

    # 3. Check that it does not run as root
    - ./ci/verify-image-user.sh "${IMAGE}:${VERSION}"

    # 4. Publish and resolve the digest
    - docker push "${IMAGE}:${VERSION}"
    - DIGEST=$(crane digest "${IMAGE}:${VERSION}")
    - echo "Published digest: ${DIGEST}"

    # 5. Sign BY DIGEST, with the pipeline's OIDC identity
    - cosign sign --yes "${IMAGE}@${DIGEST}"

    # 6. Generate and attach the SBOM (section 9)
    - syft "${IMAGE}@${DIGEST}" -o spdx-json > sbom.json
    - cosign attest --yes --predicate sbom.json --type spdxjson "${IMAGE}@${DIGEST}"

    # 7. Publish the digest so that the deployment uses it
    - echo "IMAGE_DIGEST=${DIGEST}" >> variables.env
  artifacts:
    reports:
      dotenv: variables.env

The order matters: scan before publishing. Publishing first and scanning afterwards means that for a while there is a vulnerable image available for somebody to deploy.

  1. Verifying in the cluster with an admission policy

Signing in the pipeline is half the job. If the cluster accepts any image, the signature is decoration: whoever can bypass the pipeline and push directly to the registry deploys whatever they like.

Signature verification has to happen in the cluster, at admission control. It is the only way to guarantee that absolutely everything that runs has been through the process.

A Kyverno policy with signature verification

We pick up Kyverno from 08-03, now with verifyImages:

# k8s/policies/verify-image-signatures.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
  annotations:
    policies.kyverno.io/title: Image signature verification
    policies.kyverno.io/category: Supply chain
    policies.kyverno.io/severity: critical
    policies.kyverno.io/description: >-
      Every image deployed in rutas-norte-pre and rutas-norte-pro must be
      signed by the official Rutas Norte pipeline. Kyverno verifies the
      signature against the transparency log and, if it is valid, MUTATES the
      manifest replacing the tag with the verified digest.
spec:
  validationFailureAction: Enforce
  background: false        # verifyImages only acts at admission
  webhookTimeoutSeconds: 30  # verifying against Rekor can take a while
  rules:
    - name: official-pipeline-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/*"
          # Replaces the tag with the verified digest in the manifest
          mutateDigest: true
          # Requires the reference to be verifiable
          required: true
          # Caches the verifications so as not to query Rekor for every pod
          useCache: true
          attestors:
            - count: 1
              entries:
                # Keyless signing with an OIDC identity
                - keyless:
                    subject: "https://git.rutasnorte.example/platform/*"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev

        # The mirrored public images are signed by the platform team
        - imageReferences:
            - "registry.rutasnorte.example/external/*"
          mutateDigest: true
          required: true
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexample1234567890abcdefghij
                      klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789example==
                      -----END PUBLIC KEY-----

Key aspects of the manifest:

  • mutateDigest: true is the most elegant part of this policy. Kyverno verifies the signature, obtains the digest and rewrites the manifest so that it uses the digest instead of the tag. It automatically solves the problem from section 4: even though the Deployment says :2.7.1, the pod that is created uses @sha256:..., the very one that was verified.
  • required: true: if the image matches no verification rule, it is rejected. Without this, an image from an unforeseen registry would go through unverified.
  • useCache: true: without a cache, every pod creation queries the transparency log, which adds latency and creates an external dependency on the critical path.
  • webhookTimeoutSeconds: 30: verification can take a while. If the timeout expires and failurePolicy is Fail, pods cannot be created.
  • Two rules: our images are verified by OIDC identity; the mirrored public ones, with the key of the platform team that reviewed and copied them.

Testing the policy

# A signed image: it passes
kubectl run signed-test \
  --image=registry.rutasnorte.example/bookings-api:2.7.1 \
  -n rutas-norte-pro
pod/signed-test created
# Check that Kyverno replaced the tag with the digest
kubectl get pod signed-test -n rutas-norte-pro \
  -o jsonpath='{.spec.containers[0].image}{"\n"}'
registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

We asked for a tag and a verified digest is running. Exactly what we wanted.

# An unsigned image: it is rejected
kubectl run unsigned-test \
  --image=registry.rutasnorte.example/experiment:0.1.0 \
  -n rutas-norte-pro
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/rutas-norte-pro/unsigned-test was blocked due to the following policies

verify-image-signatures:
  official-pipeline-signature: 'failed to verify image
    registry.rutasnorte.example/experiment:0.1.0: .attestors[0].entries[0].keyless:
    no signatures found'

The alternative: Sigstore's policy-controller

Sigstore has its own admission controller, specialised in just this:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: rutasnorte-signature
spec:
  images:
    - glob: "registry.rutasnorte.example/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://git.rutasnorte.example
            subjectRegExp: "https://git.rutasnorte.example/platform/.*"
      ctlog:
        url: https://rekor.sigstore.dev

And it is enabled by labelling the namespace, just like PSA:

kubectl label namespace rutas-norte-pro policy.sigstore.dev/include=true
Kyverno policy-controller
Scope Every policy in the cluster Signatures and attestations only
Other policies (labels, resources) Yes No
mutateDigest Yes Yes
Complexity One engine for everything One more piece

For Rutas Norte, Kyverno, because we already have it from 08-03 and it avoids adding another admission webhook. The policy-controller makes sense if you do not want a general policy engine.

The indispensable operational warning

This is one of those policies that can halt the platform:

  • If Rekor does not respond and there is no cache, verification fails.
  • If the pipeline's identity changes (a migration to another CI provider), all new signatures stop validating.
  • If failurePolicy: Fail and Kyverno goes down, no pod can be created.

Mandatory mitigations before setting Enforce:

  1. Start with validationFailureAction: Audit for weeks.
  2. useCache: true always.
  3. Kyverno with three replicas, a PDB and anti-affinity (08-03).
  4. A documented procedure for disabling the policy in an emergency, with who can do it and how it is logged.
  5. Exclude kube-system and rutas-norte-sistema.

  1. SBOM and provenance attestations

SBOM: the component inventory

An SBOM (Software Bill of Materials) is the complete list of everything inside an image: every system package, every library, every transitive dependency, with its version and its licence.

# Generate an image's SBOM
syft registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b... \
  -o spdx-json > sbom-bookings-api.json
# See a readable summary
syft registry.rutasnorte.example/bookings-api:2.7.1 -o table | head -15
NAME                    VERSION      TYPE
base-files              12.4         deb
express                 4.19.2       npm
jsonwebtoken            9.0.2        npm
libc6                   2.36-9       deb
node                    22.7.0       binary
pg                      8.12.0       npm
prom-client             15.1.3       npm
...

Standard formats: SPDX (an ISO standard) and CycloneDX (OWASP). Either will do; what matters is having one.

Attaching it to the image as a signed attestation:

cosign attest --yes \
  --predicate sbom-bookings-api.json \
  --type spdxjson \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b...

That way the SBOM travels with the image, signed, and can be retrieved at any time:

cosign download attestation \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b... \
  | jq -r '.payload' | base64 -d | jq '.predicate.packages | length'
412

Why it matters when a serious vulnerability appears

This is the scenario that justifies the whole effort. On an ordinary Tuesday a critical vulnerability is published in a widely used logging library. The question the whole organisation needs to answer in minutes, not days, is:

Is that library in any of our images? In which ones? At which version? Which of them are in production?

Without an SBOM, the answer means pulling every image, inspecting it, reviewing every repository's dependency files and hoping nothing was missed. Days of work, with uncertainty at the end.

With an SBOM:

#!/usr/bin/env bash
# ci/find-component.sh — which images contain this component?
COMPONENT="$1"

for image in $(cat production-image-inventory.txt); do
  result=$(cosign download attestation "$image" 2>/dev/null \
    | jq -r '.payload' | base64 -d \
    | jq -r --arg c "$COMPONENT" \
        '.predicate.packages[]? | select(.name == $c) | "\(.name) \(.versionInfo)"')
  [[ -n "$result" ]] && echo "$image -> $result"
done
./ci/find-component.sh jsonwebtoken
registry.rutasnorte.example/bookings-api@sha256:9f2c1d... -> jsonwebtoken 9.0.2
registry.rutasnorte.example/notifications-worker@sha256:5e8a1b... -> jsonwebtoken 9.0.2

Two minutes. And with certainty, not with hope.

Provenance attestations (SLSA)

SLSA (Supply-chain Levels for Software Artifacts) is a framework that defines levels of assurance about how an artefact was built. A provenance attestation answers:

Question What the attestation contains
Which source code did it come from? The repository and the commit hash
Who built it? The identity of the build system
When? A timestamp
With what parameters? Build arguments, variables
In what environment? The runner image, the tool versions
cosign attest --yes --predicate provenance.json --type slsaprovenance \
  registry.rutasnorte.example/bookings-api@sha256:9f2c1d4e8a7b...

The SLSA levels, in summary:

Level What it requires
L1 The build is automated and generates provenance
L2 The provenance is signed by a hosted build service
L3 The build service is hardened, with isolation between builds

A reasonable target for a platform like Rutas Norte is L2: an automated build on a hosted service, with signed provenance. L3 requires a build service with strong isolation guarantees, which usually means dedicated infrastructure.

And the piece that closes the circle: requiring the attestation at admission.

# Fragment of the Kyverno policy: require an SBOM as well as a signature
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/rutasnorte/*"
          required: true
          mutateDigest: true
          attestations:
            - type: https://spdx.dev/Document
              attestors:
                - entries:
                    - keyless:
                        subject: "https://git.rutasnorte.example/platform/*"
                        issuer: "https://git.rutasnorte.example"
              conditions:
                - all:
                    # Require the SBOM to declare at least one package:
                    # this discards empty or badly generated attestations
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

With this, an image with no SBOM is not deployed in production. The practical consequence is that the inventory never goes out of date, because it is impossible to deploy something that is not inventoried.

  1. Base image policy and periodic rebuilds

The ageing problem

An image built today with node:22.7.0-alpine has no known vulnerabilities. Three months from now, that same image —without anybody having touched it— will have several, because vulnerabilities will have been published in packages it contains.

An image does not degrade: the world changes around it.

This has a consequence many people do not internalise: if an image has been in production for six months without being rebuilt, it accumulates six months of unpatched vulnerabilities, even if the application code is perfect.

Periodic rebuilds

The solution is to rebuild regularly, even if the code has not changed.

# .gitlab-ci.yml — scheduled weekly rebuild
weekly-rebuild:
  stage: publish
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TYPE == "rebuild"
  script:
    # Force pulling the most recent base image within the permitted range
    - docker build --pull --no-cache -t "${IMAGE}:${VERSION}-b${CI_PIPELINE_IID}" .
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMAGE}:${VERSION}-b${CI_PIPELINE_IID}"
    - docker push "${IMAGE}:${VERSION}-b${CI_PIPELINE_IID}"
    - DIGEST=$(crane digest "${IMAGE}:${VERSION}-b${CI_PIPELINE_IID}")
    - cosign sign --yes "${IMAGE}@${DIGEST}"
    # Automatically open a PR updating the digest in the manifests
    - ./ci/open-digest-update-pr.sh "${DIGEST}"

Why rebuilding weekly is cheaper than patching in a hurry

This is the part of the argument you have to be able to defend when somebody asks "why rebuild if we have not changed anything?".

Scheduled weekly rebuild Emergency patching
When it happens Tuesday morning, planned The day the vulnerability comes out, whenever that is
How much changes One week's worth of patches Months of accumulated changes
Risk of breakage Low: a small delta High: many changes at once
Detected in The pipeline's tests Production, in a hurry
Pressure None Maximum
Cost Minutes of CPU in CI Hours of several people's time, with a risk of incident
Does the procedure work? It is tested every week You find out whether it works on the day you need it

That last point is the decisive one. A rebuild procedure that runs every week is tested. One that runs once a year, during a crisis, is untested at exactly the moment it matters most.

Rutas Norte's base image policy

# k8s/policies/allowed-base-images.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: allowed-base-images
  namespace: rutas-norte-sistema
data:
  policy.yaml: |
    # Base images approved for building Rutas Norte images.
    # Quarterly review by the platform team with security.
    approved:
      - pattern: "gcr.io/distroless/*"
        justification: "Minimal surface. Preferred for our own applications."
        owner: platform
      - pattern: "docker.io/library/alpine:3.20*"
        justification: "When a shell or system utilities are needed."
        owner: platform
      - pattern: "docker.io/library/node:22.*-alpine"
        justification: "Build stage for Node.js applications."
        owner: platform
      - pattern: "docker.io/library/postgres:16.*-alpine"
        justification: "Database. Only supported 16.x versions."
        owner: platform
      - pattern: "docker.io/library/redis:7.4*-alpine"
        justification: "Availability cache."
        owner: platform

    forbidden:
      - pattern: "*:latest"
        reason: "Not reproducible."
      - pattern: "docker.io/*/  # user accounts, not official"
        reason: "Unverifiable origin; use official images or build our own."

    rules:
      - "Every base image must be mirrored into registry.rutasnorte.example/external/."
      - "Every base image must be scanned before being mirrored."
      - "Weekly rebuild of all our own images (Tuesday 06:00)."
      - "Major version upgrade of the base: a reviewed change, never automatic."
      - "A base image with no security updates for 6 months is flagged
         for review: it may be abandoned."

And tools that automate the tracking:

Tool What it does
Renovate / Dependabot Opens a PR when there is a new version of the base image or of a dependency
crane digest in CI Detects whether the base tag's content changed
Continuous registry scanning (08-06) Alerts on new vulnerabilities in already published images

  1. Rutas Norte's complete image policy

We bring everything together in the document that governs the platform.

Who can publish

Identity Repository Permission Requirements
ci-rutasnorte rutasnorte/* Push Only from protected branches of the official repository
development (group) dev/* Push For testing; never deployable in pre or pro
platform (group) external/* Push Mirroring public images, after scanning and manual signing
platform (group) Everything Delete With a second factor and a record of the operation
pull-pro rutasnorte/*, external/* Pull only The production nodes' credential

Requirements for entering rutas-norte-pro

An image is only deployed in production if it meets all of these requirements, and each one is verified by a specific mechanism:

# Requirement Verified by
1 It comes from registry.rutasnorte.example The Kyverno mandatory-registry policy (08-03)
2 It is referenced by digest Kyverno's mutateDigest: true
3 It does not use the latest tag A ValidatingAdmissionPolicy (08-03)
4 It is signed by the official pipeline The Kyverno verify-image-signatures policy
5 It has a signed SBOM attached An attestation condition in Kyverno
6 It has a provenance attestation An attestation condition in Kyverno
7 No critical vulnerabilities without a documented exception Trivy in the pipeline (08-06)
8 It declares a numeric non-root user verify-image-user.sh in CI
9 Its base is on the approved list Code review of the Dockerfile
10 Rebuilt within the last 30 days A scheduled job that alerts
11 It has been through rutas-norte-pre The promotion procedure

The complete flow

flowchart TD
    A["Commit on a protected branch"] --> B["CI: multi-stage build<br/>approved base"]
    B --> C["Scan with Trivy<br/>critical = failure"]
    C -->|Fails| X1["Build stopped"]
    C -->|Passes| D["Verify the non-root user"]
    D --> E["Publish to the registry"]
    E --> F["Resolve the digest"]
    F --> G["Sign with Cosign<br/>OIDC identity"]
    G --> H["Generate and attach the SBOM<br/>+ provenance"]
    H --> I["Deploy to rutas-norte-pre"]
    I --> J["Integration tests"]
    J -->|Pass| K["Promotion to rutas-norte-pro"]
    K --> L["Admission: PSA + VAP + Kyverno"]
    L -->|Invalid signature| X2["Pod rejected"]
    L -->|No SBOM| X2
    L -->|Wrong registry| X2
    L -->|All correct| M["Pod running<br/>with a verified digest"]

    style X1 fill:#f9d5d5,stroke:#c33
    style X2 fill:#f9d5d5,stroke:#c33
    style M fill:#d5f9d5,stroke:#3a3

The final bookings-api manifest

# k8s/environments/pro/bookings-api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
  labels:
    app: bookings-api
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
  annotations:
    # Complete traceability of what is running
    image.rutasnorte.example/version: "2.7.1"
    image.rutasnorte.example/commit: "a3f9c1d"
    image.rutasnorte.example/built: "2026-08-04T09:14:22Z"
    image.rutasnorte.example/signed-by: "ci-rutasnorte"
spec:
  replicas: 4
  selector:
    matchLabels:
      app: bookings-api
  template:
    metadata:
      labels:
        app: bookings-api
        app.kubernetes.io/part-of: rutas-norte
        environment: pro
    spec:
      serviceAccountName: bookings-api    # with imagePullSecrets declared
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532                  # matches the Dockerfile's USER
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          # v2.7.1 (a3f9c1d) — verified digest, signature checked at admission
          image: registry.rutasnorte.example/rutasnorte/bookings-api@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
          imagePullPolicy: IfNotPresent   # safe with a digest
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metrics, containerPort: 9090 }
          livenessProbe:
            httpGet: { path: /health, port: http }
            initialDelaySeconds: 10
          readinessProbe:
            httpGet: { path: /ready, port: http }
            periodSeconds: 5
          volumeMounts:
            - { name: temp, mountPath: /tmp }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { memory: 512Mi }
        - name: payments-ambassador
          image: registry.rutasnorte.example/rutasnorte/payments-ambassador@sha256:3c8e1f5a9d2b7046e8c3f1a5d9b2e7c4f8a1d5b9e3c7f2a6d4b8e1c5f9a3d7b2
          imagePullPolicy: IfNotPresent
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 10004
            capabilities: { drop: ["ALL"] }
          resources:
            requests: { cpu: 20m, memory: 32Mi }
            limits:   { memory: 64Mi }
      volumes:
        - name: temp
          emptyDir: { sizeLimit: 64Mi }

This manifest brings together the whole module: the ServiceAccount from 03-06 and 08-01, the securityContext from 08-02, restricted compliance from 08-03, and the verified digest reference from this lesson.

Common Mistakes and Tips

Using latest in any environment that is not a local experiment. It breaks rollbacks, makes deployments irreproducible and can leave replicas on different versions.

Using moving tags such as :2.7 or :16. They are reassigned with every patch. They look like specific versions and they are not.

Believing that imagePullPolicy: Always protects you from a reassigned tag. It guarantees that every replica runs the new content, which is exactly what whoever reassigned it wanted. The solution is the digest.

Declaring USER by name in the Dockerfile. runAsNonRoot: true cannot verify it and the pod does not start, with an error whose cause lies in another file.

Leaving secrets in the image layers. Deleting them in a later RUN does not remove them from the earlier layer. Use RUN --mount=type=secret.

Using npm install instead of npm ci. It can resolve different versions on each build, making the image irreproducible.

Including development dependencies in the final image. Test frameworks and linters have no business in production and are usually half the dependency tree.

Moving to distroless with no debugging plan. There is no shell. You have to master kubectl debug with ephemeral containers before you need it during an incident.

Forgetting the root certificates when using scratch. Every HTTPS connection fails with a certificate verification error.

Signing by tag instead of by digest. It leaves ambiguity about what content was vouched for.

Storing the private signing key in the pipeline. Whoever compromises the CI can sign anything. Use keyless signing with OIDC, or a KMS.

Signing in the pipeline and not verifying in the cluster. A signature with no verification is decoration: whoever can push directly to the registry deploys whatever they like.

Putting the verification policy on Enforce from day one. It can halt the entire platform. Audit for weeks, useCache: true, high availability for the engine and a documented emergency procedure.

Scanning after publishing. During that interval there is a vulnerable image available to deploy.

Giving push credentials to the production nodes. A compromised node can poison the registry the whole platform draws from.

Not rebuilding the images that do not change. They accumulate months of unpatched vulnerabilities. Rebuild weekly.

Golden tip: the question you must be able to answer at any moment is "what exactly is running in production right now, who approved it and what does it contain?". The digest for the what, the signature for the who, the SBOM for the contents. If one of the three is missing, you have a gap.

Exercises

Exercise 1: harden a Dockerfile

This is notifications-worker's current Dockerfile:

FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
  1. List all the security and quality problems it has.
  2. Rewrite it with a multi-stage build, a minimal base and a non-root user.
  3. Write the commands that would verify that the resulting image meets the Rutas Norte policy requirements.

Exercise 2: from a tag to a verified digest

The web-store Deployment in production uses:

image: registry.rutasnorte.example/rutasnorte/web-store:1.9
imagePullPolicy: Always
  1. Explain the three concrete risks of this configuration.
  2. Write the commands to obtain the digest, verify its signature and inspect its SBOM.
  3. Write the corrected manifest fragment.
  4. How would you check that the three replicas are running the same content?

Exercise 3: an admission policy with signature verification for a new component

Rutas Norte adds sms-gateway, a component that sends SMS notices about timetable changes. It is built in a different repository (https://git.rutasnorte.example/communications/sms-gateway) by a different team.

Write the Kyverno rule that has to be added to the verify-image-signatures policy so that:

  • Only images from registry.rutasnorte.example/rutasnorte/sms-gateway* are accepted.
  • Signed from the communications/sms-gateway repository (and not from another one).
  • With an attached SBOM containing at least one package.
  • Replacing the tag with the verified digest.

Also state how you would roll it out without risking blocking legitimate deployments, and what you would check before moving it to Enforce.

Solutions

Solution 1

1. Problems with the original Dockerfile:

# Problem Consequence
1 FROM node:22 with no patch version Not reproducible: 22 is reassigned constantly
2 A full Debian base image (~1.1 GB) Hundreds of unnecessary packages, dozens of vulnerabilities
3 No multi-stage build Compilers and a package manager in the final image
4 COPY . . before installing It breaks the layer cache and can copy .env, .git or keys
5 No .dockerignore It makes the previous problem worse
6 npm install instead of npm ci It can resolve different versions on each build
7 It includes development dependencies A test framework and linters in production
8 No USER: it runs as root It breaches runAsNonRoot; the pod does not even start under the PSA from 08-03
9 CMD npm start in shell form Process 1 is sh, which does not forward signals: the pod does not terminate cleanly and takes 30 s to die
10 No OCI labels No traceability of the origin
11 npm caches in the image Unnecessary size

Problem 9 is subtle and very real: with the shell form, PID 1 is the shell, which does not propagate SIGTERM to the Node process. Kubernetes waits out the full terminationGracePeriodSeconds and then kills the pod with SIGKILL, cutting off the notifications in flight.

2. Rewritten Dockerfile:

# notifications-worker Dockerfile

# ---------- Stage 1: build ----------
FROM node:22.7.0-alpine AS builder

WORKDIR /build

# Manifests first: it takes advantage of the layer cache
COPY package.json package-lock.json ./

# npm ci: reproducible; --omit=dev: no development dependencies
RUN npm ci --omit=dev && npm cache clean --force

COPY src/ ./src/
RUN npm run build

# ---------- Stage 2: final image ----------
FROM gcr.io/distroless/nodejs22-debian12:nonroot

WORKDIR /app

COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /build/dist ./dist

# Non-root user, BY NUMBER (distroless's nonroot)
USER 65532

EXPOSE 3000

LABEL org.opencontainers.image.title="notifications-worker" \
      org.opencontainers.image.description="Sending booking confirmation emails" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/platform/notifications-worker" \
      org.opencontainers.image.licenses="Proprietary"

# Exec form: the Node process is PID 1 and receives SIGTERM directly
CMD ["dist/worker.js"]

And the .dockerignore, which solves problems 4 and 5:

# .dockerignore
.git
.gitignore
.env
.env.*
node_modules
npm-debug.log
Dockerfile
.dockerignore
k8s/
docs/
*.md
.vscode/
coverage/
test/

.env and .git on that list are not optional: COPY . . with no .dockerignore can put development credentials and the repository's entire history inside the image, where they stay forever.

3. Verification:

IMAGE=registry.rutasnorte.example/rutasnorte/notifications-worker:3.2.0

# a) A non-root, numeric user
docker inspect "$IMAGE" --format '{{.Config.User}}'
65532
# b) Size (compared with the original)
docker images "$IMAGE" --format '{{.Size}}'
158MB
# c) No shell (it confirms it is distroless)
docker run --rm --entrypoint sh "$IMAGE" -c 'echo hello' 2>&1 | head -1
docker: Error response from daemon: failed to create task for container:
exec: "sh": executable file not found in $PATH
# d) No serious vulnerabilities
trivy image --severity HIGH,CRITICAL --exit-code 1 "$IMAGE"
registry.rutasnorte.example/rutasnorte/notifications-worker:3.2.0 (debian 12.7)
Total: 0 (HIGH: 0, CRITICAL: 0)
# e) No secrets in the layers
trivy image --scanners secret "$IMAGE"
Total: 0
# f) A valid signature
cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/platform/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "$IMAGE" >/dev/null && echo "Signature verified"
Signature verified
# g) SBOM present
cosign download attestation "$IMAGE" | jq -r '.payload' | base64 -d \
  | jq '.predicate.packages | length'
187

From 412 packages in the original version to 187: less than half the surface to watch.

Solution 2

1. The three risks:

Risk Detail
The moving :1.9 tag It is a minor-version tag: it is reassigned with every patch (1.9.1, 1.9.2...). The content changes without anybody modifying the manifest or going through code review
No content verification Nothing guarantees that the content of :1.9 is the one that was approved. If somebody with registry access reassigns it, the cluster runs the new thing
imagePullPolicy: Always as false protection It guarantees that every replica runs the tag's current content. Faced with a malicious reassignment, that spreads the problem across the whole platform instead of containing it. What is more, if the registry does not respond, the pods do not start

2. Commands:

IMG=registry.rutasnorte.example/rutasnorte/web-store

# a) Obtain the tag's current digest
crane digest "$IMG:1.9"
sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
# b) Verify the signature (by digest, not by tag)
DIGEST=$(crane digest "$IMG:1.9")
cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/platform/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "${IMG}@${DIGEST}" | jq -r '.[0].optional'
{
  "commit": "e7b4c2a",
  "built-by": "ci-rutasnorte",
  "date": "2026-07-28T14:02:11Z",
  "Bundle": { "SignedEntryTimestamp": "..." }
}

A note: the date says 2026-07-28, nine days ago. Within the policy's 30-day window, but worth bearing in mind.

# c) Inspect the SBOM
cosign download attestation "${IMG}@${DIGEST}" \
  | jq -r '.payload' | base64 -d \
  | jq -r '.predicate.packages[] | "\(.name) \(.versionInfo)"' | head -10
alpine-baselayout 3.6.5-r0
busybox 1.36.1-r29
ca-certificates 20240705-r0
libcrypto3 3.3.1-r3
libssl3 3.3.1-r3
nginx 1.27.1-r0
pcre2 10.43-r0
zlib 1.3.1-r1

3. Corrected manifest:

# k8s/environments/pro/web-store.yaml (fragment)
spec:
  template:
    spec:
      containers:
        - name: nginx
          # web-store v1.9.3 (commit e7b4c2a, built 2026-07-28)
          # Signature verified: ci-rutasnorte / platform/web-store
          image: registry.rutasnorte.example/rutasnorte/web-store@sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
          imagePullPolicy: IfNotPresent   # safe with a digest and independent of the registry

And with Kustomize (10-04), so as not to write the digest by hand in every environment:

# k8s/environments/pro/kustomization.yaml
images:
  - name: registry.rutasnorte.example/rutasnorte/web-store
    newTag: "1.9.3"
    digest: "sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2"

4. Checking that the three replicas run the same thing:

kubectl get pods -n rutas-norte-pro -l app=web-store \
  -o json | jq -r '.items[] | "\(.metadata.name)  \(.status.containerStatuses[0].imageID)"'
web-store-6f8d9c4b7-k2m4x  registry.rutasnorte.example/rutasnorte/web-store@sha256:7d3b2f8e1a6c...
web-store-6f8d9c4b7-p3n8v  registry.rutasnorte.example/rutasnorte/web-store@sha256:7d3b2f8e1a6c...
web-store-6f8d9c4b7-x9q2z  registry.rutasnorte.example/rutasnorte/web-store@sha256:7d3b2f8e1a6c...

And the version that returns a single value if everything is right:

kubectl get pods -n rutas-norte-pro -l app=web-store \
  -o jsonpath='{range .items[*]}{.status.containerStatuses[0].imageID}{"\n"}{end}' \
  | sort -u | wc -l
1

A 1 means every replica is running the same content. Any higher number is an incident to investigate immediately: it means there are replicas with different contents under the same name.

Solution 3

# A rule to be ADDED to spec.rules of the verify-image-signatures policy
    - name: sms-gateway-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/rutasnorte/sms-gateway*"
          mutateDigest: true      # replaces the tag with the verified digest
          required: true          # with no verifiable signature, it is rejected
          useCache: true          # avoids querying Rekor on every pod creation
          attestors:
            - count: 1
              entries:
                - keyless:
                    # The EXACT identity of the communications repository.
                    # No trailing wildcard: only this repository, not
                    # any other one in the communications group.
                    subject: "https://git.rutasnorte.example/communications/sms-gateway"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev
          # Require an SBOM signed by the same identity
          attestations:
            - type: https://spdx.dev/Document
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        subject: "https://git.rutasnorte.example/communications/sms-gateway"
                        issuer: "https://git.rutasnorte.example"
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

Decisions worth highlighting:

  • A subject with no wildcard. Writing communications/* would allow any repository in that group to sign sms-gateway images. By pinning the exact path, only that specific repository's pipeline can vouch for these images. It is the same least-privilege principle from 08-01, applied to signing.
  • A separate rule, not an extension of the existing one. We could have added communications/* to the main rule's subject, but that would let the communications team sign bookings-api images. Each repository signs its own.
  • The sms-gateway* pattern with an asterisk covers variants such as sms-gateway-worker if the component grows. If you prefer to be strict, drop the asterisk.

Risk-free rollout:

# 1. Apply the rule in Audit mode (it does not block)
#    A copy is edited with validationFailureAction: Audit
sed 's/validationFailureAction: Enforce/validationFailureAction: Audit/' \
  k8s/policies/verify-image-signatures.yaml | kubectl apply -f -

# 2. Deploy sms-gateway in rutas-norte-pre and check the report
kubectl apply -f k8s/environments/pre/sms-gateway.yaml

kubectl get policyreport -n rutas-norte-pre -o json | jq -r '
  .items[].results[]
  | select(.policy == "verify-image-signatures")
  | "\(.rule): \(.result) - \(.resources[0].name)\n    \(.message // "")"'
sms-gateway-signature: pass - sms-gateway-7d4c8b9f6-m2k5x
# 3. Manually verify that the signature is the expected one
IMG=registry.rutasnorte.example/rutasnorte/sms-gateway
DIGEST=$(crane digest "$IMG:1.0.0")

cosign verify \
  --certificate-identity "https://git.rutasnorte.example/communications/sms-gateway" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "${IMG}@${DIGEST}" | jq -r '.[0].optional.Issuer, .[0].optional.Subject'
# 4. Check that an UNSIGNED image would be rejected (a negative test)
kubectl run unsigned-test \
  --image=registry.rutasnorte.example/rutasnorte/sms-gateway:experimental \
  -n rutas-norte-pre --dry-run=server
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
... sms-gateway-signature: 'failed to verify image ... no signatures found'
# 5. Only then, move to Enforce
kubectl apply -f k8s/policies/verify-image-signatures.yaml

What to check before Enforce:

# Check Why
1 The policy report has no fail in pre or in pro A fail under Enforce is a blocked deployment
2 The communications/sms-gateway pipeline signs correctly If it does not sign, all its deployments will be blocked
3 The SBOM is generated and attached in that pipeline The attestation condition requires it
4 useCache: true is enabled Without a cache, every pod queries Rekor: latency and an external dependency
5 The negative test rejects an unsigned image It confirms the policy really works
6 Kyverno has 3 replicas, a PDB and anti-affinity The engine is a dependency of the apiserver (08-03)
7 A documented emergency procedure exists Who can disable the policy, how, and how it is logged
8 rutas-norte-sistema and kube-system are excluded The infrastructure components use external images

Point 7 deserves emphasis. A signature verification policy on Enforce can prevent any deployment if Rekor does not respond or if the pipeline's identity changes. There must be a written procedure, with names, for disabling it within minutes, and that procedure must have been rehearsed. A security measure that can paralyse the platform and has no emergency exit is an operational risk, not a protection.

Conclusion

We have protected the supply chain of Rutas Norte's images:

  • An image can be poisoned at five points: the base image, the dependencies, the build process, the registry and the pull. Each one needs its defence, and the defences reinforce one another: the digest guarantees the content, the signature guarantees the endorsement, the SBOM guarantees the inventory.
  • Minimal images with multi-stage builds: scratch for static binaries, distroless for languages with a runtime, alpine when you need an operating system. The cost is debugging, which is solved with ephemeral containers (kubectl debug).
  • A numeric non-root user in the Dockerfile, reinforced by the manifest's runAsNonRoot (08-02): each one covers the other's gap.
  • latest is unacceptable and so are moving tags such as :1.9. An immutable tag is acceptable; the digest is the only cryptographically reproducible reference.
  • imagePullPolicy: Always does not protect you from a reassigned tag: it guarantees that every replica runs the new content. With a digest, IfNotPresent is safe and does not depend on the registry responding.
  • The private registry is the single control point: imagePullSecrets on the ServiceAccount, a strict separation between pushing and pulling, server-side tag immutability, and mirroring the public images you use.
  • Cosign signs the images, preferably keyless with an OIDC identity, which removes the problem of looking after the private key and lets you verify who signed and from where.
  • Signing is not enough: you have to verify in the cluster. The Kyverno policy with verifyImages and mutateDigest: true rejects what is unsigned and replaces the tag with the verified digest.
  • The SBOM turns "do we have that vulnerable library?" into a two-minute query instead of days of investigation. Provenance attestations (SLSA) answer where the image came from and who built it.
  • Rebuilding weekly is cheaper than patching in a hurry: a smaller delta, less risk, and above all a tested procedure rather than one that gets its first outing on the day of the crisis.
  • Rutas Norte's complete policy establishes who publishes where and the eleven requirements an image must meet to enter rutas-norte-pro, each one verified by a specific mechanism.

The platform is now protected in all four dimensions: who can do what, what a container can do, what talks to what, and what exactly runs.

But two questions remain unanswered, and they are precisely the ones any audit asks. The first is "who did what?": if tomorrow we discover that the Secret with the bookings-postgres credentials was read, or that a Deployment vanished, or that a ServiceAccount did something odd at three in the morning, right now we have no way of knowing. We have put doors in place, but we keep no record of who goes through them. The second is "what holes do I have right now?": we know how to scan an image before publishing it, but the ones that have been in production for months accumulate new vulnerabilities every week, and nobody is looking at them.

The module's last lesson, 08-06, Auditing, Scanning and Vulnerability Management, answers both: the apiserver audit log and how to query it to answer concrete questions, continuous scanning with Trivy inside and outside the cluster, assessment against the CIS standards with kube-bench, runtime detection with Falco integrated with module 7's Alertmanager, and —what is genuinely missing in most teams— vulnerability management as a process, with deadlines, owners and exceptions that carry an expiry date.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved