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-reservaseinformes-ocupacionen 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
- La cadena de suministro de una imagen
- Construir imágenes mínimas
- Usuario no root en el Dockerfile
- Identificar la imagen sin ambigüedad: etiquetas y digests
imagePullPolicyy la trampa de la etiqueta reutilizada- El registro privado
- Firma y verificación con Cosign
- Verificar en el clúster con una política de admisión
- SBOM y atestaciones de procedencia
- Política de imágenes base y actualización periódica
- La política completa de imágenes de Rutas Norte
- Errores comunes y consejos
- Ejercicios
- Conclusión
- 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.
- 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 cien lugar denpm install.ciinstala exactamente lo que dicepackage-lock.jsony falla si el fichero de bloqueo no está sincronizado.installpuede 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.--chownen elCOPY: los ficheros quedan con el propietario correcto sin necesidad de unRUN chownque 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=devComparació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í | Sí (apt) | Muy fácil | Decenas |
node:22-slim |
~250 MB | ~120 | Sí | Sí | 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:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknownNo 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 -- shDefaulting 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_modulesEl 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.
- 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
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 | Sí | Sí |
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 10001Con 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-rootEl 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
Con distroless esto falla porque no hay id. La alternativa es inspeccionar los metadatos:
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"
- 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
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:
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 | Sí | Nunca |
imagen:2.7 |
Sí (se reasigna en cada parche) | Sí | Evitar en producción |
imagen:2.7.1 |
Solo si alguien la reasigna | Sí | 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0O, 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# 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0Esa 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 -uapi 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.
imagePullPolicy y la trampa de la etiqueta reutilizada
imagePullPolicy y la trampa de la etiqueta reutilizadaimagePullPolicy 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:
- Se despliega
api-reservas:2.7.1conimagePullPolicy: IfNotPresent. - Los nodos A, B y C descargan la imagen y la guardan en caché.
- Alguien reasigna la etiqueta
2.7.1en el registro a un contenido distinto. - Un pod se recrea en el nodo D, que no tenía la imagen: descarga la nueva.
- 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 | Sí: 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 registroCon 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.
- 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 APIkubectl 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:
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"
doneQué 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.
- 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 versionFirma con par de claves
El modelo clásico:
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í:
- Cosign obtiene un token OIDC de un proveedor de identidad (la propia canalización, GitHub Actions, GitLab CI, Google, etc.).
- 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.
- Firma la imagen con esa clave.
- Publica la firma y el certificado en Rekor, un registro público de transparencia, inmutable y solo de adición.
- 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 certificatesLo 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 | Sí: toda firma queda registrada públicamente |
| Funciona sin conexión | Sí | 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.envEl 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.
- 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: truees 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 yfailurePolicyesFail, 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# 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0Pedimos 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-proError 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.devY se activa etiquetando el namespace, igual que el PSA:
| Kyverno | policy-controller | |
|---|---|---|
| Alcance | Todas las políticas del clúster | Solo firmas y atestaciones |
| Otras políticas (etiquetas, recursos) | Sí | No |
mutateDigest |
Sí | Sí |
| 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: Faily Kyverno cae, no se puede crear ningún pod.
Mitigaciones obligatorias antes de poner Enforce:
- Empezar con
validationFailureAction: Auditdurante semanas. useCache: truesiempre.- Kyverno con tres réplicas, PDB y anti-afinidad (08-03).
- Un procedimiento documentado para desactivar la política en una emergencia, con quién puede hacerlo y cómo se registra.
- Excluir
kube-systemyrutas-norte-sistema.
- 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.jsonNAME 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'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"
doneregistry.rutasnorte.example/api-reservas@sha256:9f2c1d... -> jsonwebtoken 9.0.2
registry.rutasnorte.example/worker-notificaciones@sha256:5e8a1b... -> jsonwebtoken 9.0.2Dos 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: 0Con 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.
- 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 |
- 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:
- Enumera todos los problemas de seguridad y de calidad que tiene.
- Reescríbelo con construcción multietapa, base mínima y usuario no root.
- 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:
- Explica los tres riesgos concretos de esta configuración.
- Escribe las órdenes para obtener el digest, verificar su firma e inspeccionar su SBOM.
- Escribe el fragmento corregido del manifiesto.
- ¿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}}'# c) Sin shell (confirma que es distroless)
docker run --rm --entrypoint sh "$IMAGEN" -c 'echo hola' 2>&1 | head -1docker: Error response from daemon: failed to create task for container:
exec: "sh": executable file not found in $PATHregistry.rutasnorte.example/rutasnorte/worker-notificaciones:3.2.0 (debian 12.7)
Total: 0 (HIGH: 0, CRITICAL: 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"# g) SBOM presente
cosign download attestation "$IMAGEN" | jq -r '.payload' | base64 -d \
| jq '.predicate.packages | length'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"# 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 -10alpine-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-r13. 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 registroY 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 -lUn 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: 0Decisiones a destacar:
subjectsin comodín. Escribircomunicaciones/*permitiría que cualquier repositorio de ese grupo firmase imágenes depasarela-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/*alsubjectde la regla principal, pero eso permitiría que el equipo de comunicaciones firmase imágenes deapi-reservas. Cada repositorio firma lo suyo. - El patrón
pasarela-sms*con asterisco cubre variantes comopasarela-sms-workersi 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 // "")"'# 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=serverError from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
... firma-pasarela-sms: 'failed to verify image ... no signatures found'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:
scratchpara binarios estáticos,distrolesspara lenguajes con runtime,alpinecuando 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
runAsNonRootdel manifiesto (08-02): cada uno protege el hueco del otro. latestes inaceptable y las etiquetas móviles como:1.9también. La etiqueta inmutable es aceptable; el digest es la única referencia criptográficamente reproducible.imagePullPolicy: Alwaysno protege de una etiqueta reasignada: garantiza que todas las réplicas ejecuten el contenido nuevo. Con digest,IfNotPresentes seguro y no depende de que el registro responda.- El registro privado es el punto de control único:
imagePullSecretsen 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
verifyImagesymutateDigest: truerechaza 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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
