Todo lo que llevamos construido en este módulo protege el clúster en tiempo de ejecución. RBAC decide quién puede hacer qué (08-01). El securityContext limita lo que puede hacer un contenedor (08-02). Las políticas de admisión impiden desplegar algo que no cumpla las reglas (08-03). Y las políticas de red controlan qué habla con qué y qué puede salir (08-04).

Pero todo eso descansa sobre una suposición que nunca hemos cuestionado: que las imágenes que ejecutamos son las que creemos.

Y esa suposición es frágil. registry.rutasnorte.example/api-reservas:2.7.1 es una etiqueta, y una etiqueta se puede reasignar: la imagen que se descargó ayer puede no ser la de hoy. La imagen base sobre la que se construyó contiene cientos de paquetes que nadie ha revisado. Las dependencias se instalaron desde repositorios públicos durante la construcción. Y hoy por hoy, nada impide que alguien con acceso al registro suba una imagen modificada con el mismo nombre y que el clúster la ejecute sin rechistar, con todos nuestros securityContext perfectamente aplicados... a un contenedor que hace algo distinto de lo que creemos.

Esta lección se ocupa de la cadena de suministro de las imágenes: de dónde vienen, cómo se construyen, cómo se identifican sin ambigüedad, cómo se firman y cómo se consigue que el clúster rechace todo lo que no esté verificado.

Advertencia importante. La política de imágenes de una plataforma de producción debe diseñarla y revisarla un profesional de seguridad. Cuando las imágenes procesan datos personales —api-reservas, postgres-reservas e informes-ocupacion en Rutas Norte— el responsable de cumplimiento normativo debe conocer qué controles existen sobre el software que trata esos datos, porque una dependencia comprometida es una vía directa de acceso a ellos. El enfoque de esta lección es exclusivamente defensivo: entender dónde se puede envenenar una imagen para cerrar cada punto y detectar los intentos.

Contenido

  1. La cadena de suministro de una imagen
  2. Construir imágenes mínimas
  3. Usuario no root en el Dockerfile
  4. Identificar la imagen sin ambigüedad: etiquetas y digests
  5. imagePullPolicy y la trampa de la etiqueta reutilizada
  6. El registro privado
  7. Firma y verificación con Cosign
  8. Verificar en el clúster con una política de admisión
  9. SBOM y atestaciones de procedencia
  10. Política de imágenes base y actualización periódica
  11. La política completa de imágenes de Rutas Norte
  12. Errores comunes y consejos
  13. Ejercicios
  14. Conclusión

  1. La cadena de suministro de una imagen

Una imagen de contenedor no aparece de la nada. Es el resultado de una cadena de pasos, y cada eslabón es un punto donde se puede introducir algo indeseado.

flowchart TB
    A["1. Imagen base<br/>node:22-alpine"] --> B["2. Dependencias<br/>npm install, apt-get"]
    B --> C["3. Proceso de construcción<br/>docker build en la CI"]
    C --> D["4. Registro<br/>registry.rutasnorte.example"]
    D --> E["5. Descarga<br/>el kubelet baja la imagen"]
    E --> F["6. Ejecución<br/>el contenedor corre en el nodo"]

    A -.->|"imagen base comprometida<br/>o abandonada"| X1["Riesgo"]
    B -.->|"paquete malicioso,<br/>typosquatting,<br/>dependencia abandonada"| X2["Riesgo"]
    C -.->|"CI comprometida,<br/>secretos en capas,<br/>construcción no reproducible"| X3["Riesgo"]
    D -.->|"credenciales robadas,<br/>etiqueta reasignada"| X4["Riesgo"]
    E -.->|"etiqueta mutable,<br/>sin verificación"| X5["Riesgo"]

    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

Los cinco puntos de riesgo

Eslabón Cómo se compromete Defensa (apartado)
Imagen base Una imagen pública abandonada, con vulnerabilidades sin parchear, o de un autor desconocido Imágenes mínimas y de origen conocido (2), política de bases (10)
Dependencias Un paquete de npm o PyPI con código malicioso; un nombre parecido al legítimo Ficheros de bloqueo, SBOM (9), escaneo (08-06)
Construcción La CI comprometida inyecta algo; secretos que quedan en una capa Procedencia (9), construcción multietapa (2)
Registro Credenciales robadas: alguien sube una imagen con el mismo nombre Control de acceso, etiquetas inmutables (6), firma (7)
Descarga La etiqueta apunta ahora a otra imagen Digest (4), verificación de firma en admisión (8)

Fíjate en que las defensas se refuerzan entre sí. El digest garantiza que descargas exactamente el contenido que esperas, pero no dice si ese contenido es bueno. La firma dice que alguien de confianza lo aprobó, pero no si contiene vulnerabilidades. El escaneo dice qué vulnerabilidades tiene, pero no si alguien lo modificó. Hacen falta todas.

Por qué esto importa concretamente en Rutas Norte

Piensa en api-reservas. Es una aplicación Node.js con quizá 400 dependencias transitivas: paquetes que instala un paquete que instala un paquete que pediste tú. Cada una es código que se ejecutará con acceso a la conexión de postgres-reservas, es decir, con acceso a la tabla de clientes.

No hace falta ningún fallo del kernel ni ninguna escalada de privilegios. Una dependencia comprometida ya está dentro, con los permisos legítimos de la aplicación. Ni el securityContext más restrictivo lo impide, porque no está haciendo nada prohibido: está usando la conexión a la base de datos que la aplicación necesita.

Esa es la razón de ser de esta lección.

  1. Construir imágenes mínimas

El principio es sencillo: lo que no está en la imagen no puede tener vulnerabilidades ni ser usado por nadie.

El problema de las imágenes voluminosas

Una imagen basada en una distribución completa trae cientos de paquetes que tu aplicación no usa: compiladores, gestores de paquetes, curl, wget, un intérprete de órdenes completo, herramientas de red. Cada uno es superficie de ataque y una fuente potencial de alertas de vulnerabilidad que hay que gestionar.

Construcción multietapa

Es la técnica fundamental: usar una imagen con herramientas para construir, y copiar solo el resultado a una imagen limpia.

# Dockerfile de api-reservas — construcción multietapa

# ---------- Etapa 1: construcción ----------
# Aquí sí hay compiladores, gestores de paquetes y herramientas.
# Esta etapa NO acaba en la imagen final.
FROM node:22.7.0-alpine AS constructor

WORKDIR /construccion

# Copiar SOLO los manifiestos de dependencias primero:
# así esta capa se reutiliza mientras no cambien las dependencias.
COPY package.json package-lock.json ./

# npm ci (no npm install) instala EXACTAMENTE lo que dice el fichero
# de bloqueo. Es reproducible y no actualiza nada por su cuenta.
RUN npm ci --omit=dev

COPY src/ ./src/
RUN npm run build

# Eliminar cachés que no aportan nada a la ejecución
RUN npm cache clean --force

# ---------- Etapa 2: imagen final ----------
# distroless: solo el runtime de Node.js y las librerías del sistema
# imprescindibles. Sin shell, sin gestor de paquetes, sin utilidades.
FROM gcr.io/distroless/nodejs22-debian12:nonroot

WORKDIR /app

# Copiar SOLO lo necesario desde la etapa de construcción
COPY --from=constructor --chown=nonroot:nonroot /construccion/node_modules ./node_modules
COPY --from=constructor --chown=nonroot:nonroot /construccion/dist ./dist

# Usuario no root, declarado por NÚMERO (ver apartado 3)
USER 65532

EXPOSE 8080

# Etiquetas OCI estándar: documentan el origen de la imagen
LABEL org.opencontainers.image.title="api-reservas" \
      org.opencontainers.image.description="API de reservas de Rutas Norte" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/plataforma/api-reservas" \
      org.opencontainers.image.licenses="Proprietary"

# distroless no tiene shell, así que la forma exec es obligatoria
CMD ["dist/servidor.js"]

Puntos a destacar:

  • npm ci en lugar de npm install. ci instala exactamente lo que dice package-lock.json y falla si el fichero de bloqueo no está sincronizado. install puede resolver versiones distintas en cada construcción, lo que hace que dos construcciones del mismo código produzcan imágenes diferentes. Reproducibilidad y seguridad van de la mano.
  • Copiar los manifiestos antes que el código. Aprovecha la caché de capas: mientras las dependencias no cambien, no se reinstalan.
  • --omit=dev: las dependencias de desarrollo (marcos de pruebas, linters) no deben acabar en producción. Suelen ser la mitad del árbol.
  • --chown en el COPY: los ficheros quedan con el propietario correcto sin necesidad de un RUN chown que duplicaría el tamaño de la capa.
  • Etiquetas OCI: dejan rastro de dónde viene la imagen. Cuando dentro de un año alguien encuentre esta imagen en el registro, sabrá de qué repositorio salió.

Un detalle importante que se olvida constantemente: los secretos usados durante la construcción quedan en las capas aunque los borres después. Si haces RUN echo $TOKEN > /tmp/token && ... && rm /tmp/token, el token sigue estando en la capa intermedia y cualquiera que descargue la imagen puede extraerlo. La solución correcta es RUN --mount=type=secret, que monta el secreto durante la orden sin persistirlo:

# Forma correcta de usar un secreto durante la construcción
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci --omit=dev

Comparación real de imágenes base

Base Tamaño típico Paquetes del sistema Shell Gestor de paquetes Depuración Vulnerabilidades habituales
node:22 (Debian completa) ~1,1 GB ~400 Sí (apt) Muy fácil Decenas
node:22-slim ~250 MB ~120 Fácil Bastantes menos
node:22-alpine ~180 MB ~30 Sí (busybox) Sí (apk) Fácil Pocas
distroless/nodejs22 ~150 MB ~10 No No Difícil Muy pocas
scratch (binario estático) ~15 MB 0 No No Muy difícil Prácticamente ninguna

Y con nginx, el caso de tienda-web:

Imagen Tamaño Notas
nginx:1.27 ~190 MB Corre como root para abrir el puerto 80
nginx:1.27-alpine ~50 MB Sigue arrancando como root
nginxinc/nginx-unprivileged:1.27-alpine ~50 MB Sin root, puerto 8080: la que adoptamos en 08-02

La compensación honesta: depuración

Esta es la parte que los artículos entusiastas no cuentan. Con distroless o scratch:

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

No hay shell. Eso es exactamente lo que queremos desde el punto de vista de la seguridad —quien consiga ejecutar código no tiene herramientas— pero significa que las técnicas de diagnóstico habituales no funcionan.

La solución es la que vimos en 07-06: los contenedores efímeros.

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

El contenedor efímero trae las herramientas y, con --target, comparte el namespace de procesos del contenedor objetivo. Puedes inspeccionar el proceso, su sistema de ficheros a través de /proc/1/root y su red, sin que la imagen de producción contenga ni una sola utilidad.

Nota de seguridad importante: kubectl debug requiere el subrecurso pods/ephemeralcontainers, que en 08-01 clasificamos como de riesgo muy alto y no concedimos ni a soporte ni a desarrollo. Es una operación de plataforma. Y en 08-03 la política de registro obligatorio de Kyverno incluía ephemeralContainers precisamente para que nadie pudiera inyectar una imagen arbitraria por esta vía.

Criterio de elección para Rutas Norte

Componente Base elegida Razón
api-reservas distroless/nodejs22 Aplicación propia, expuesta a internet, procesa datos personales. Máxima reducción de superficie
tienda-web nginx-unprivileged:alpine Servidor web estándar; alpine es suficiente y facilita ajustar la configuración
worker-notificaciones distroless/nodejs22 Igual que la API; sin acceso interactivo necesario
informes-ocupacion distroless/nodejs22 CronJob que lee datos personales
postgres-reservas postgres:16.4-alpine (oficial) Base de datos: no la construimos nosotros. Se usa la oficial, replicada en nuestro registro
redis-cache redis:7.4-alpine (oficial) Igual
utilidades (depuración) alpine con herramientas Solo para contenedores efímeros; nunca en un pod desplegado

Un principio útil: scratch para binarios estáticos (Go, Rust), distroless para lenguajes con runtime (Node.js, Python, Java), alpine cuando necesites un poco de sistema operativo, y una distribución completa solo si tienes una razón concreta que puedas escribir.

  1. Usuario no root en el Dockerfile

En 08-02 configuramos runAsNonRoot: true y runAsUser en los manifiestos. Aquí hacemos lo mismo desde el otro lado: declarar el usuario en la imagen.

Por qué las dos cosas

# En el Dockerfile
USER 65532
# En el manifiesto
securityContext:
  runAsNonRoot: true
  runAsUser: 65532

Parece redundante y no lo es. Se refuerzan mutuamente:

Escenario Solo USER en el Dockerfile Solo runAsUser en el manifiesto Ambos
La imagen se ejecuta fuera de Kubernetes (docker run, pruebas locales) Protegido Sin protección Protegido
Alguien despliega la imagen sin securityContext Protegido Sin protección Protegido
Alguien reconstruye la imagen olvidando el USER Sin protección Protegido Protegido
Kubernetes verifica antes de arrancar No puede: no sabe qué usuario trae

El caso más valioso es el segundo: si mañana alguien copia esta imagen a un manifiesto nuevo sin el securityContext completo, el USER del Dockerfile sigue protegiendo. Y en el otro sentido, runAsNonRoot: true en el manifiesto detecta si alguien reconstruyó la imagen sin el USER y rechaza el pod en lugar de arrancarlo como root en silencio.

Declarar el usuario por NÚMERO

Esto es crítico y ya lo adelantamos en 08-02:

# MAL: nombre de usuario
RUN adduser -D -u 10001 aplicacion
USER aplicacion

# BIEN: número
RUN adduser -D -u 10001 aplicacion
USER 10001

Con un nombre, Kubernetes no puede verificar runAsNonRoot: true, porque para resolverlo tendría que leer el /etc/passwd de dentro de la imagen antes de arrancarla:

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

El pod no arranca. Un fallo desconcertante cuya causa está en el Dockerfile, no en el manifiesto.

Crear un usuario en una imagen sin gestor de usuarios

En distroless no hay adduser. Las imágenes con el sufijo :nonroot ya traen el usuario 65532 creado, que es lo más cómodo. Para scratch, se copia un /etc/passwd fabricado en la etapa de construcción:

FROM golang:1.23-alpine AS constructor
WORKDIR /construccion
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Binario estático: sin dependencias del sistema
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o exportador ./cmd/exportador

# Fabricar un /etc/passwd mínimo con un solo usuario
RUN echo "exportador:x:10005:10005::/:/sbin/nologin" > /passwd-minimo

FROM scratch
# Certificados raíz: sin ellos no se puede hacer HTTPS
COPY --from=constructor /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=constructor /passwd-minimo /etc/passwd
COPY --from=constructor /construccion/exportador /exportador
USER 10005
ENTRYPOINT ["/exportador"]

La imagen resultante contiene tres ficheros: el binario, los certificados y el /etc/passwd. Nada más. No hay shell que abrir, no hay curl que usar, no hay gestor de paquetes que instale nada. Es la superficie de ataque más pequeña posible.

Un detalle que sorprende: sin los certificados raíz, cualquier conexión HTTPS falla con un error de verificación de certificado. Es el problema más habitual al pasar a scratch.

Comprobación

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

Con distroless esto falla porque no hay id. La alternativa es inspeccionar los metadatos:

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

Y una comprobación automatizable para la canalización:

#!/usr/bin/env bash
# ci/verificar-usuario-imagen.sh
IMAGEN="$1"
usuario=$(docker inspect "$IMAGEN" --format '{{.Config.User}}')

if [[ -z "$usuario" || "$usuario" == "root" || "$usuario" == "0" ]]; then
  echo "FALLO: $IMAGEN se ejecuta como root (USER='$usuario')"
  exit 1
fi
if ! [[ "$usuario" =~ ^[0-9]+$ ]]; then
  echo "FALLO: $IMAGEN declara el usuario por nombre ('$usuario'), no por número."
  echo "       runAsNonRoot no podrá verificarlo y el pod no arrancará."
  exit 1
fi
echo "OK: $IMAGEN se ejecuta como UID $usuario"

  1. Identificar la imagen sin ambigüedad: etiquetas y digests

Esta es, probablemente, la parte con más consecuencias prácticas de la lección.

Por qué latest es inaceptable

image: registry.rutasnorte.example/api-reservas:latest   # NUNCA

latest no significa "la última": es simplemente la etiqueta que se usa por defecto cuando no especificas ninguna. No hay ninguna garantía de que apunte a nada en concreto.

Los problemas, uno a uno:

Problema Consecuencia
No sabes qué versión corre Imposible correlacionar un error con una versión del código
Réplicas distintas pueden tener versiones distintas Si una réplica se recrea, descarga lo que haya ahora. Puedes tener tres réplicas con tres versiones
Los rollbacks no funcionan kubectl rollout undo (02-04) revierte al Deployment anterior, cuya imagen era... latest
No es reproducible El mismo manifiesto aplicado hoy y mañana da resultados distintos
No hay revisión de cambios El contenido cambia sin que se modifique ningún fichero de Git

Ese segundo punto es especialmente insidioso porque produce incidencias imposibles de diagnosticar: la mitad de las peticiones funcionan y la otra mitad no, según a qué réplica lleguen.

En 08-03 escribimos una ValidatingAdmissionPolicy que prohíbe latest a nivel de clúster. Esa política es la que convierte esta recomendación en una garantía.

Etiquetas inmutables

El siguiente escalón es usar etiquetas que, por convención y por configuración del registro, nunca se reasignan:

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

Esquemas habituales:

Esquema Ejemplo Ventajas
Versión semántica 2.7.1 Legible; comunica el alcance del cambio
Hash del commit a3f9c1d Trazabilidad directa al código
Combinado 2.7.1-a3f9c1d Lo mejor de ambos: la recomendación
Fecha y construcción 2026.08.06-142 Útil si no hay versionado formal

La clave es la palabra inmutable: una vez publicada 2.7.1, esa etiqueta nunca se reasigna a otro contenido. Si hay que corregir algo, se publica 2.7.2. Muchos registros permiten aplicar esa regla del lado del servidor (apartado 6), y entonces deja de ser una convención para ser una garantía.

El digest: la única referencia realmente reproducible

Incluso con etiquetas inmutables por convención, la etiqueta es un puntero mutable: si alguien con acceso al registro la reasigna, el clúster descargará otra cosa.

El digest es el hash SHA-256 del manifiesto de la imagen. Es criptográficamente inmutable: si el contenido cambia, el digest cambia. No se puede reasignar porque no es un nombre, es una huella del contenido.

image: registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
Referencia ¿Puede cambiar el contenido? Legible Recomendación
imagen:latest Sí, en cualquier momento Nunca
imagen:2.7 (se reasigna en cada parche) Evitar en producción
imagen:2.7.1 Solo si alguien la reasigna Aceptable con inmutabilidad del registro
imagen@sha256:... Imposible No La correcta para producción

La objeción práctica es evidente: un digest es ilegible. La solución es escribir ambos:

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

O, en la práctica moderna, dejar que una herramienta lo gestione. Kustomize (10-04) resuelve el digest automáticamente:

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

Y herramientas como Renovate o Dependabot actualizan los digests con una pull request cuando hay versiones nuevas, con lo que el cambio de imagen pasa por revisión de código como cualquier otro cambio. Ese es exactamente el objetivo.

Obtener el digest

# De una imagen en el registro, sin descargarla
crane digest registry.rutasnorte.example/api-reservas:2.7.1
sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# De la imagen que está corriendo ahora mismo en el clúster
kubectl get pods -n rutas-norte-pro -l app=api-reservas \
  -o jsonpath='{.items[*].status.containerStatuses[*].imageID}{"\n"}'
registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

Esa segunda orden es oro puro para investigar un incidente: te dice exactamente qué contenido se está ejecutando, no qué etiqueta se pidió. Si el digest no coincide con el que esperabas, tienes un problema que investigar urgentemente.

Una comprobación muy útil: verificar que todas las réplicas corren el mismo digest.

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

Dos líneas para dos contenedores. Si aparecieran tres líneas para api, hay réplicas con versiones distintas.

  1. imagePullPolicy y la trampa de la etiqueta reutilizada

imagePullPolicy determina cuándo el kubelet descarga la imagen del registro.

Valor Comportamiento Implicación de seguridad
Always Consulta el registro en cada arranque de contenedor Detecta la reasignación de etiquetas; requiere el registro disponible
IfNotPresent Usa la copia local del nodo si existe Un nodo puede quedarse con una versión antigua indefinidamente
Never Solo usa la copia local; falla si no está Solo para desarrollo local con imágenes precargadas

Los valores por defecto, que sorprenden

Si no especificas imagePullPolicy:

Referencia de la imagen Valor por defecto
Etiqueta :latest Always
Cualquier otra etiqueta IfNotPresent
Digest (@sha256:...) IfNotPresent

Ese comportamiento tiene una lógica: con un digest, IfNotPresent es perfectamente seguro porque la copia local es necesariamente el contenido correcto (si el digest coincide, el contenido coincide).

La trampa de la etiqueta reutilizada

Aquí está el escenario que hay que entender:

  1. Se despliega api-reservas:2.7.1 con imagePullPolicy: IfNotPresent.
  2. Los nodos A, B y C descargan la imagen y la guardan en caché.
  3. Alguien reasigna la etiqueta 2.7.1 en el registro a un contenido distinto.
  4. Un pod se recrea en el nodo D, que no tenía la imagen: descarga la nueva.
  5. Los pods de A, B y C siguen ejecutando la antigua.

Resultado: réplicas del mismo Deployment ejecutando contenidos distintos, con el mismo nombre de imagen. Y ninguna herramienta de Kubernetes te lo va a decir: kubectl get pods muestra la misma imagen en todos.

Solo mirando el imageID (el digest efectivo) se ve la discrepancia. Por eso la comprobación del apartado anterior es tan valiosa.

Y por eso la solución de fondo es usar digests, no ajustar imagePullPolicy:

Enfoque ¿Resuelve el problema?
imagePullPolicy: Always con etiqueta Parcialmente: detecta el cambio, pero ejecuta el contenido nuevo sin que nadie lo haya aprobado
Digest : es imposible que el contenido difiera

Fíjate en el matiz de la primera fila. Always no te protege de una etiqueta reasignada maliciosamente: te garantiza que todos los pods ejecutan el contenido nuevo, que es exactamente lo que quería quien la reasignó.

Otras consideraciones de Always

Aspecto Efecto
Disponibilidad Si el registro no responde, los pods no arrancan. Un fallo del registro se convierte en un fallo de la plataforma
Latencia de arranque Añade una consulta al registro en cada arranque (rápida si la imagen ya está en caché, pero no gratis)
Autorización Comprueba que el imagePullSecret sigue siendo válido, lo que revoca el acceso a nodos que ya tenían la imagen

Ese último punto es un beneficio de seguridad real: con IfNotPresent, un nodo que ya tiene la imagen en caché la sigue usando aunque las credenciales del registro se hayan revocado.

La recomendación de Rutas Norte

containers:
  - name: api
    # Digest: el contenido es criptográficamente el esperado
    image: registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
    imagePullPolicy: IfNotPresent   # seguro con digest, y no depende del registro

Con digest, IfNotPresent es a la vez seguro y robusto. Es lo mejor de ambos mundos.

Excepción: en rutas-norte-dev, donde se itera rápido con etiquetas mutables, Always es razonable. Y un aviso de 08-02 que aplica aquí: en minikube con imágenes construidas localmente, Always hace que el kubelet intente descargar del registro y falle; ahí es donde Never o IfNotPresent tienen sentido.

  1. El registro privado

Por qué un registro propio

Razón Detalle
Control de acceso Decides quién publica y quién descarga
Punto de control único Todo lo que se ejecuta pasa por un sitio donde escanear y firmar
Disponibilidad No dependes de que un registro público esté accesible
Réplica de imágenes públicas Una imagen pública puede desaparecer o cambiar; tu copia no
Auditoría Registro de quién subió y descargó qué, y cuándo

En 08-03 escribimos la política de Kyverno que obliga a que toda imagen venga de registry.rutasnorte.example. El registro deja de ser una comodidad para ser el punto donde se aplican todos los controles.

imagePullSecrets en la ServiceAccount

Ya vimos los imagePullSecrets en 03-02. La mejora importante es declararlos en la ServiceAccount en lugar de en cada pod:

# Crear el secreto con las credenciales del registro
apiVersion: v1
kind: Secret
metadata:
  name: registro-rutasnorte
  namespace: rutas-norte-pro
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64 del fichero de configuración de Docker>
---
# Declararlo en la ServiceAccount: se aplica a TODOS los pods que la usan
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
imagePullSecrets:
  - name: registro-rutasnorte
automountServiceAccountToken: true   # de 03-06: lee un ConfigMap por API
kubectl create secret docker-registry registro-rutasnorte \
  --namespace rutas-norte-pro \
  --docker-server=registry.rutasnorte.example \
  --docker-username=descarga-pro \
  --docker-password="$(cat /ruta/segura/clave-descarga)" \
  [email protected]

Ventajas de declararlo en la ServiceAccount:

  • No hay que repetirlo en cada Deployment: se olvida menos.
  • Cambiar las credenciales es un solo objeto.
  • Se puede tener una ServiceAccount con acceso a un repositorio concreto del registro y otra con acceso a otro.

Y una advertencia de 08-01 que aplica directamente: este Secret contiene credenciales del registro. Quien tenga list sobre secrets en el namespace puede leerlas. En nuestro diseño, solo plataforma.

Control de acceso al registro

El principio es la separación entre publicar y descargar:

Identidad Permiso Ámbito
ci-rutasnorte (canalización) Publicar (push) Solo rutasnorte/*
descarga-pro (nodos de producción) Solo descargar (pull) Solo rutasnorte/*
descarga-dev Solo descargar rutasnorte/*
plataforma (personas) Publicar y borrar Todo, con segundo factor
desarrollo (personas) Publicar Solo rutasnorte/dev/*

Los nodos de producción nunca deben tener credenciales de publicación. Si alguien compromete un nodo y el imagePullSecret permite publicar, puede subir imágenes envenenadas al registro del que se abastece toda la plataforma. Es una escalada de un nodo al clúster entero.

Inmutabilidad de etiquetas del lado del registro

La mayoría de registros modernos permiten configurar que una etiqueta, una vez publicada, no se pueda reasignar:

# Ejemplo con Harbor: regla de inmutabilidad en el proyecto
# Configuración > Inmutabilidad de etiquetas
#   Repositorios: **
#   Etiquetas: ** (excepto dev-*)

Esto convierte "usamos etiquetas inmutables por convención" en una garantía técnica. Un intento de reasignar una etiqueta falla:

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

Sigue siendo recomendable usar digests, porque la inmutabilidad protege contra el error y contra la reasignación, pero un registro comprometido a nivel de almacenamiento podría burlarla. El digest lo verifica el propio cliente.

Réplica de las imágenes públicas

Rutas Norte usa postgres:16.4, redis:7.4-alpine y nginx-unprivileged. Todas son imágenes públicas excelentes, y aun así conviene copiarlas al registro propio:

#!/usr/bin/env bash
# ci/replicar-imagenes-publicas.sh
# Copia las imágenes públicas al registro de la empresa, por DIGEST.
set -euo pipefail

REGISTRO=registry.rutasnorte.example

# Formato: origen[@digest] destino
IMAGENES=(
  "docker.io/library/postgres:16.4-alpine    $REGISTRO/externas/postgres:16.4-alpine"
  "docker.io/library/redis:7.4-alpine        $REGISTRO/externas/redis:7.4-alpine"
  "docker.io/nginxinc/nginx-unprivileged:1.27-alpine $REGISTRO/externas/nginx-unprivileged:1.27-alpine"
)

for linea in "${IMAGENES[@]}"; do
  read -r origen destino <<<"$linea"
  echo "== $origen -> $destino"

  # Resolver el digest del origen y registrarlo: queda constancia de QUÉ se copió
  digest=$(crane digest "$origen")
  echo "   digest de origen: $digest"

  # Copiar por digest, no por etiqueta: garantiza que copiamos lo que verificamos
  crane copy "${origen%%:*}@${digest}" "$destino"

  # Escanear antes de darla por buena (ver 08-06)
  trivy image --severity HIGH,CRITICAL --exit-code 1 "$destino" || {
    echo "   AVISO: vulnerabilidades graves. Revisar antes de usar en producción."
  }

  # Firmar la copia (ver apartado 7)
  cosign sign --yes "$destino"
done

Qué resuelve esto:

Problema Cómo lo resuelve la réplica
La imagen pública desaparece o se borra Tienes tu copia
El registro público no está disponible Tus despliegues siguen funcionando
La imagen pública cambia sin aviso Tu copia es la que verificaste
Límites de descarga de los registros públicos No los tocas
No sabes qué imágenes públicas usas Están todas en externas/

Ese último punto tiene un valor de inventario que se subestima: cuando aparezca una vulnerabilidad grave en una librería, la pregunta será "¿qué imágenes nuestras la contienen?", y tener todo en un registro propio hace que la respuesta sea una consulta en lugar de una investigación.

  1. Firma y verificación con Cosign

Hasta ahora hemos garantizado qué contenido se ejecuta (digest). Falta garantizar que ese contenido lo aprobó alguien de confianza.

El problema que resuelve la firma

Un digest te dice que la imagen no ha cambiado. No te dice si esa imagen la construyó tu canalización o alguien que robó las credenciales del registro. La firma criptográfica responde a esa pregunta: quién avala esta imagen.

Sigstore y Cosign

Sigstore es un proyecto que hace la firma de artefactos software accesible. Cosign es su herramienta para firmar imágenes de contenedor.

Instalación:

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

Firma con par de claves

El modelo clásico:

# 1. Generar el par de claves (una vez)
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. Firmar una imagen POR DIGEST (nunca por etiqueta)
cosign sign --key cosign.key \
  registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# 3. Verificar
cosign verify --key cosign.pub \
  registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b... | jq .
[
  {
    "critical": {
      "identity": { "docker-reference": "registry.rutasnorte.example/api-reservas" },
      "image": {
        "docker-manifest-digest": "sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0"
      },
      "type": "cosign container image signature"
    },
    "optional": {
      "commit": "a3f9c1d",
      "construida-por": "ci-rutasnorte",
      "fecha": "2026-08-04T09:14:22Z"
    }
  }
]

Firmar siempre por digest, nunca por etiqueta. Si firmas imagen:2.7.1, Cosign resuelve el digest en ese momento; pero si alguien reasigna la etiqueta después, la firma no corresponde al nuevo contenido y la verificación falla, que es lo correcto pero confuso. Firmar por digest deja claro qué se está avalando.

El problema del par de claves es dónde vive la clave privada. Si está en la canalización, quien comprometa la canalización puede firmar cualquier cosa. Se puede mitigar con un KMS:

cosign sign --key awskms:///alias/rutasnorte-firma-imagenes \
  registry.rutasnorte.example/api-reservas@sha256:...

Firma sin claves con identidad OIDC

Es el modelo que recomienda Sigstore y resuelve el problema de la custodia de claves.

Funciona así:

  1. Cosign obtiene un token OIDC de un proveedor de identidad (la propia canalización, GitHub Actions, GitLab CI, Google, etc.).
  2. Solicita a Fulcio (la autoridad certificadora de Sigstore) un certificado de corta duración, válido diez minutos, que vincula la identidad con una clave efímera.
  3. Firma la imagen con esa clave.
  4. Publica la firma y el certificado en Rekor, un registro público de transparencia, inmutable y solo de adición.
  5. Descarta la clave privada.
# En la canalización: sin ninguna clave que custodiar
COSIGN_EXPERIMENTAL=1 cosign sign --yes \
  registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b...

Verificación por identidad, no por clave:

cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/plataforma/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b...
Verification for registry.rutasnorte.example/api-reservas@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

Lo que estás verificando no es "esta imagen la firmó la clave X", sino algo mucho más útil: "esta imagen la firmó la canalización del repositorio plataforma/* de git.rutasnorte.example".

Comparación:

Par de claves Sin claves (OIDC)
Custodia de la clave Problema real: hay que guardarla y rotarla No hay clave que guardar
Si se roba la clave Se puede firmar cualquier cosa hasta que se detecte La clave vive 10 minutos
Qué verifica Que la firmó quien tiene la clave Qué identidad y desde dónde
Registro de transparencia Opcional : toda firma queda registrada públicamente
Funciona sin conexión Necesita Fulcio y Rekor (o instancias propias)
Complejidad inicial Baja Media

Recomendación para Rutas Norte: sin claves con OIDC, usando el proveedor de identidad de la canalización. Es más seguro y elimina el trabajo de custodiar claves.

Nota sobre privacidad: el registro de transparencia público de Sigstore hace públicos los nombres de las imágenes firmadas. Si eso es un problema (revela nombres internos de proyectos), se puede desplegar una instancia privada de Fulcio y Rekor.

Firmar en la canalización

# .gitlab-ci.yml (fragmento) — construcción, escaneo y firma
construir-y-firmar:
  stage: publicar
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    - VERSION="${CI_COMMIT_TAG:-0.0.0-${CI_COMMIT_SHORT_SHA}}"
    - IMAGEN="registry.rutasnorte.example/api-reservas"

    # 1. Construir
    - docker build -t "${IMAGEN}:${VERSION}" .

    # 2. Escanear ANTES de publicar: si hay vulnerabilidades graves, se para (08-06)
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMAGEN}:${VERSION}"

    # 3. Verificar que no corre como root
    - ./ci/verificar-usuario-imagen.sh "${IMAGEN}:${VERSION}"

    # 4. Publicar y resolver el digest
    - docker push "${IMAGEN}:${VERSION}"
    - DIGEST=$(crane digest "${IMAGEN}:${VERSION}")
    - echo "Digest publicado: ${DIGEST}"

    # 5. Firmar POR DIGEST, con identidad OIDC de la canalización
    - cosign sign --yes "${IMAGEN}@${DIGEST}"

    # 6. Generar y adjuntar el SBOM (apartado 9)
    - syft "${IMAGEN}@${DIGEST}" -o spdx-json > sbom.json
    - cosign attest --yes --predicate sbom.json --type spdxjson "${IMAGEN}@${DIGEST}"

    # 7. Publicar el digest para que el despliegue lo use
    - echo "DIGEST_IMAGEN=${DIGEST}" >> variables.env
  artifacts:
    reports:
      dotenv: variables.env

El orden importa: escanear antes de publicar. Publicar primero y escanear después significa que durante un rato hay una imagen vulnerable disponible para que alguien la despliegue.

  1. Verificar en el clúster con una política de admisión

Firmar en la canalización es la mitad del trabajo. Si el clúster acepta cualquier imagen, la firma es un adorno: quien pueda saltarse la canalización y publicar directamente en el registro despliega lo que quiera.

La verificación de firmas tiene que ocurrir en el clúster, en el control de admisión. Es la única forma de garantizar que absolutamente todo lo que se ejecuta ha pasado por el proceso.

Política de Kyverno con verificación de firma

Retomamos Kyverno de 08-03, ahora con verifyImages:

# k8s/politicas/verificar-firma-imagenes.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verificar-firma-imagenes
  annotations:
    policies.kyverno.io/title: Verificación de firma de imágenes
    policies.kyverno.io/category: Cadena de suministro
    policies.kyverno.io/severity: critical
    policies.kyverno.io/description: >-
      Toda imagen desplegada en rutas-norte-pre y rutas-norte-pro debe estar
      firmada por la canalización oficial de Rutas Norte. Kyverno verifica la
      firma contra el registro de transparencia y, si es válida, MUTA el
      manifiesto sustituyendo la etiqueta por el digest verificado.
spec:
  validationFailureAction: Enforce
  background: false        # verifyImages solo actúa en admisión
  webhookTimeoutSeconds: 30  # verificar contra Rekor puede tardar
  rules:
    - name: firma-canalizacion-oficial
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/*"
          # Sustituye la etiqueta por el digest verificado en el manifiesto
          mutateDigest: true
          # Exige que la referencia sea verificable
          required: true
          # Cachea las verificaciones para no consultar Rekor en cada pod
          useCache: true
          attestors:
            - count: 1
              entries:
                # Firma sin claves con identidad OIDC
                - keyless:
                    subject: "https://git.rutasnorte.example/plataforma/*"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev

        # Las imágenes públicas replicadas las firma el equipo de plataforma
        - imageReferences:
            - "registry.rutasnorte.example/externas/*"
          mutateDigest: true
          required: true
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEejemplo1234567890abcdefghij
                      klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ejemplo==
                      -----END PUBLIC KEY-----

Aspectos clave del manifiesto:

  • mutateDigest: true es lo más elegante de esta política. Kyverno verifica la firma, obtiene el digest y reescribe el manifiesto para que use el digest en lugar de la etiqueta. Resuelve automáticamente el problema del apartado 4: aunque el Deployment diga :2.7.1, el pod que se crea usa @sha256:..., el mismo que se verificó.
  • required: true: si la imagen no encaja con ninguna regla de verificación, se rechaza. Sin esto, una imagen de un registro no contemplado pasaría sin verificar.
  • useCache: true: sin caché, cada creación de pod consulta el registro de transparencia, lo que añade latencia y crea una dependencia externa en el camino crítico.
  • webhookTimeoutSeconds: 30: la verificación puede tardar. Si el tiempo de espera se agota y failurePolicy es Fail, no se pueden crear pods.
  • Dos reglas: nuestras imágenes se verifican por identidad OIDC; las públicas replicadas, con la clave del equipo de plataforma que las revisó y copió.

Probando la política

# Imagen firmada: pasa
kubectl run prueba-firmada \
  --image=registry.rutasnorte.example/api-reservas:2.7.1 \
  -n rutas-norte-pro
pod/prueba-firmada created
# Comprobar que Kyverno sustituyó la etiqueta por el digest
kubectl get pod prueba-firmada -n rutas-norte-pro \
  -o jsonpath='{.spec.containers[0].image}{"\n"}'
registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

Pedimos una etiqueta y se está ejecutando un digest verificado. Exactamente lo que queríamos.

# Imagen sin firmar: se rechaza
kubectl run prueba-sin-firmar \
  --image=registry.rutasnorte.example/experimento:0.1.0 \
  -n rutas-norte-pro
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/rutas-norte-pro/prueba-sin-firmar was blocked due to the following policies

verificar-firma-imagenes:
  firma-canalizacion-oficial: 'failed to verify image
    registry.rutasnorte.example/experimento:0.1.0: .attestors[0].entries[0].keyless:
    no signatures found'

La alternativa: policy-controller de Sigstore

Sigstore tiene su propio controlador de admisión, especializado únicamente en esto:

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

Y se activa etiquetando el namespace, igual que el PSA:

kubectl label namespace rutas-norte-pro policy.sigstore.dev/include=true
Kyverno policy-controller
Alcance Todas las políticas del clúster Solo firmas y atestaciones
Otras políticas (etiquetas, recursos) No
mutateDigest
Complejidad Un motor para todo Una pieza más

Para Rutas Norte, Kyverno, porque ya lo tenemos de 08-03 y evita añadir otro webhook de admisión. El policy-controller tiene sentido si no quieres un motor de política general.

El aviso operativo imprescindible

Esta política es de las que pueden parar la plataforma:

  • Si Rekor no responde y no hay caché, la verificación falla.
  • Si la identidad de la canalización cambia (se migra de proveedor de CI), todas las firmas nuevas dejan de validar.
  • Si failurePolicy: Fail y Kyverno cae, no se puede crear ningún pod.

Mitigaciones obligatorias antes de poner Enforce:

  1. Empezar con validationFailureAction: Audit durante semanas.
  2. useCache: true siempre.
  3. Kyverno con tres réplicas, PDB y anti-afinidad (08-03).
  4. Un procedimiento documentado para desactivar la política en una emergencia, con quién puede hacerlo y cómo se registra.
  5. Excluir kube-system y rutas-norte-sistema.

  1. SBOM y atestaciones de procedencia

SBOM: el inventario de componentes

Un SBOM (Software Bill of Materials) es la lista completa de todo lo que hay dentro de una imagen: cada paquete del sistema, cada librería, cada dependencia transitiva, con su versión y su licencia.

# Generar el SBOM de una imagen
syft registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b... \
  -o spdx-json > sbom-api-reservas.json
# Ver un resumen legible
syft registry.rutasnorte.example/api-reservas: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
...

Formatos estándar: SPDX (norma ISO) y CycloneDX (OWASP). Ambos valen; lo importante es tener uno.

Adjuntarlo a la imagen como atestación firmada:

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

Así el SBOM viaja con la imagen, firmado, y se puede recuperar en cualquier momento:

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

Por qué importa cuando aparece una vulnerabilidad grave

Este es el escenario que justifica todo el esfuerzo. Un martes cualquiera se publica una vulnerabilidad crítica en una librería de registro muy utilizada. La pregunta que toda la organización necesita responder en minutos, no en días, es:

¿Está esa librería en alguna de nuestras imágenes? ¿En cuáles? ¿Con qué versión? ¿Cuáles están en producción?

Sin SBOM, la respuesta implica descargar cada imagen, inspeccionarla, revisar los ficheros de dependencias de cada repositorio y esperar no haberse dejado nada. Días de trabajo, con incertidumbre al final.

Con SBOM:

#!/usr/bin/env bash
# ci/buscar-componente.sh — ¿qué imágenes contienen este componente?
COMPONENTE="$1"

for imagen in $(cat inventario-imagenes-produccion.txt); do
  resultado=$(cosign download attestation "$imagen" 2>/dev/null \
    | jq -r '.payload' | base64 -d \
    | jq -r --arg c "$COMPONENTE" \
        '.predicate.packages[]? | select(.name == $c) | "\(.name) \(.versionInfo)"')
  [[ -n "$resultado" ]] && echo "$imagen -> $resultado"
done
./ci/buscar-componente.sh jsonwebtoken
registry.rutasnorte.example/api-reservas@sha256:9f2c1d... -> jsonwebtoken 9.0.2
registry.rutasnorte.example/worker-notificaciones@sha256:5e8a1b... -> jsonwebtoken 9.0.2

Dos minutos. Y con certeza, no con esperanza.

Atestaciones de procedencia (SLSA)

SLSA (Supply-chain Levels for Software Artifacts) es un marco que define niveles de garantía sobre cómo se construyó un artefacto. Una atestación de procedencia responde a:

Pregunta Qué contiene la atestación
¿De qué código fuente salió? Repositorio y hash del commit
¿Quién la construyó? Identidad del sistema de construcción
¿Cuándo? Marca de tiempo
¿Con qué parámetros? Argumentos de construcción, variables
¿En qué entorno? Imagen del ejecutor, versión de las herramientas
cosign attest --yes --predicate procedencia.json --type slsaprovenance \
  registry.rutasnorte.example/api-reservas@sha256:9f2c1d4e8a7b...

Los niveles de SLSA, resumidos:

Nivel Qué exige
L1 La construcción está automatizada y genera procedencia
L2 La procedencia está firmada por un servicio de construcción alojado
L3 El servicio de construcción está endurecido, con aislamiento entre construcciones

Un objetivo razonable para una plataforma como Rutas Norte es L2: construcción automatizada en un servicio alojado, con procedencia firmada. L3 requiere un servicio de construcción con garantías fuertes de aislamiento, lo que suele implicar infraestructura específica.

Y la pieza que cierra el círculo: exigir la atestación en admisión.

# Fragmento de la política de Kyverno: exigir SBOM además de firma
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/rutasnorte/*"
          required: true
          mutateDigest: true
          attestations:
            - type: https://spdx.dev/Document
              attestors:
                - entries:
                    - keyless:
                        subject: "https://git.rutasnorte.example/plataforma/*"
                        issuer: "https://git.rutasnorte.example"
              conditions:
                - all:
                    # Exigir que el SBOM declare al menos un paquete:
                    # descarta atestaciones vacías o mal generadas
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

Con esto, una imagen sin SBOM no se despliega en producción. La consecuencia práctica es que el inventario nunca se queda desactualizado, porque es imposible desplegar algo que no esté inventariado.

  1. Política de imágenes base y actualización periódica

El problema del envejecimiento

Una imagen construida hoy con node:22.7.0-alpine no tiene vulnerabilidades conocidas. Dentro de tres meses, esa misma imagen —sin que nadie la haya tocado— tendrá varias, porque se habrán publicado vulnerabilidades en paquetes que contiene.

Una imagen no se degrada: el mundo cambia a su alrededor.

Esto tiene una consecuencia que mucha gente no interioriza: si una imagen lleva seis meses en producción sin reconstruirse, acumula seis meses de vulnerabilidades sin parchear, aunque el código de la aplicación sea perfecto.

La reconstrucción periódica

La solución es reconstruir con regularidad, aunque el código no cambie.

# .gitlab-ci.yml — reconstrucción semanal programada
reconstruccion-semanal:
  stage: publicar
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule" && $TIPO_PROGRAMADO == "reconstruccion"
  script:
    # Forzar la descarga de la imagen base más reciente del rango permitido
    - docker build --pull --no-cache -t "${IMAGEN}:${VERSION}-b${CI_PIPELINE_IID}" .
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMAGEN}:${VERSION}-b${CI_PIPELINE_IID}"
    - docker push "${IMAGEN}:${VERSION}-b${CI_PIPELINE_IID}"
    - DIGEST=$(crane digest "${IMAGEN}:${VERSION}-b${CI_PIPELINE_IID}")
    - cosign sign --yes "${IMAGEN}@${DIGEST}"
    # Abrir automáticamente una PR que actualice el digest en los manifiestos
    - ./ci/abrir-pr-actualizacion-digest.sh "${DIGEST}"

Por qué reconstruir semanalmente es más barato que parchear con prisa

Esta es la parte del argumento que hay que saber defender ante quien pregunte "¿para qué reconstruir si no hemos cambiado nada?".

Reconstrucción semanal programada Parcheo de urgencia
Cuándo ocurre Martes por la mañana, planificado El día que sale la vulnerabilidad, sea cuando sea
Cuánto cambia Los parches de una semana Meses de cambios acumulados
Riesgo de rotura Bajo: poco delta Alto: muchos cambios a la vez
Se detecta en Las pruebas de la canalización Producción, con prisa
Presión Ninguna Máxima
Coste Minutos de CPU en la CI Horas de varias personas, con riesgo de incidencia
¿Funciona el procedimiento? Se prueba cada semana Se descubre si funciona el día que hace falta

Ese último punto es el decisivo. Un procedimiento de reconstrucción que se ejecuta cada semana está probado. Uno que se ejecuta una vez al año, cuando hay una crisis, está sin probar exactamente cuando más importa.

La política de imágenes base de Rutas Norte

# k8s/politicas/imagenes-base-permitidas.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: imagenes-base-permitidas
  namespace: rutas-norte-sistema
data:
  politica.yaml: |
    # Imágenes base aprobadas para construir imágenes de Rutas Norte.
    # Revisión trimestral por el equipo de plataforma con seguridad.
    aprobadas:
      - patron: "gcr.io/distroless/*"
        justificacion: "Superficie mínima. Preferida para aplicaciones propias."
        responsable: plataforma
      - patron: "docker.io/library/alpine:3.20*"
        justificacion: "Cuando se necesita shell o utilidades del sistema."
        responsable: plataforma
      - patron: "docker.io/library/node:22.*-alpine"
        justificacion: "Etapa de construcción de aplicaciones Node.js."
        responsable: plataforma
      - patron: "docker.io/library/postgres:16.*-alpine"
        justificacion: "Base de datos. Solo versiones 16.x con soporte."
        responsable: plataforma
      - patron: "docker.io/library/redis:7.4*-alpine"
        justificacion: "Caché de disponibilidad."
        responsable: plataforma

    prohibidas:
      - patron: "*:latest"
        razon: "No reproducible."
      - patron: "docker.io/*/  # cuentas de usuario, no oficiales"
        razon: "Origen no verificable; usar imágenes oficiales o construir la nuestra."

    reglas:
      - "Toda imagen base debe replicarse en registry.rutasnorte.example/externas/."
      - "Toda imagen base debe escanearse antes de replicarse."
      - "Reconstrucción semanal de todas las imágenes propias (martes 06:00)."
      - "Actualización de versión mayor de la base: cambio revisado, nunca automático."
      - "Una imagen base sin actualizaciones de seguridad durante 6 meses se marca
         para revisión: puede estar abandonada."

Y herramientas que automatizan el seguimiento:

Herramienta Qué hace
Renovate / Dependabot Abre PR cuando hay una versión nueva de la imagen base o de una dependencia
crane digest en la CI Detecta si la etiqueta de la base cambió de contenido
Escaneo continuo del registro (08-06) Alerta de vulnerabilidades nuevas en imágenes ya publicadas

  1. La política completa de imágenes de Rutas Norte

Reunimos todo en el documento que rige la plataforma.

Quién puede publicar

Identidad Repositorio Permiso Requisitos
ci-rutasnorte rutasnorte/* Publicar Solo desde ramas protegidas del repositorio oficial
desarrollo (grupo) dev/* Publicar Para pruebas; nunca desplegable en pre ni pro
plataforma (grupo) externas/* Publicar Réplica de públicas, tras escaneo y firma manual
plataforma (grupo) Todos Borrar Con segundo factor y registro de la operación
descarga-pro rutasnorte/*, externas/* Solo descargar Credencial de los nodos de producción

Requisitos para entrar en rutas-norte-pro

Una imagen solo se despliega en producción si cumple todos estos requisitos, y cada uno lo verifica un mecanismo concreto:

# Requisito Verificado por
1 Procede de registry.rutasnorte.example Política Kyverno registro-obligatorio (08-03)
2 Se referencia por digest mutateDigest: true de Kyverno
3 No usa la etiqueta latest ValidatingAdmissionPolicy (08-03)
4 Está firmada por la canalización oficial Política Kyverno verificar-firma-imagenes
5 Tiene SBOM adjunto y firmado Condición de atestación en Kyverno
6 Tiene atestación de procedencia Condición de atestación en Kyverno
7 Sin vulnerabilidades críticas sin excepción documentada Trivy en la canalización (08-06)
8 Declara usuario no root numérico verificar-usuario-imagen.sh en la CI
9 Base en la lista de aprobadas Revisión de código del Dockerfile
10 Reconstruida en los últimos 30 días Trabajo programado que alerta
11 Ha pasado por rutas-norte-pre Procedimiento de promoción

El flujo completo

flowchart TD
    A["Commit en rama protegida"] --> B["CI: construcción multietapa<br/>base aprobada"]
    B --> C["Escaneo con Trivy<br/>crítico = falla"]
    C -->|Falla| X1["Construcción detenida"]
    C -->|Pasa| D["Verificar usuario no root"]
    D --> E["Publicar en el registro"]
    E --> F["Resolver el digest"]
    F --> G["Firmar con Cosign<br/>identidad OIDC"]
    G --> H["Generar y adjuntar SBOM<br/>+ procedencia"]
    H --> I["Desplegar en rutas-norte-pre"]
    I --> J["Pruebas de integración"]
    J -->|Pasan| K["Promoción a rutas-norte-pro"]
    K --> L["Admisión: PSA + VAP + Kyverno"]
    L -->|Firma no válida| X2["Pod rechazado"]
    L -->|Sin SBOM| X2
    L -->|Registro incorrecto| X2
    L -->|Todo correcto| M["Pod en ejecución<br/>con digest verificado"]

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

El manifiesto final de api-reservas

# k8s/entornos/pro/api-reservas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
  annotations:
    # Trazabilidad completa de qué se está ejecutando
    imagen.rutasnorte.example/version: "2.7.1"
    imagen.rutasnorte.example/commit: "a3f9c1d"
    imagen.rutasnorte.example/construida: "2026-08-04T09:14:22Z"
    imagen.rutasnorte.example/firmada-por: "ci-rutasnorte"
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reservas
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: pro
    spec:
      serviceAccountName: api-reservas    # con imagePullSecrets declarados
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532                  # coincide con el USER del Dockerfile
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          # v2.7.1 (a3f9c1d) — digest verificado, firma comprobada en admisión
          image: registry.rutasnorte.example/rutasnorte/api-reservas@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
          imagePullPolicy: IfNotPresent   # seguro con digest
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metricas, containerPort: 9090 }
          livenessProbe:
            httpGet: { path: /salud, port: http }
            initialDelaySeconds: 10
          readinessProbe:
            httpGet: { path: /preparado, port: http }
            periodSeconds: 5
          volumeMounts:
            - { name: temporal, mountPath: /tmp }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { memory: 512Mi }
        - name: embajador-pagos
          image: registry.rutasnorte.example/rutasnorte/embajador-pagos@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: temporal
          emptyDir: { sizeLimit: 64Mi }

Este manifiesto reúne el módulo entero: la ServiceAccount de 03-06 y 08-01, el securityContext de 08-02, el cumplimiento de restricted de 08-03, y la referencia por digest verificado de esta lección.

Errores Comunes y Consejos

Usar latest en cualquier entorno que no sea un experimento local. Rompe los rollbacks, hace irreproducibles los despliegues y puede dejar réplicas con versiones distintas.

Usar etiquetas móviles como :2.7 o :16. Se reasignan en cada parche. Parecen versiones concretas y no lo son.

Creer que imagePullPolicy: Always protege de una etiqueta reasignada. Garantiza que todas las réplicas ejecuten el contenido nuevo, que es justo lo que buscaba quien la reasignó. La solución es el digest.

Declarar el USER por nombre en el Dockerfile. runAsNonRoot: true no puede verificarlo y el pod no arranca, con un error cuya causa está en otro fichero.

Dejar secretos en las capas de la imagen. Borrarlos en un RUN posterior no los quita de la capa anterior. Usa RUN --mount=type=secret.

Usar npm install en lugar de npm ci. Puede resolver versiones distintas en cada construcción, haciendo la imagen irreproducible.

Incluir dependencias de desarrollo en la imagen final. Marcos de pruebas y linters no pintan nada en producción y suelen ser la mitad del árbol de dependencias.

Pasar a distroless sin plan de depuración. No hay shell. Hay que dominar kubectl debug con contenedores efímeros antes de necesitarlo en una incidencia.

Olvidar los certificados raíz al usar scratch. Todas las conexiones HTTPS fallan con un error de verificación de certificado.

Firmar por etiqueta en lugar de por digest. Deja ambigüedad sobre qué contenido se avaló.

Guardar la clave privada de firma en la canalización. Quien comprometa la CI puede firmar cualquier cosa. Usa firma sin claves con OIDC, o un KMS.

Firmar en la canalización y no verificar en el clúster. La firma sin verificación es un adorno: quien pueda publicar directamente en el registro despliega lo que quiera.

Poner la política de verificación en Enforce desde el primer día. Puede parar la plataforma entera. Audit durante semanas, useCache: true, alta disponibilidad del motor y un procedimiento de emergencia documentado.

Escanear después de publicar. Durante ese intervalo hay una imagen vulnerable disponible para desplegar.

Dar credenciales de publicación a los nodos de producción. Un nodo comprometido puede envenenar el registro del que se abastece toda la plataforma.

No reconstruir las imágenes que no cambian. Acumulan meses de vulnerabilidades sin parchear. Reconstruye semanalmente.

Consejo de oro: la pregunta que debes poder responder en cualquier momento es "¿qué está corriendo exactamente en producción ahora mismo, quién lo aprobó y qué contiene?". Digest para el qué, firma para el quién, SBOM para el contenido. Si falta una de las tres, tienes un hueco.

Ejercicios

Ejercicio 1: endurecer un Dockerfile

Este es el Dockerfile actual de worker-notificaciones:

FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
  1. Enumera todos los problemas de seguridad y de calidad que tiene.
  2. Reescríbelo con construcción multietapa, base mínima y usuario no root.
  3. Escribe las órdenes que verificarían que la imagen resultante cumple los requisitos de la política de Rutas Norte.

Ejercicio 2: de etiqueta a digest verificado

El Deployment de tienda-web en producción usa:

image: registry.rutasnorte.example/rutasnorte/tienda-web:1.9
imagePullPolicy: Always
  1. Explica los tres riesgos concretos de esta configuración.
  2. Escribe las órdenes para obtener el digest, verificar su firma e inspeccionar su SBOM.
  3. Escribe el fragmento corregido del manifiesto.
  4. ¿Cómo comprobarías que las tres réplicas están ejecutando el mismo contenido?

Ejercicio 3: política de admisión con verificación de firma para un componente nuevo

Rutas Norte incorpora pasarela-sms, un componente que envía avisos por SMS de cambios de horario. Se construye en un repositorio distinto (https://git.rutasnorte.example/comunicaciones/pasarela-sms) por un equipo distinto.

Escribe la regla de Kyverno que hay que añadir a la política verificar-firma-imagenes para que:

  • Solo se acepten imágenes de registry.rutasnorte.example/rutasnorte/pasarela-sms*.
  • Firmadas desde el repositorio comunicaciones/pasarela-sms (y no desde otro).
  • Con SBOM adjunto que contenga al menos un paquete.
  • Sustituyendo la etiqueta por el digest verificado.

Indica también cómo la desplegarías sin riesgo de bloquear despliegues legítimos, y qué comprobarías antes de pasarla a Enforce.

Soluciones

Solución 1

1. Problemas del Dockerfile original:

# Problema Consecuencia
1 FROM node:22 sin versión de parche No reproducible: 22 se reasigna constantemente
2 Imagen base Debian completa (~1,1 GB) Cientos de paquetes innecesarios, decenas de vulnerabilidades
3 Sin construcción multietapa Compiladores y gestor de paquetes en la imagen final
4 COPY . . antes de instalar Rompe la caché de capas y puede copiar .env, .git o claves
5 Sin .dockerignore Agrava el problema anterior
6 npm install en lugar de npm ci Puede resolver versiones distintas en cada construcción
7 Incluye dependencias de desarrollo Marco de pruebas y linters en producción
8 Sin USER: corre como root Incumple runAsNonRoot; el pod ni siquiera arranca con el PSA de 08-03
9 CMD npm start en forma shell El proceso 1 es sh, que no reenvía las señales: el pod no termina limpiamente y tarda 30 s en morir
10 Sin etiquetas OCI Sin trazabilidad del origen
11 Cachés de npm en la imagen Tamaño innecesario

El problema 9 es sutil y muy real: con la forma shell, el PID 1 es el intérprete de órdenes, que no propaga SIGTERM al proceso de Node. Kubernetes espera el terminationGracePeriodSeconds completo y luego mata el pod con SIGKILL, cortando las notificaciones en curso.

2. Dockerfile reescrito:

# Dockerfile de worker-notificaciones

# ---------- Etapa 1: construcción ----------
FROM node:22.7.0-alpine AS constructor

WORKDIR /construccion

# Manifiestos primero: aprovecha la caché de capas
COPY package.json package-lock.json ./

# npm ci: reproducible; --omit=dev: sin dependencias de desarrollo
RUN npm ci --omit=dev && npm cache clean --force

COPY src/ ./src/
RUN npm run build

# ---------- Etapa 2: imagen final ----------
FROM gcr.io/distroless/nodejs22-debian12:nonroot

WORKDIR /app

COPY --from=constructor --chown=nonroot:nonroot /construccion/node_modules ./node_modules
COPY --from=constructor --chown=nonroot:nonroot /construccion/dist ./dist

# Usuario no root, POR NÚMERO (el nonroot de distroless)
USER 65532

EXPOSE 3000

LABEL org.opencontainers.image.title="worker-notificaciones" \
      org.opencontainers.image.description="Envío de correos de confirmación de reserva" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/plataforma/worker-notificaciones" \
      org.opencontainers.image.licenses="Proprietary"

# Forma exec: el proceso de Node es el PID 1 y recibe SIGTERM directamente
CMD ["dist/worker.js"]

Y el .dockerignore, que resuelve los problemas 4 y 5:

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

.env y .git en esa lista no son opcionales: COPY . . sin .dockerignore puede meter credenciales de desarrollo y todo el historial del repositorio dentro de la imagen, donde quedan para siempre.

3. Verificación:

IMAGEN=registry.rutasnorte.example/rutasnorte/worker-notificaciones:3.2.0

# a) Usuario no root y numérico
docker inspect "$IMAGEN" --format '{{.Config.User}}'
65532
# b) Tamaño (comparación con el original)
docker images "$IMAGEN" --format '{{.Size}}'
158MB
# c) Sin shell (confirma que es distroless)
docker run --rm --entrypoint sh "$IMAGEN" -c 'echo hola' 2>&1 | head -1
docker: Error response from daemon: failed to create task for container:
exec: "sh": executable file not found in $PATH
# d) Sin vulnerabilidades graves
trivy image --severity HIGH,CRITICAL --exit-code 1 "$IMAGEN"
registry.rutasnorte.example/rutasnorte/worker-notificaciones:3.2.0 (debian 12.7)
Total: 0 (HIGH: 0, CRITICAL: 0)
# e) Sin secretos en las capas
trivy image --scanners secret "$IMAGEN"
Total: 0
# f) Firma válida
cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/plataforma/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "$IMAGEN" >/dev/null && echo "Firma verificada"
Firma verificada
# g) SBOM presente
cosign download attestation "$IMAGEN" | jq -r '.payload' | base64 -d \
  | jq '.predicate.packages | length'
187

De 412 paquetes en la versión original a 187: menos de la mitad de superficie que vigilar.

Solución 2

1. Los tres riesgos:

Riesgo Detalle
Etiqueta móvil :1.9 Es una etiqueta de versión menor: se reasigna con cada parche (1.9.1, 1.9.2...). El contenido cambia sin que nadie modifique el manifiesto ni pase por revisión de código
Sin verificación de contenido Nada garantiza que el contenido de :1.9 sea el que se aprobó. Si alguien con acceso al registro la reasigna, el clúster ejecuta lo nuevo
imagePullPolicy: Always como falsa protección Garantiza que todas las réplicas ejecuten el contenido actual de la etiqueta. Ante una reasignación maliciosa, eso propaga el problema a toda la plataforma en lugar de contenerlo. Además, si el registro no responde, los pods no arrancan

2. Órdenes:

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

# a) Obtener el digest actual de la etiqueta
crane digest "$IMG:1.9"
sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
# b) Verificar la firma (por digest, no por etiqueta)
DIGEST=$(crane digest "$IMG:1.9")
cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/plataforma/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "${IMG}@${DIGEST}" | jq -r '.[0].optional'
{
  "commit": "e7b4c2a",
  "construida-por": "ci-rutasnorte",
  "fecha": "2026-07-28T14:02:11Z",
  "Bundle": { "SignedEntryTimestamp": "..." }
}

Nota: la fecha dice 2026-07-28, hace nueve días. Dentro del margen de 30 días de la política, pero conviene tenerlo presente.

# c) Inspeccionar el 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. Manifiesto corregido:

# k8s/entornos/pro/tienda-web.yaml (fragmento)
spec:
  template:
    spec:
      containers:
        - name: nginx
          # tienda-web v1.9.3 (commit e7b4c2a, construida 2026-07-28)
          # Firma verificada: ci-rutasnorte / plataforma/tienda-web
          image: registry.rutasnorte.example/rutasnorte/tienda-web@sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
          imagePullPolicy: IfNotPresent   # seguro con digest y no depende del registro

Y con Kustomize (10-04), para no escribir el digest a mano en cada entorno:

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

4. Comprobar que las tres réplicas ejecutan lo mismo:

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

Y la versión que devuelve un único valor si todo está bien:

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

Un 1 significa que todas las réplicas ejecutan el mismo contenido. Cualquier número mayor es un incidente que hay que investigar de inmediato: significa que hay réplicas con contenidos distintos bajo el mismo nombre.

Solución 3

# Regla que se AÑADE a spec.rules de la política verificar-firma-imagenes
    - name: firma-pasarela-sms
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/rutasnorte/pasarela-sms*"
          mutateDigest: true      # sustituye la etiqueta por el digest verificado
          required: true          # sin firma verificable, se rechaza
          useCache: true          # evita consultar Rekor en cada creación de pod
          attestors:
            - count: 1
              entries:
                - keyless:
                    # Identidad EXACTA del repositorio de comunicaciones.
                    # Sin comodín al final: solo este repositorio, no
                    # cualquier otro del grupo comunicaciones.
                    subject: "https://git.rutasnorte.example/comunicaciones/pasarela-sms"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev
          # Exigir SBOM firmado por la misma identidad
          attestations:
            - type: https://spdx.dev/Document
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        subject: "https://git.rutasnorte.example/comunicaciones/pasarela-sms"
                        issuer: "https://git.rutasnorte.example"
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

Decisiones a destacar:

  • subject sin comodín. Escribir comunicaciones/* permitiría que cualquier repositorio de ese grupo firmase imágenes de pasarela-sms. Al fijar la ruta exacta, solo la canalización de ese repositorio concreto puede avalar estas imágenes. Es el mismo principio de mínimo privilegio de 08-01, aplicado a la firma.
  • Regla separada, no ampliar la existente. Podríamos haber añadido comunicaciones/* al subject de la regla principal, pero eso permitiría que el equipo de comunicaciones firmase imágenes de api-reservas. Cada repositorio firma lo suyo.
  • El patrón pasarela-sms* con asterisco cubre variantes como pasarela-sms-worker si el componente crece. Si se prefiere ser estricto, se quita el asterisco.

Despliegue sin riesgo:

# 1. Aplicar la regla en modo Audit (no bloquea)
#    Se edita una copia con validationFailureAction: Audit
sed 's/validationFailureAction: Enforce/validationFailureAction: Audit/' \
  k8s/politicas/verificar-firma-imagenes.yaml | kubectl apply -f -

# 2. Desplegar pasarela-sms en rutas-norte-pre y comprobar el informe
kubectl apply -f k8s/entornos/pre/pasarela-sms.yaml

kubectl get policyreport -n rutas-norte-pre -o json | jq -r '
  .items[].results[]
  | select(.policy == "verificar-firma-imagenes")
  | "\(.rule): \(.result) - \(.resources[0].name)\n    \(.message // "")"'
firma-pasarela-sms: pass - pasarela-sms-7d4c8b9f6-m2k5x
# 3. Verificar manualmente que la firma es la esperada
IMG=registry.rutasnorte.example/rutasnorte/pasarela-sms
DIGEST=$(crane digest "$IMG:1.0.0")

cosign verify \
  --certificate-identity "https://git.rutasnorte.example/comunicaciones/pasarela-sms" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "${IMG}@${DIGEST}" | jq -r '.[0].optional.Issuer, .[0].optional.Subject'
# 4. Comprobar que una imagen SIN firmar sería rechazada (prueba negativa)
kubectl run prueba-sin-firma \
  --image=registry.rutasnorte.example/rutasnorte/pasarela-sms:experimental \
  -n rutas-norte-pre --dry-run=server
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
... firma-pasarela-sms: 'failed to verify image ... no signatures found'
# 5. Solo entonces, pasar a Enforce
kubectl apply -f k8s/politicas/verificar-firma-imagenes.yaml

Qué comprobar antes de Enforce:

# Comprobación Por qué
1 El informe de política no tiene ningún fail en pre ni en pro Un fail en Enforce es un despliegue bloqueado
2 La canalización de comunicaciones/pasarela-sms firma correctamente Si no firma, todos sus despliegues se bloquearán
3 El SBOM se genera y adjunta en esa canalización La condición de atestación lo exige
4 useCache: true está activo Sin caché, cada pod consulta Rekor: latencia y dependencia externa
5 La prueba negativa rechaza una imagen sin firmar Confirma que la política funciona de verdad
6 Kyverno tiene 3 réplicas, PDB y anti-afinidad El motor es una dependencia del apiserver (08-03)
7 Existe un procedimiento de emergencia documentado Quién puede desactivar la política, cómo, y cómo se registra
8 rutas-norte-sistema y kube-system están excluidos Los componentes de infraestructura usan imágenes externas

El punto 7 merece énfasis. Una política de verificación de firmas en Enforce puede impedir cualquier despliegue si Rekor no responde o si la identidad de la canalización cambia. Debe existir un procedimiento escrito, con nombres, para desactivarla en minutos, y ese procedimiento debe haberse ensayado. Una medida de seguridad que puede paralizar la plataforma y no tiene salida de emergencia es un riesgo operativo, no una protección.

Conclusión

Hemos protegido la cadena de suministro de las imágenes de Rutas Norte:

  • Una imagen se puede envenenar en cinco puntos: la imagen base, las dependencias, el proceso de construcción, el registro y la descarga. Cada uno necesita su defensa, y las defensas se refuerzan entre sí: el digest garantiza el contenido, la firma garantiza el aval, el SBOM garantiza el inventario.
  • Imágenes mínimas con construcción multietapa: scratch para binarios estáticos, distroless para lenguajes con runtime, alpine cuando hace falta sistema operativo. El coste es la depuración, que se resuelve con contenedores efímeros (kubectl debug).
  • Usuario no root numérico en el Dockerfile, que se refuerza con el runAsNonRoot del manifiesto (08-02): cada uno protege el hueco del otro.
  • latest es inaceptable y las etiquetas móviles como :1.9 también. La etiqueta inmutable es aceptable; el digest es la única referencia criptográficamente reproducible.
  • imagePullPolicy: Always no protege de una etiqueta reasignada: garantiza que todas las réplicas ejecuten el contenido nuevo. Con digest, IfNotPresent es seguro y no depende de que el registro responda.
  • El registro privado es el punto de control único: imagePullSecrets en la ServiceAccount, separación estricta entre publicar y descargar, inmutabilidad de etiquetas del lado del servidor, y réplica de las imágenes públicas que se usan.
  • Cosign firma las imágenes, preferiblemente sin claves con identidad OIDC, que elimina el problema de custodiar la clave privada y permite verificar quién y desde dónde firmó.
  • Firmar no basta: hay que verificar en el clúster. La política de Kyverno con verifyImages y mutateDigest: true rechaza lo no firmado y sustituye la etiqueta por el digest verificado.
  • El SBOM convierte "¿tenemos esa librería vulnerable?" en una consulta de dos minutos en lugar de días de investigación. Las atestaciones de procedencia (SLSA) responden a de dónde salió la imagen y quién la construyó.
  • Reconstruir semanalmente es más barato que parchear con prisa: menos delta, menos riesgo, y sobre todo un procedimiento probado en lugar de uno que se estrena el día de la crisis.
  • La política completa de Rutas Norte establece quién publica dónde y los once requisitos que debe cumplir una imagen para entrar en rutas-norte-pro, cada uno verificado por un mecanismo concreto.

La plataforma está ahora protegida en las cuatro dimensiones: quién puede hacer qué, qué puede hacer un contenedor, qué habla con qué, y qué se ejecuta exactamente.

Pero quedan dos preguntas sin respuesta, y son justo las que hace cualquier auditoría. La primera es "¿quién hizo qué?": si mañana descubrimos que el Secret con las credenciales de postgres-reservas fue leído, o que un Deployment desapareció, o que una ServiceAccount hizo algo raro a las tres de la madrugada, ahora mismo no tenemos forma de saberlo. Hemos puesto puertas, pero no llevamos registro de quién las cruza. La segunda es "¿qué agujeros tengo ahora mismo?": sabemos escanear una imagen antes de publicarla, pero las que llevan meses en producción acumulan vulnerabilidades nuevas cada semana, y nadie las está mirando.

La última lección del módulo, 08-06, Auditoría, Escaneo y Gestión de Vulnerabilidades, responde a las dos: el registro de auditoría del apiserver y cómo consultarlo para responder a preguntas concretas, el escaneo continuo con Trivy dentro y fuera del clúster, la evaluación frente a los estándares CIS con kube-bench, la detección en tiempo de ejecución con Falco integrada con el Alertmanager del módulo 7, y —lo que de verdad falta en la mayoría de equipos— la gestión de vulnerabilidades como proceso, con plazos, responsables y excepciones con fecha de caducidad.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados