Tot el que portem construït en aquest mòdul protegeix el clúster en temps d'execució. RBAC decideix qui pot fer què (08-01). El securityContext limita el que pot fer un contenidor (08-02). Les polítiques d'admissió impedeixen desplegar alguna cosa que no compleixi les regles (08-03). I les polítiques de xarxa controlen què parla amb què i què pot sortir (08-04).

Però tot això descansa sobre una suposició que mai no hem qüestionat: que les imatges que executem són les que ens pensem.

I aquella suposició és fràgil. registry.rutasnorte.example/api-reserves:2.7.1 és una etiqueta, i una etiqueta es pot reassignar: la imatge que es va descarregar ahir pot no ser la d'avui. La imatge base sobre la qual es va construir conté centenars de paquets que ningú no ha revisat. Les dependències es van instal·lar des de repositoris públics durant la construcció. I ara com ara, res no impedeix que algú amb accés al registre pugi una imatge modificada amb el mateix nom i que el clúster l'executi sense piular, amb tots els nostres securityContext perfectament aplicats... a un contenidor que fa una cosa diferent de la que ens pensem.

Aquesta lliçó s'ocupa de la cadena de subministrament de les imatges: d'on vénen, com es construeixen, com s'identifiquen sense ambigüitat, com se signen i com s'aconsegueix que el clúster rebutgi tot el que no estigui verificat.

Advertiment important. La política d'imatges d'una plataforma de producció l'ha de dissenyar i revisar un professional de seguretat. Quan les imatges processen dades personals —api-reserves, postgres-reserves i informes-ocupacio a Rutas Norte— el responsable de compliment normatiu ha de conèixer quins controls existeixen sobre el programari que tracta aquelles dades, perquè una dependència compromesa és una via directa d'accés a elles. L'enfocament d'aquesta lliçó és exclusivament defensiu: entendre on es pot enverinar una imatge per tancar cada punt i detectar els intents.

Contingut

  1. La cadena de subministrament d'una imatge
  2. Construir imatges mínimes
  3. Usuari no root al Dockerfile
  4. Identificar la imatge sense ambigüitat: etiquetes i digests
  5. imagePullPolicy i el parany de l'etiqueta reutilitzada
  6. El registre privat
  7. Signatura i verificació amb Cosign
  8. Verificar al clúster amb una política d'admissió
  9. SBOM i atestacions de procedència
  10. Política d'imatges base i actualització periòdica
  11. La política completa d'imatges de Rutas Norte
  12. Errors comuns i consells
  13. Exercicis
  14. Conclusió

  1. La cadena de subministrament d'una imatge

Una imatge de contenidor no apareix del no-res. És el resultat d'una cadena de passos, i cada baula és un punt on es pot introduir alguna cosa indesitjada.

flowchart TB
    A["1. Imatge base<br/>node:22-alpine"] --> B["2. Dependències<br/>npm install, apt-get"]
    B --> C["3. Procés de construcció<br/>docker build a la CI"]
    C --> D["4. Registre<br/>registry.rutasnorte.example"]
    D --> E["5. Descàrrega<br/>el kubelet baixa la imatge"]
    E --> F["6. Execució<br/>el contenidor corre al node"]

    A -.->|"imatge base compromesa<br/>o abandonada"| X1["Risc"]
    B -.->|"paquet maliciós,<br/>typosquatting,<br/>dependència abandonada"| X2["Risc"]
    C -.->|"CI compromesa,<br/>secrets en capes,<br/>construcció no reproduïble"| X3["Risc"]
    D -.->|"credencials robades,<br/>etiqueta reassignada"| X4["Risc"]
    E -.->|"etiqueta mutable,<br/>sense verificació"| X5["Risc"]

    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

Els cinc punts de risc

Baula Com es compromet Defensa (apartat)
Imatge base Una imatge pública abandonada, amb vulnerabilitats sense apedaçar, o d'un autor desconegut Imatges mínimes i d'origen conegut (2), política de bases (10)
Dependències Un paquet d'npm o PyPI amb codi maliciós; un nom semblant al legítim Fitxers de bloqueig, SBOM (9), escaneig (08-06)
Construcció La CI compromesa hi injecta alguna cosa; secrets que queden en una capa Procedència (9), construcció multietapa (2)
Registre Credencials robades: algú puja una imatge amb el mateix nom Control d'accés, etiquetes immutables (6), signatura (7)
Descàrrega L'etiqueta apunta ara a una altra imatge Digest (4), verificació de signatura en admissió (8)

Fixa't que les defenses es reforcen entre si. El digest garanteix que descarregues exactament el contingut que esperes, però no diu si aquell contingut és bo. La signatura diu que algú de confiança el va aprovar, però no si conté vulnerabilitats. L'escaneig diu quines vulnerabilitats té, però no si algú el va modificar. Calen totes.

Per què això importa concretament a Rutas Norte

Pensa en api-reserves. És una aplicació Node.js amb potser 400 dependències transitives: paquets que instal·la un paquet que instal·la un paquet que vas demanar tu. Cadascuna és codi que s'executarà amb accés a la connexió de postgres-reserves, és a dir, amb accés a la taula de clients.

No cal cap fallada del kernel ni cap escalada de privilegis. Una dependència compromesa ja és a dins, amb els permisos legítims de l'aplicació. Ni el securityContext més restrictiu no ho impedeix, perquè no està fent res prohibit: està fent servir la connexió a la base de dades que l'aplicació necessita.

Aquesta és la raó de ser d'aquesta lliçó.

  1. Construir imatges mínimes

El principi és senzill: el que no és a la imatge no pot tenir vulnerabilitats ni ser utilitzat per ningú.

El problema de les imatges voluminoses

Una imatge basada en una distribució completa porta centenars de paquets que la teva aplicació no fa servir: compiladors, gestors de paquets, curl, wget, un intèrpret d'ordres complet, eines de xarxa. Cadascun és superfície d'atac i una font potencial d'alertes de vulnerabilitat que cal gestionar.

Construcció multietapa

És la tècnica fonamental: fer servir una imatge amb eines per construir, i copiar només el resultat a una imatge neta.

# Dockerfile d'api-reserves — construcció multietapa

# ---------- Etapa 1: construcció ----------
# Aquí sí que hi ha compiladors, gestors de paquets i eines.
# Aquesta etapa NO acaba a la imatge final.
FROM node:22.7.0-alpine AS constructor

WORKDIR /construccio

# Copiar NOMÉS els manifests de dependències primer:
# així aquesta capa es reutilitza mentre no canviïn les dependències.
COPY package.json package-lock.json ./

# npm ci (no npm install) instal·la EXACTAMENT el que diu el fitxer
# de bloqueig. És reproduïble i no actualitza res pel seu compte.
RUN npm ci --omit=dev

COPY src/ ./src/
RUN npm run build

# Eliminar cachés que no aporten res a l'execució
RUN npm cache clean --force

# ---------- Etapa 2: imatge final ----------
# distroless: només el runtime de Node.js i les llibreries del sistema
# imprescindibles. Sense shell, sense gestor de paquets, sense utilitats.
FROM gcr.io/distroless/nodejs22-debian12:nonroot

WORKDIR /app

# Copiar NOMÉS el necessari des de l'etapa de construcció
COPY --from=constructor --chown=nonroot:nonroot /construccio/node_modules ./node_modules
COPY --from=constructor --chown=nonroot:nonroot /construccio/dist ./dist

# Usuari no root, declarat per NÚMERO (veure apartat 3)
USER 65532

EXPOSE 8080

# Etiquetes OCI estàndard: documenten l'origen de la imatge
LABEL org.opencontainers.image.title="api-reserves" \
      org.opencontainers.image.description="API de reserves de Rutas Norte" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/plataforma/api-reserves" \
      org.opencontainers.image.licenses="Proprietary"

# distroless no té shell, així que la forma exec és obligatòria
CMD ["dist/servidor.js"]

Punts a destacar:

  • npm ci en lloc de npm install. ci instal·la exactament el que diu package-lock.json i falla si el fitxer de bloqueig no està sincronitzat. install pot resoldre versions diferents en cada construcció, cosa que fa que dues construccions del mateix codi produeixin imatges diferents. Reproduïbilitat i seguretat van de la mà.
  • Copiar els manifests abans que el codi. Aprofita la caché de capes: mentre les dependències no canviïn, no es reinstal·len.
  • --omit=dev: les dependències de desenvolupament (marcs de proves, linters) no han d'acabar a producció. Solen ser la meitat de l'arbre.
  • --chown al COPY: els fitxers queden amb el propietari correcte sense necessitat d'un RUN chown que duplicaria la mida de la capa.
  • Etiquetes OCI: deixen rastre d'on ve la imatge. Quan d'aquí a un any algú trobi aquesta imatge al registre, sabrà de quin repositori va sortir.

Un detall important que s'oblida constantment: els secrets utilitzats durant la construcció queden a les capes encara que els esborris després. Si fas RUN echo $TOKEN > /tmp/token && ... && rm /tmp/token, el token continua estant a la capa intermèdia i qualsevol que descarregui la imatge el pot extreure. La solució correcta és RUN --mount=type=secret, que munta el secret durant l'ordre sense persistir-lo:

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

Comparació real d'imatges base

Base Mida típica Paquets del sistema Shell Gestor de paquets Depuració Vulnerabilitats habituals
node:22 (Debian completa) ~1,1 GB ~400 Sí (apt) Molt fàcil Desenes
node:22-slim ~250 MB ~120 Fàcil Bastants menys
node:22-alpine ~180 MB ~30 Sí (busybox) Sí (apk) Fàcil Poques
distroless/nodejs22 ~150 MB ~10 No No Difícil Molt poques
scratch (binari estàtic) ~15 MB 0 No No Molt difícil Pràcticament cap

I amb nginx, el cas de botiga-web:

Imatge Mida Notes
nginx:1.27 ~190 MB Corre com a root per obrir el port 80
nginx:1.27-alpine ~50 MB Continua arrencant com a root
nginxinc/nginx-unprivileged:1.27-alpine ~50 MB Sense root, port 8080: la que vam adoptar a 08-02

La compensació honesta: depuració

Aquesta és la part que els articles entusiastes no expliquen. Amb distroless o scratch:

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

No hi ha shell. Això és exactament el que volem des del punt de vista de la seguretat —qui aconsegueixi executar codi no té eines— però significa que les tècniques de diagnòstic habituals no funcionen.

La solució és la que vam veure a 07-06: els contenidors efímers.

kubectl debug -n rutas-norte-pro -it deploy/api-reserves \
  --image=registry.rutasnorte.example/utilitats: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 contenidor efímer porta les eines i, amb --target, comparteix el namespace de processos del contenidor objectiu. Pots inspeccionar el procés, el seu sistema de fitxers a través de /proc/1/root i la seva xarxa, sense que la imatge de producció contingui ni una sola utilitat.

Nota de seguretat important: kubectl debug requereix el subrecurs pods/ephemeralcontainers, que a 08-01 vam classificar com de risc molt alt i no vam concedir ni a suport ni a desenvolupament. És una operació de plataforma. I a 08-03 la política de registre obligatori de Kyverno incloïa ephemeralContainers precisament perquè ningú no pogués injectar una imatge arbitrària per aquesta via.

Criteri d'elecció per a Rutas Norte

Component Base triada Raó
api-reserves distroless/nodejs22 Aplicació pròpia, exposada a internet, processa dades personals. Màxima reducció de superfície
botiga-web nginx-unprivileged:alpine Servidor web estàndard; alpine n'hi ha prou i facilita ajustar la configuració
worker-notificacions distroless/nodejs22 Igual que l'API; sense accés interactiu necessari
informes-ocupacio distroless/nodejs22 CronJob que llegeix dades personals
postgres-reserves postgres:16.4-alpine (oficial) Base de dades: no la construïm nosaltres. Es fa servir l'oficial, replicada al nostre registre
redis-cache redis:7.4-alpine (oficial) Igual
utilitats (depuració) alpine amb eines Només per a contenidors efímers; mai en un pod desplegat

Un principi útil: scratch per a binaris estàtics (Go, Rust), distroless per a llenguatges amb runtime (Node.js, Python, Java), alpine quan necessitis una mica de sistema operatiu, i una distribució completa només si tens una raó concreta que puguis escriure.

  1. Usuari no root al Dockerfile

A 08-02 vam configurar runAsNonRoot: true i runAsUser als manifests. Aquí fem el mateix des de l'altre costat: declarar l'usuari a la imatge.

Per què les dues coses

# Al Dockerfile
USER 65532
# Al manifest
securityContext:
  runAsNonRoot: true
  runAsUser: 65532

Sembla redundant i no ho és. Es reforcen mútuament:

Escenari Només USER al Dockerfile Només runAsUser al manifest Tots dos
La imatge s'executa fora de Kubernetes (docker run, proves locals) Protegit Sense protecció Protegit
Algú desplega la imatge sense securityContext Protegit Sense protecció Protegit
Algú reconstrueix la imatge oblidant el USER Sense protecció Protegit Protegit
Kubernetes verifica abans d'arrencar No pot: no sap quin usuari porta

El cas més valuós és el segon: si demà algú copia aquesta imatge a un manifest nou sense el securityContext complet, el USER del Dockerfile continua protegint. I en l'altre sentit, runAsNonRoot: true al manifest detecta si algú va reconstruir la imatge sense el USER i rebutja el pod en lloc d'arrencar-lo com a root en silenci.

Declarar l'usuari per NÚMERO

Això és crític i ja ho vam avançar a 08-02:

# MALAMENT: nom d'usuari
RUN adduser -D -u 10001 aplicacio
USER aplicacio

# BÉ: número
RUN adduser -D -u 10001 aplicacio
USER 10001

Amb un nom, Kubernetes no pot verificar runAsNonRoot: true, perquè per resoldre'l hauria de llegir el /etc/passwd de dins de la imatge abans d'arrencar-la:

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

El pod no arrenca. Una fallada desconcertant la causa de la qual és al Dockerfile, no al manifest.

Crear un usuari en una imatge sense gestor d'usuaris

A distroless no hi ha adduser. Les imatges amb el sufix :nonroot ja porten l'usuari 65532 creat, que és el més còmode. Per a scratch, es copia un /etc/passwd fabricat a l'etapa de construcció:

FROM golang:1.23-alpine AS constructor
WORKDIR /construccio
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Binari estàtic: sense dependències del sistema
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o exportador ./cmd/exportador

# Fabricar un /etc/passwd mínim amb un sol usuari
RUN echo "exportador:x:10005:10005::/:/sbin/nologin" > /passwd-minim

FROM scratch
# Certificats arrel: sense ells no es pot fer HTTPS
COPY --from=constructor /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=constructor /passwd-minim /etc/passwd
COPY --from=constructor /construccio/exportador /exportador
USER 10005
ENTRYPOINT ["/exportador"]

La imatge resultant conté tres fitxers: el binari, els certificats i el /etc/passwd. Res més. No hi ha shell que obrir, no hi ha curl que fer servir, no hi ha gestor de paquets que instal·li res. És la superfície d'atac més petita possible.

Un detall que sorprèn: sense els certificats arrel, qualsevol connexió HTTPS falla amb un error de verificació de certificat. És el problema més habitual en passar a scratch.

Comprovació

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

Amb distroless això falla perquè no hi ha id. L'alternativa és inspeccionar les metadades:

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

I una comprovació automatitzable per a la canalització:

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

if [[ -z "$usuari" || "$usuari" == "root" || "$usuari" == "0" ]]; then
  echo "FALLA: $IMATGE s'executa com a root (USER='$usuari')"
  exit 1
fi
if ! [[ "$usuari" =~ ^[0-9]+$ ]]; then
  echo "FALLA: $IMATGE declara l'usuari per nom ('$usuari'), no per número."
  echo "       runAsNonRoot no ho podrà verificar i el pod no arrencarà."
  exit 1
fi
echo "OK: $IMATGE s'executa com a UID $usuari"

  1. Identificar la imatge sense ambigüitat: etiquetes i digests

Aquesta és, probablement, la part amb més conseqüències pràctiques de la lliçó.

Per què latest és inacceptable

image: registry.rutasnorte.example/api-reserves:latest   # MAI

latest no significa "l'última": és simplement l'etiqueta que es fa servir per defecte quan no n'especifiques cap. No hi ha cap garantia que apunti a res en concret.

Els problemes, un a un:

Problema Conseqüència
No saps quina versió corre Impossible correlacionar un error amb una versió del codi
Rèpliques diferents poden tenir versions diferents Si una rèplica es recrea, descarrega el que hi hagi ara. Pots tenir tres rèpliques amb tres versions
Els rollbacks no funcionen kubectl rollout undo (02-04) reverteix al Deployment anterior, la imatge del qual era... latest
No és reproduïble El mateix manifest aplicat avui i demà dona resultats diferents
No hi ha revisió de canvis El contingut canvia sense que es modifiqui cap fitxer de Git

Aquell segon punt és especialment insidiós perquè produeix incidències impossibles de diagnosticar: la meitat de les peticions funcionen i l'altra meitat no, segons a quina rèplica arribin.

A 08-03 vam escriure una ValidatingAdmissionPolicy que prohibeix latest a nivell de clúster. Aquella política és la que converteix aquesta recomanació en una garantia.

Etiquetes immutables

L'escaló següent és fer servir etiquetes que, per convenció i per configuració del registre, mai no es reassignen:

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

Esquemes habituals:

Esquema Exemple Avantatges
Versió semàntica 2.7.1 Llegible; comunica l'abast del canvi
Hash del commit a3f9c1d Traçabilitat directa al codi
Combinat 2.7.1-a3f9c1d El millor de tots dos: la recomanació
Data i construcció 2026.08.06-142 Útil si no hi ha versionatge formal

La clau és la paraula immutable: un cop publicada 2.7.1, aquella etiqueta mai no es reassigna a un altre contingut. Si cal corregir alguna cosa, es publica 2.7.2. Molts registres permeten aplicar aquella regla del costat del servidor (apartat 6), i aleshores deixa de ser una convenció per ser una garantia.

El digest: l'única referència realment reproduïble

Fins i tot amb etiquetes immutables per convenció, l'etiqueta és un punter mutable: si algú amb accés al registre la reassigna, el clúster descarregarà una altra cosa.

El digest és el hash SHA-256 del manifest de la imatge. És criptogràficament immutable: si el contingut canvia, el digest canvia. No es pot reassignar perquè no és un nom, és una empremta del contingut.

image: registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
Referència Pot canviar el contingut? Llegible Recomanació
imatge:latest Sí, en qualsevol moment Mai
imatge:2.7 (es reassigna en cada pedaç) Evitar a producció
imatge:2.7.1 Només si algú la reassigna Acceptable amb immutabilitat del registre
imatge@sha256:... Impossible No La correcta per a producció

L'objecció pràctica és evident: un digest és illegible. La solució és escriure tots dos:

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

O, a la pràctica moderna, deixar que una eina ho gestioni. Kustomize (10-04) resol el digest automàticament:

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

I eines com Renovate o Dependabot actualitzen els digests amb una petició de canvi quan hi ha versions noves, amb la qual cosa el canvi d'imatge passa per revisió de codi com qualsevol altre canvi. Aquest és exactament l'objectiu.

Obtenir el digest

# D'una imatge al registre, sense descarregar-la
crane digest registry.rutasnorte.example/api-reserves:2.7.1
sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# De la imatge que està corrent ara mateix al clúster
kubectl get pods -n rutas-norte-pro -l app=api-reserves \
  -o jsonpath='{.items[*].status.containerStatuses[*].imageID}{"\n"}'
registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

Aquella segona ordre és or pur per investigar un incident: et diu exactament quin contingut s'està executant, no quina etiqueta es va demanar. Si el digest no coincideix amb el que esperaves, tens un problema per investigar urgentment.

Una comprovació molt útil: verificar que totes les rèpliques corren el mateix digest.

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

Dues línies per a dos contenidors. Si apareguessin tres línies per a api, hi ha rèpliques amb versions diferents.

  1. imagePullPolicy i el parany de l'etiqueta reutilitzada

imagePullPolicy determina quan el kubelet descarrega la imatge del registre.

Valor Comportament Implicació de seguretat
Always Consulta el registre a cada arrencada de contenidor Detecta la reassignació d'etiquetes; requereix el registre disponible
IfNotPresent Fa servir la còpia local del node si existeix Un node pot quedar-se amb una versió antiga indefinidament
Never Només fa servir la còpia local; falla si no hi és Només per a desenvolupament local amb imatges precarregades

Els valors per defecte, que sorprenen

Si no especifiques imagePullPolicy:

Referència de la imatge Valor per defecte
Etiqueta :latest Always
Qualsevol altra etiqueta IfNotPresent
Digest (@sha256:...) IfNotPresent

Aquell comportament té una lògica: amb un digest, IfNotPresent és perfectament segur perquè la còpia local és necessàriament el contingut correcte (si el digest coincideix, el contingut coincideix).

El parany de l'etiqueta reutilitzada

Aquí hi ha l'escenari que cal entendre:

  1. Es desplega api-reserves:2.7.1 amb imagePullPolicy: IfNotPresent.
  2. Els nodes A, B i C descarreguen la imatge i la guarden en caché.
  3. Algú reassigna l'etiqueta 2.7.1 al registre a un contingut diferent.
  4. Un pod es recrea al node D, que no tenia la imatge: descarrega la nova.
  5. Els pods d'A, B i C continuen executant l'antiga.

Resultat: rèpliques del mateix Deployment executant continguts diferents, amb el mateix nom d'imatge. I cap eina de Kubernetes no t'ho dirà: kubectl get pods mostra la mateixa imatge a tots.

Només mirant l'imageID (el digest efectiu) es veu la discrepància. Per això la comprovació de l'apartat anterior és tan valuosa.

I per això la solució de fons és fer servir digests, no ajustar imagePullPolicy:

Enfocament Resol el problema?
imagePullPolicy: Always amb etiqueta Parcialment: detecta el canvi, però executa el contingut nou sense que ningú l'hagi aprovat
Digest : és impossible que el contingut difereixi

Fixa't en el matís de la primera fila. Always no et protegeix d'una etiqueta reassignada maliciosament: et garanteix que tots els pods executen el contingut nou, que és exactament el que volia qui la va reassignar.

Altres consideracions d'Always

Aspecte Efecte
Disponibilitat Si el registre no respon, els pods no arrenquen. Una fallada del registre es converteix en una fallada de la plataforma
Latència d'arrencada Afegeix una consulta al registre en cada arrencada (ràpida si la imatge ja és en caché, però no gratis)
Autorització Comprova que l'imagePullSecret continua sent vàlid, cosa que revoca l'accés a nodes que ja tenien la imatge

Aquell últim punt és un benefici de seguretat real: amb IfNotPresent, un node que ja té la imatge en caché la continua fent servir encara que les credencials del registre s'hagin revocat.

La recomanació de Rutas Norte

containers:
  - name: api
    # Digest: el contingut és criptogràficament l'esperat
    image: registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
    imagePullPolicy: IfNotPresent   # segur amb digest, i no depèn del registre

Amb digest, IfNotPresent és alhora segur i robust. És el millor de tots dos mons.

Excepció: a rutas-norte-dev, on s'itera ràpid amb etiquetes mutables, Always és raonable. I un avís de 08-02 que s'aplica aquí: a minikube amb imatges construïdes localment, Always fa que el kubelet intenti descarregar del registre i falli; allà és on Never o IfNotPresent tenen sentit.

  1. El registre privat

Per què un registre propi

Raó Detall
Control d'accés Decideixes qui publica i qui descarrega
Punt de control únic Tot el que s'executa passa per un lloc on escanejar i signar
Disponibilitat No depens que un registre públic estigui accessible
Rèplica d'imatges públiques Una imatge pública pot desaparèixer o canviar; la teva còpia no
Auditoria Registre de qui va pujar i descarregar què, i quan

A 08-03 vam escriure la política de Kyverno que obliga que tota imatge vingui de registry.rutasnorte.example. El registre deixa de ser una comoditat per ser el punt on s'apliquen tots els controls.

imagePullSecrets a la ServiceAccount

Ja vam veure els imagePullSecrets a 03-02. La millora important és declarar-los a la ServiceAccount en lloc de a cada pod:

# Crear el secret amb les credencials del registre
apiVersion: v1
kind: Secret
metadata:
  name: registre-rutasnorte
  namespace: rutas-norte-pro
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64 del fitxer de configuració de Docker>
---
# Declarar-lo a la ServiceAccount: s'aplica a TOTS els pods que la fan servir
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
imagePullSecrets:
  - name: registre-rutasnorte
automountServiceAccountToken: true   # de 03-06: llegeix un ConfigMap per API
kubectl create secret docker-registry registre-rutasnorte \
  --namespace rutas-norte-pro \
  --docker-server=registry.rutasnorte.example \
  --docker-username=descarrega-pro \
  --docker-password="$(cat /ruta/segura/clau-descarrega)" \
  [email protected]

Avantatges de declarar-lo a la ServiceAccount:

  • No cal repetir-lo a cada Deployment: s'oblida menys.
  • Canviar les credencials és un sol objecte.
  • Es pot tenir una ServiceAccount amb accés a un repositori concret del registre i una altra amb accés a un altre.

I un advertiment de 08-01 que s'aplica directament: aquest Secret conté credencials del registre. Qui tingui list sobre secrets al namespace les pot llegir. Al nostre disseny, només plataforma.

Control d'accés al registre

El principi és la separació entre publicar i descarregar:

Identitat Permís Àmbit
ci-rutasnorte (canalització) Publicar (push) Només rutasnorte/*
descarrega-pro (nodes de producció) Només descarregar (pull) Només rutasnorte/*
descarrega-dev Només descarregar rutasnorte/*
plataforma (persones) Publicar i esborrar Tot, amb segon factor
desenvolupament (persones) Publicar Només rutasnorte/dev/*

Els nodes de producció mai no han de tenir credencials de publicació. Si algú compromet un node i l'imagePullSecret permet publicar, pot pujar imatges enverinades al registre del qual s'abasteix tota la plataforma. És una escalada d'un node al clúster sencer.

Immutabilitat d'etiquetes del costat del registre

La majoria de registres moderns permeten configurar que una etiqueta, un cop publicada, no es pugui reassignar:

# Exemple amb Harbor: regla d'immutabilitat al projecte
# Configuració > Immutabilitat d'etiquetes
#   Repositoris: **
#   Etiquetes: ** (excepte dev-*)

Això converteix "fem servir etiquetes immutables per convenció" en una garantia tècnica. Un intent de reassignar una etiqueta falla:

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

Continua sent recomanable fer servir digests, perquè la immutabilitat protegeix contra l'error i contra la reassignació, però un registre compromès a nivell d'emmagatzematge la podria burlar. El digest el verifica el mateix client.

Rèplica de les imatges públiques

Rutas Norte fa servir postgres:16.4, redis:7.4-alpine i nginx-unprivileged. Totes són imatges públiques excel·lents, i tot i així convé copiar-les al registre propi:

#!/usr/bin/env bash
# ci/replicar-imatges-publiques.sh
# Copia les imatges públiques al registre de l'empresa, per DIGEST.
set -euo pipefail

REGISTRE=registry.rutasnorte.example

# Format: origen[@digest] desti
IMATGES=(
  "docker.io/library/postgres:16.4-alpine    $REGISTRE/externes/postgres:16.4-alpine"
  "docker.io/library/redis:7.4-alpine        $REGISTRE/externes/redis:7.4-alpine"
  "docker.io/nginxinc/nginx-unprivileged:1.27-alpine $REGISTRE/externes/nginx-unprivileged:1.27-alpine"
)

for linia in "${IMATGES[@]}"; do
  read -r origen desti <<<"$linia"
  echo "== $origen -> $desti"

  # Resoldre el digest de l'origen i registrar-lo: queda constància de QUÈ es va copiar
  digest=$(crane digest "$origen")
  echo "   digest d'origen: $digest"

  # Copiar per digest, no per etiqueta: garanteix que copiem el que verifiquem
  crane copy "${origen%%:*}@${digest}" "$desti"

  # Escanejar abans de donar-la per bona (veure 08-06)
  trivy image --severity HIGH,CRITICAL --exit-code 1 "$desti" || {
    echo "   AVÍS: vulnerabilitats greus. Revisar abans de fer servir a producció."
  }

  # Signar la còpia (veure apartat 7)
  cosign sign --yes "$desti"
done

Què resol això:

Problema Com ho resol la rèplica
La imatge pública desapareix o s'esborra Tens la teva còpia
El registre públic no està disponible Els teus desplegaments continuen funcionant
La imatge pública canvia sense avís La teva còpia és la que vas verificar
Límits de descàrrega dels registres públics No els toques
No saps quines imatges públiques fas servir Són totes a externes/

Aquell últim punt té un valor d'inventari que se subestima: quan aparegui una vulnerabilitat greu en una llibreria, la pregunta serà "quines imatges nostres la contenen?", i tenir-ho tot en un registre propi fa que la resposta sigui una consulta en lloc d'una investigació.

  1. Signatura i verificació amb Cosign

Fins ara hem garantit quin contingut s'executa (digest). Falta garantir que aquell contingut l'ha aprovat algú de confiança.

El problema que resol la signatura

Un digest et diu que la imatge no ha canviat. No et diu si aquella imatge la va construir la teva canalització o algú que va robar les credencials del registre. La signatura criptogràfica respon a aquella pregunta: qui avala aquesta imatge.

Sigstore i Cosign

Sigstore és un projecte que fa la signatura d'artefactes de programari accessible. Cosign és la seva eina per signar imatges de contenidor.

Instal·lació:

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

Signatura amb parell de claus

El model clàssic:

# 1. Generar el parell de claus (una vegada)
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. Signar una imatge PER DIGEST (mai per etiqueta)
cosign sign --key cosign.key \
  registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
# 3. Verificar
cosign verify --key cosign.pub \
  registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b... | jq .
[
  {
    "critical": {
      "identity": { "docker-reference": "registry.rutasnorte.example/api-reserves" },
      "image": {
        "docker-manifest-digest": "sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0"
      },
      "type": "cosign container image signature"
    },
    "optional": {
      "commit": "a3f9c1d",
      "construida-per": "ci-rutasnorte",
      "data": "2026-08-04T09:14:22Z"
    }
  }
]

Signar sempre per digest, mai per etiqueta. Si signes imatge:2.7.1, Cosign resol el digest en aquell moment; però si algú reassigna l'etiqueta després, la signatura no correspon al nou contingut i la verificació falla, cosa que és correcta però confusa. Signar per digest deixa clar què s'està avalant.

El problema del parell de claus és on viu la clau privada. Si és a la canalització, qui comprometi la canalització pot signar qualsevol cosa. Es pot mitigar amb un KMS:

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

Signatura sense claus amb identitat OIDC

És el model que recomana Sigstore i resol el problema de la custòdia de claus.

Funciona així:

  1. Cosign obté un token OIDC d'un proveïdor d'identitat (la mateixa canalització, GitHub Actions, GitLab CI, Google, etc.).
  2. Sol·licita a Fulcio (l'autoritat certificadora de Sigstore) un certificat de curta durada, vàlid deu minuts, que vincula la identitat amb una clau efímera.
  3. Signa la imatge amb aquella clau.
  4. Publica la signatura i el certificat a Rekor, un registre públic de transparència, immutable i només d'addició.
  5. Descarta la clau privada.
# A la canalització: sense cap clau que custodiar
COSIGN_EXPERIMENTAL=1 cosign sign --yes \
  registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b...

Verificació per identitat, no per clau:

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

El que estàs verificant no és "aquesta imatge la va signar la clau X", sinó una cosa molt més útil: "aquesta imatge la va signar la canalització del repositori plataforma/* de git.rutasnorte.example".

Comparació:

Parell de claus Sense claus (OIDC)
Custòdia de la clau Problema real: cal guardar-la i rotar-la No hi ha clau que guardar
Si es roba la clau Es pot signar qualsevol cosa fins que es detecti La clau viu 10 minuts
Què verifica Que la va signar qui té la clau Quina identitat i des d'on
Registre de transparència Opcional : tota signatura queda registrada públicament
Funciona sense connexió Necessita Fulcio i Rekor (o instàncies pròpies)
Complexitat inicial Baixa Mitjana

Recomanació per a Rutas Norte: sense claus amb OIDC, fent servir el proveïdor d'identitat de la canalització. És més segur i elimina la feina de custodiar claus.

Nota sobre privacitat: el registre de transparència públic de Sigstore fa públics els noms de les imatges signades. Si això és un problema (revela noms interns de projectes), es pot desplegar una instància privada de Fulcio i Rekor.

Signar a la canalització

# .gitlab-ci.yml (fragment) — construcció, escaneig i signatura
construir-i-signar:
  stage: publicar
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    - VERSION="${CI_COMMIT_TAG:-0.0.0-${CI_COMMIT_SHORT_SHA}}"
    - IMATGE="registry.rutasnorte.example/api-reserves"

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

    # 2. Escanejar ABANS de publicar: si hi ha vulnerabilitats greus, s'atura (08-06)
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMATGE}:${VERSION}"

    # 3. Verificar que no corre com a root
    - ./ci/verificar-usuari-imatge.sh "${IMATGE}:${VERSION}"

    # 4. Publicar i resoldre el digest
    - docker push "${IMATGE}:${VERSION}"
    - DIGEST=$(crane digest "${IMATGE}:${VERSION}")
    - echo "Digest publicat: ${DIGEST}"

    # 5. Signar PER DIGEST, amb identitat OIDC de la canalització
    - cosign sign --yes "${IMATGE}@${DIGEST}"

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

    # 7. Publicar el digest perquè el desplegament el faci servir
    - echo "DIGEST_IMATGE=${DIGEST}" >> variables.env
  artifacts:
    reports:
      dotenv: variables.env

L'ordre importa: escanejar abans de publicar. Publicar primer i escanejar després significa que durant una estona hi ha una imatge vulnerable disponible perquè algú la desplegui.

  1. Verificar al clúster amb una política d'admissió

Signar a la canalització és la meitat de la feina. Si el clúster accepta qualsevol imatge, la signatura és un adorn: qui pugui saltar-se la canalització i publicar directament al registre desplega el que vulgui.

La verificació de signatures ha d'ocórrer al clúster, al control d'admissió. És l'única forma de garantir que absolutament tot el que s'executa ha passat pel procés.

Política de Kyverno amb verificació de signatura

Reprenem Kyverno de 08-03, ara amb verifyImages:

# k8s/politiques/verificar-firma-imatges.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verificar-firma-imatges
  annotations:
    policies.kyverno.io/title: Verificació de signatura d'imatges
    policies.kyverno.io/category: Cadena de subministrament
    policies.kyverno.io/severity: critical
    policies.kyverno.io/description: >-
      Tota imatge desplegada a rutas-norte-pre i rutas-norte-pro ha d'estar
      signada per la canalització oficial de Rutas Norte. Kyverno verifica la
      signatura contra el registre de transparència i, si és vàlida, MUTA el
      manifest substituint l'etiqueta pel digest verificat.
spec:
  validationFailureAction: Enforce
  background: false        # verifyImages només actua en admissió
  webhookTimeoutSeconds: 30  # verificar contra Rekor pot trigar
  rules:
    - name: firma-canalitzacio-oficial
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/*"
          # Substitueix l'etiqueta pel digest verificat al manifest
          mutateDigest: true
          # Exigeix que la referència sigui verificable
          required: true
          # Cacheja les verificacions per no consultar Rekor a cada pod
          useCache: true
          attestors:
            - count: 1
              entries:
                # Signatura sense claus amb identitat OIDC
                - keyless:
                    subject: "https://git.rutasnorte.example/plataforma/*"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev

        # Les imatges públiques replicades les signa l'equip de plataforma
        - imageReferences:
            - "registry.rutasnorte.example/externes/*"
          mutateDigest: true
          required: true
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexemple1234567890abcdefghij
                      klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789exemple==
                      -----END PUBLIC KEY-----

Aspectes clau del manifest:

  • mutateDigest: true és el més elegant d'aquesta política. Kyverno verifica la signatura, obté el digest i reescriu el manifest perquè faci servir el digest en lloc de l'etiqueta. Resol automàticament el problema de l'apartat 4: encara que el Deployment digui :2.7.1, el pod que es crea fa servir @sha256:..., el mateix que es va verificar.
  • required: true: si la imatge no encaixa amb cap regla de verificació, es rebutja. Sense això, una imatge d'un registre no contemplat passaria sense verificar.
  • useCache: true: sense caché, cada creació de pod consulta el registre de transparència, cosa que afegeix latència i crea una dependència externa al camí crític.
  • webhookTimeoutSeconds: 30: la verificació pot trigar. Si el temps d'espera s'esgota i failurePolicy és Fail, no es poden crear pods.
  • Dues regles: les nostres imatges es verifiquen per identitat OIDC; les públiques replicades, amb la clau de l'equip de plataforma que les va revisar i copiar.

Provant la política

# Imatge signada: passa
kubectl run prova-signada \
  --image=registry.rutasnorte.example/api-reserves:2.7.1 \
  -n rutas-norte-pro
pod/prova-signada created
# Comprovar que Kyverno va substituir l'etiqueta pel digest
kubectl get pod prova-signada -n rutas-norte-pro \
  -o jsonpath='{.spec.containers[0].image}{"\n"}'
registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0

Vam demanar una etiqueta i s'està executant un digest verificat. Exactament el que volíem.

# Imatge sense signar: es rebutja
kubectl run prova-sense-signar \
  --image=registry.rutasnorte.example/experiment:0.1.0 \
  -n rutas-norte-pro
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/rutas-norte-pro/prova-sense-signar was blocked due to the following policies

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

L'alternativa: policy-controller de Sigstore

Sigstore té el seu propi controlador d'admissió, especialitzat únicament en això:

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

I s'activa etiquetant el namespace, igual que el PSA:

kubectl label namespace rutas-norte-pro policy.sigstore.dev/include=true
Kyverno policy-controller
Abast Totes les polítiques del clúster Només signatures i atestacions
Altres polítiques (etiquetes, recursos) No
mutateDigest
Complexitat Un motor per a tot Una peça més

Per a Rutas Norte, Kyverno, perquè ja el tenim de 08-03 i evita afegir un altre webhook d'admissió. El policy-controller té sentit si no vols un motor de política general.

L'avís operatiu imprescindible

Aquesta política és de les que poden aturar la plataforma:

  • Si Rekor no respon i no hi ha caché, la verificació falla.
  • Si la identitat de la canalització canvia (es migra de proveïdor de CI), totes les signatures noves deixen de validar.
  • Si failurePolicy: Fail i Kyverno cau, no es pot crear cap pod.

Mitigacions obligatòries abans de posar Enforce:

  1. Començar amb validationFailureAction: Audit durant setmanes.
  2. useCache: true sempre.
  3. Kyverno amb tres rèpliques, PDB i antiafinitat (08-03).
  4. Un procediment documentat per desactivar la política en una emergència, amb qui ho pot fer i com es registra.
  5. Excloure kube-system i rutas-norte-sistema.

  1. SBOM i atestacions de procedència

SBOM: l'inventari de components

Un SBOM (Software Bill of Materials) és la llista completa de tot el que hi ha dins d'una imatge: cada paquet del sistema, cada llibreria, cada dependència transitiva, amb la seva versió i la seva llicència.

# Generar el SBOM d'una imatge
syft registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b... \
  -o spdx-json > sbom-api-reserves.json
# Veure un resum llegible
syft registry.rutasnorte.example/api-reserves: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
...

Formats estàndard: SPDX (norma ISO) i CycloneDX (OWASP). Tots dos valen; l'important és tenir-ne un.

Adjuntar-lo a la imatge com a atestació signada:

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

Així el SBOM viatja amb la imatge, signat, i es pot recuperar en qualsevol moment:

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

Per què importa quan apareix una vulnerabilitat greu

Aquest és l'escenari que justifica tot l'esforç. Un dimarts qualsevol es publica una vulnerabilitat crítica en una llibreria de registre molt utilitzada. La pregunta que tota l'organització necessita respondre en minuts, no en dies, és:

És aquella llibreria en alguna de les nostres imatges? En quines? Amb quina versió? Quines són a producció?

Sense SBOM, la resposta implica descarregar cada imatge, inspeccionar-la, revisar els fitxers de dependències de cada repositori i esperar no haver-se deixat res. Dies de feina, amb incertesa al final.

Amb SBOM:

#!/usr/bin/env bash
# ci/buscar-component.sh — quines imatges contenen aquest component?
COMPONENT="$1"

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

Dos minuts. I amb certesa, no amb esperança.

Atestacions de procedència (SLSA)

SLSA (Supply-chain Levels for Software Artifacts) és un marc que defineix nivells de garantia sobre com es va construir un artefacte. Una atestació de procedència respon a:

Pregunta Què conté l'atestació
De quin codi font va sortir? Repositori i hash del commit
Qui la va construir? Identitat del sistema de construcció
Quan? Marca de temps
Amb quins paràmetres? Arguments de construcció, variables
En quin entorn? Imatge de l'executor, versió de les eines
cosign attest --yes --predicate procedencia.json --type slsaprovenance \
  registry.rutasnorte.example/api-reserves@sha256:9f2c1d4e8a7b...

Els nivells de SLSA, resumits:

Nivell Què exigeix
L1 La construcció està automatitzada i genera procedència
L2 La procedència està signada per un servei de construcció allotjat
L3 El servei de construcció està endurit, amb aïllament entre construccions

Un objectiu raonable per a una plataforma com Rutas Norte és L2: construcció automatitzada en un servei allotjat, amb procedència signada. L3 requereix un servei de construcció amb garanties fortes d'aïllament, cosa que sol implicar infraestructura específica.

I la peça que tanca el cercle: exigir l'atestació en admissió.

# Fragment de la política de Kyverno: exigir SBOM a més de signatura
      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 declari almenys un paquet:
                    # descarta atestacions buides o mal generades
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

Amb això, una imatge sense SBOM no es desplega a producció. La conseqüència pràctica és que l'inventari mai no es queda desactualitzat, perquè és impossible desplegar alguna cosa que no estigui inventariada.

  1. Política d'imatges base i actualització periòdica

El problema de l'envelliment

Una imatge construïda avui amb node:22.7.0-alpine no té vulnerabilitats conegudes. D'aquí a tres mesos, aquella mateixa imatge —sense que ningú l'hagi tocada— en tindrà unes quantes, perquè s'hauran publicat vulnerabilitats en paquets que conté.

Una imatge no es degrada: el món canvia al seu voltant.

Això té una conseqüència que molta gent no interioritza: si una imatge fa sis mesos que és a producció sense reconstruir-se, acumula sis mesos de vulnerabilitats sense apedaçar, encara que el codi de l'aplicació sigui perfecte.

La reconstrucció periòdica

La solució és reconstruir amb regularitat, encara que el codi no canviï.

# .gitlab-ci.yml — reconstrucció setmanal programada
reconstruccio-setmanal:
  stage: publicar
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule" && $TIPUS_PROGRAMAT == "reconstruccio"
  script:
    # Forçar la descàrrega de la imatge base més recent del rang permès
    - docker build --pull --no-cache -t "${IMATGE}:${VERSION}-b${CI_PIPELINE_IID}" .
    - trivy image --severity HIGH,CRITICAL --exit-code 1 "${IMATGE}:${VERSION}-b${CI_PIPELINE_IID}"
    - docker push "${IMATGE}:${VERSION}-b${CI_PIPELINE_IID}"
    - DIGEST=$(crane digest "${IMATGE}:${VERSION}-b${CI_PIPELINE_IID}")
    - cosign sign --yes "${IMATGE}@${DIGEST}"
    # Obrir automàticament una PR que actualitzi el digest als manifests
    - ./ci/obrir-pr-actualitzacio-digest.sh "${DIGEST}"

Per què reconstruir setmanalment és més barat que apedaçar amb presses

Aquesta és la part de l'argument que cal saber defensar davant de qui pregunti "per a què reconstruir si no hem canviat res?".

Reconstrucció setmanal programada Apedaçament d'urgència
Quan passa Dimarts al matí, planificat El dia que surt la vulnerabilitat, sigui quan sigui
Quant canvia Els pedaços d'una setmana Mesos de canvis acumulats
Risc de trencament Baix: poc delta Alt: molts canvis alhora
Es detecta a Les proves de la canalització Producció, amb presses
Pressió Cap Màxima
Cost Minuts de CPU a la CI Hores de diverses persones, amb risc d'incidència
Funciona el procediment? Es prova cada setmana Es descobreix si funciona el dia que cal

Aquell últim punt és el decisiu. Un procediment de reconstrucció que s'executa cada setmana està provat. Un que s'executa una vegada l'any, quan hi ha una crisi, està sense provar exactament quan més importa.

La política d'imatges base de Rutas Norte

# k8s/politiques/imatges-base-permeses.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: imatges-base-permeses
  namespace: rutas-norte-sistema
data:
  politica.yaml: |
    # Imatges base aprovades per construir imatges de Rutas Norte.
    # Revisió trimestral per l'equip de plataforma amb seguretat.
    aprovades:
      - patro: "gcr.io/distroless/*"
        justificacio: "Superfície mínima. Preferida per a aplicacions pròpies."
        responsable: plataforma
      - patro: "docker.io/library/alpine:3.20*"
        justificacio: "Quan cal shell o utilitats del sistema."
        responsable: plataforma
      - patro: "docker.io/library/node:22.*-alpine"
        justificacio: "Etapa de construcció d'aplicacions Node.js."
        responsable: plataforma
      - patro: "docker.io/library/postgres:16.*-alpine"
        justificacio: "Base de dades. Només versions 16.x amb suport."
        responsable: plataforma
      - patro: "docker.io/library/redis:7.4*-alpine"
        justificacio: "Caché de disponibilitat."
        responsable: plataforma

    prohibides:
      - patro: "*:latest"
        rao: "No reproduïble."
      - patro: "docker.io/*/  # comptes d'usuari, no oficials"
        rao: "Origen no verificable; fer servir imatges oficials o construir la nostra."

    regles:
      - "Tota imatge base s'ha de replicar a registry.rutasnorte.example/externes/."
      - "Tota imatge base s'ha d'escanejar abans de replicar-se."
      - "Reconstrucció setmanal de totes les imatges pròpies (dimarts 06:00)."
      - "Actualització de versió major de la base: canvi revisat, mai automàtic."
      - "Una imatge base sense actualitzacions de seguretat durant 6 mesos es marca
         per a revisió: pot estar abandonada."

I eines que automatitzen el seguiment:

Eina Què fa
Renovate / Dependabot Obre PR quan hi ha una versió nova de la imatge base o d'una dependència
crane digest a la CI Detecta si l'etiqueta de la base va canviar de contingut
Escaneig continu del registre (08-06) Alerta de vulnerabilitats noves en imatges ja publicades

  1. La política completa d'imatges de Rutas Norte

Reunim tot en el document que regeix la plataforma.

Qui pot publicar

Identitat Repositori Permís Requisits
ci-rutasnorte rutasnorte/* Publicar Només des de branques protegides del repositori oficial
desenvolupament (grup) dev/* Publicar Per a proves; mai desplegable a pre ni pro
plataforma (grup) externes/* Publicar Rèplica de públiques, després d'escaneig i signatura manual
plataforma (grup) Tots Esborrar Amb segon factor i registre de l'operació
descarrega-pro rutasnorte/*, externes/* Només descarregar Credencial dels nodes de producció

Requisits per entrar a rutas-norte-pro

Una imatge només es desplega a producció si compleix tots aquests requisits, i cadascun el verifica un mecanisme concret:

# Requisit Verificat per
1 Prové de registry.rutasnorte.example Política Kyverno registre-obligatori (08-03)
2 Es referencia per digest mutateDigest: true de Kyverno
3 No fa servir l'etiqueta latest ValidatingAdmissionPolicy (08-03)
4 Està signada per la canalització oficial Política Kyverno verificar-firma-imatges
5 SBOM adjunt i signat Condició d'atestació a Kyverno
6 Té atestació de procedència Condició d'atestació a Kyverno
7 Sense vulnerabilitats crítiques sense excepció documentada Trivy a la canalització (08-06)
8 Declara usuari no root numèric verificar-usuari-imatge.sh a la CI
9 Base a la llista d'aprovades Revisió de codi del Dockerfile
10 Reconstruïda en els últims 30 dies Treball programat que alerta
11 Ha passat per rutas-norte-pre Procediment de promoció

El flux complet

flowchart TD
    A["Commit a branca protegida"] --> B["CI: construcció multietapa<br/>base aprovada"]
    B --> C["Escaneig amb Trivy<br/>crític = falla"]
    C -->|Falla| X1["Construcció aturada"]
    C -->|Passa| D["Verificar usuari no root"]
    D --> E["Publicar al registre"]
    E --> F["Resoldre el digest"]
    F --> G["Signar amb Cosign<br/>identitat OIDC"]
    G --> H["Generar i adjuntar SBOM<br/>+ procedència"]
    H --> I["Desplegar a rutas-norte-pre"]
    I --> J["Proves d'integració"]
    J -->|Passen| K["Promoció a rutas-norte-pro"]
    K --> L["Admissió: PSA + VAP + Kyverno"]
    L -->|Signatura no vàlida| X2["Pod rebutjat"]
    L -->|Sense SBOM| X2
    L -->|Registre incorrecte| X2
    L -->|Tot correcte| M["Pod en execució<br/>amb digest verificat"]

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

El manifest final d'api-reserves

# k8s/entorns/pro/api-reserves.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reserves
  namespace: rutas-norte-pro
  labels:
    app: api-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
  annotations:
    # Traçabilitat completa de què s'està executant
    imatge.rutasnorte.example/versio: "2.7.1"
    imatge.rutasnorte.example/commit: "a3f9c1d"
    imatge.rutasnorte.example/construida: "2026-08-04T09:14:22Z"
    imatge.rutasnorte.example/signada-per: "ci-rutasnorte"
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reserves
  template:
    metadata:
      labels:
        app: api-reserves
        app.kubernetes.io/part-of: rutas-norte
        entorn: pro
    spec:
      serviceAccountName: api-reserves    # amb imagePullSecrets declarats
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532                  # coincideix amb el USER del Dockerfile
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          # v2.7.1 (a3f9c1d) — digest verificat, signatura comprovada en admissió
          image: registry.rutasnorte.example/rutasnorte/api-reserves@sha256:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0
          imagePullPolicy: IfNotPresent   # segur amb digest
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metriques, containerPort: 9090 }
          livenessProbe:
            httpGet: { path: /salut, port: http }
            initialDelaySeconds: 10
          readinessProbe:
            httpGet: { path: /preparat, port: http }
            periodSeconds: 5
          volumeMounts:
            - { name: temporal, mountPath: /tmp }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { memory: 512Mi }
        - name: ambaixador-pagaments
          image: registry.rutasnorte.example/rutasnorte/ambaixador-pagaments@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 }

Aquest manifest reuneix el mòdul sencer: la ServiceAccount de 03-06 i 08-01, el securityContext de 08-02, el compliment de restricted de 08-03, i la referència per digest verificat d'aquesta lliçó.

Errors Comuns i Consells

Fer servir latest en qualsevol entorn que no sigui un experiment local. Trenca els rollbacks, fa irreproduïbles els desplegaments i pot deixar rèpliques amb versions diferents.

Fer servir etiquetes mòbils com :2.7 o :16. Es reassignen en cada pedaç. Semblen versions concretes i no ho són.

Creure que imagePullPolicy: Always protegeix d'una etiqueta reassignada. Garanteix que totes les rèpliques executin el contingut nou, que és just el que buscava qui la va reassignar. La solució és el digest.

Declarar el USER per nom al Dockerfile. runAsNonRoot: true no ho pot verificar i el pod no arrenca, amb un error la causa del qual és en un altre fitxer.

Deixar secrets a les capes de la imatge. Esborrar-los en un RUN posterior no els treu de la capa anterior. Fes servir RUN --mount=type=secret.

Fer servir npm install en lloc de npm ci. Pot resoldre versions diferents en cada construcció, fent la imatge irreproduïble.

Incloure dependències de desenvolupament a la imatge final. Marcs de proves i linters no pinten res a producció i solen ser la meitat de l'arbre de dependències.

Passar a distroless sense pla de depuració. No hi ha shell. Cal dominar kubectl debug amb contenidors efímers abans de necessitar-ho en una incidència.

Oblidar els certificats arrel en fer servir scratch. Totes les connexions HTTPS fallen amb un error de verificació de certificat.

Signar per etiqueta en lloc de per digest. Deixa ambigüitat sobre quin contingut es va avalar.

Guardar la clau privada de signatura a la canalització. Qui comprometi la CI pot signar qualsevol cosa. Fes servir signatura sense claus amb OIDC, o un KMS.

Signar a la canalització i no verificar al clúster. La signatura sense verificació és un adorn: qui pugui publicar directament al registre desplega el que vulgui.

Posar la política de verificació en Enforce des del primer dia. Pot aturar la plataforma sencera. Audit durant setmanes, useCache: true, alta disponibilitat del motor i un procediment d'emergència documentat.

Escanejar després de publicar. Durant aquell interval hi ha una imatge vulnerable disponible per desplegar.

Donar credencials de publicació als nodes de producció. Un node compromès pot enverinar el registre del qual s'abasteix tota la plataforma.

No reconstruir les imatges que no canvien. Acumulen mesos de vulnerabilitats sense apedaçar. Reconstrueix setmanalment.

Consell d'or: la pregunta que has de poder respondre en qualsevol moment és "què està corrent exactament a producció ara mateix, qui ho va aprovar i què conté?". Digest per al què, signatura per al qui, SBOM per al contingut. Si falta una de les tres, tens un buit.

Exercicis

Exercici 1: endurir un Dockerfile

Aquest és el Dockerfile actual de worker-notificacions:

FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
  1. Enumera tots els problemes de seguretat i de qualitat que té.
  2. Reescriu-lo amb construcció multietapa, base mínima i usuari no root.
  3. Escriu les ordres que verificarien que la imatge resultant compleix els requisits de la política de Rutas Norte.

Exercici 2: d'etiqueta a digest verificat

El Deployment de botiga-web a producció fa servir:

image: registry.rutasnorte.example/rutasnorte/botiga-web:1.9
imagePullPolicy: Always
  1. Explica els tres riscos concrets d'aquesta configuració.
  2. Escriu les ordres per obtenir el digest, verificar-ne la signatura i inspeccionar-ne el SBOM.
  3. Escriu el fragment corregit del manifest.
  4. Com comprovaries que les tres rèpliques estan executant el mateix contingut?

Exercici 3: política d'admissió amb verificació de signatura per a un component nou

Rutas Norte incorpora passarela-sms, un component que envia avisos per SMS de canvis d'horari. Es construeix en un repositori diferent (https://git.rutasnorte.example/comunicacions/passarela-sms) per un equip diferent.

Escriu la regla de Kyverno que cal afegir a la política verificar-firma-imatges perquè:

  • Només s'acceptin imatges de registry.rutasnorte.example/rutasnorte/passarela-sms*.
  • Signades des del repositori comunicacions/passarela-sms (i no des d'un altre).
  • Amb SBOM adjunt que contingui almenys un paquet.
  • Substituint l'etiqueta pel digest verificat.

Indica també com la desplegaries sense risc de bloquejar desplegaments legítims, i què comprovaries abans de passar-la a Enforce.

Solucions

Solució 1

1. Problemes del Dockerfile original:

# Problema Conseqüència
1 FROM node:22 sense versió de pedaç No reproduïble: 22 es reassigna constantment
2 Imatge base Debian completa (~1,1 GB) Centenars de paquets innecessaris, desenes de vulnerabilitats
3 Sense construcció multietapa Compiladors i gestor de paquets a la imatge final
4 COPY . . abans d'instal·lar Trenca la caché de capes i pot copiar .env, .git o claus
5 Sense .dockerignore Agreuja el problema anterior
6 npm install en lloc de npm ci Pot resoldre versions diferents en cada construcció
7 Inclou dependències de desenvolupament Marc de proves i linters a producció
8 Sense USER: corre com a root Incompleix runAsNonRoot; el pod ni tan sols arrenca amb el PSA de 08-03
9 CMD npm start en forma shell El procés 1 és sh, que no reenvia els senyals: el pod no acaba netament i triga 30 s a morir
10 Sense etiquetes OCI Sense traçabilitat de l'origen
11 Cachés d'npm a la imatge Mida innecessària

El problema 9 és subtil i molt real: amb la forma shell, el PID 1 és l'intèrpret d'ordres, que no propaga SIGTERM al procés de Node. Kubernetes espera el terminationGracePeriodSeconds complet i després mata el pod amb SIGKILL, tallant les notificacions en curs.

2. Dockerfile reescrit:

# Dockerfile de worker-notificacions

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

WORKDIR /construccio

# Manifests primer: aprofita la caché de capes
COPY package.json package-lock.json ./

# npm ci: reproduïble; --omit=dev: sense dependències de desenvolupament
RUN npm ci --omit=dev && npm cache clean --force

COPY src/ ./src/
RUN npm run build

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

WORKDIR /app

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

# Usuari no root, PER NÚMERO (el nonroot de distroless)
USER 65532

EXPOSE 3000

LABEL org.opencontainers.image.title="worker-notificacions" \
      org.opencontainers.image.description="Enviament de correus de confirmació de reserva" \
      org.opencontainers.image.vendor="Rutas Norte S.L." \
      org.opencontainers.image.source="https://git.rutasnorte.example/plataforma/worker-notificacions" \
      org.opencontainers.image.licenses="Proprietary"

# Forma exec: el procés de Node és el PID 1 i rep SIGTERM directament
CMD ["dist/worker.js"]

I el .dockerignore, que resol els problemes 4 i 5:

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

.env i .git en aquella llista no són opcionals: COPY . . sense .dockerignore pot ficar credencials de desenvolupament i tot l'historial del repositori dins de la imatge, on queden per sempre.

3. Verificació:

IMATGE=registry.rutasnorte.example/rutasnorte/worker-notificacions:3.2.0

# a) Usuari no root i numèric
docker inspect "$IMATGE" --format '{{.Config.User}}'
65532
# b) Mida (comparació amb l'original)
docker images "$IMATGE" --format '{{.Size}}'
158MB
# c) Sense shell (confirma que és distroless)
docker run --rm --entrypoint sh "$IMATGE" -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) Sense vulnerabilitats greus
trivy image --severity HIGH,CRITICAL --exit-code 1 "$IMATGE"
registry.rutasnorte.example/rutasnorte/worker-notificacions:3.2.0 (debian 12.7)
Total: 0 (HIGH: 0, CRITICAL: 0)
# e) Sense secrets a les capes
trivy image --scanners secret "$IMATGE"
Total: 0
# f) Signatura vàlida
cosign verify \
  --certificate-identity-regexp "https://git.rutasnorte.example/plataforma/.*" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "$IMATGE" >/dev/null && echo "Signatura verificada"
Signatura verificada
# g) SBOM present
cosign download attestation "$IMATGE" | jq -r '.payload' | base64 -d \
  | jq '.predicate.packages | length'
187

De 412 paquets a la versió original a 187: menys de la meitat de superfície per vigilar.

Solució 2

1. Els tres riscos:

Risc Detall
Etiqueta mòbil :1.9 És una etiqueta de versió menor: es reassigna amb cada pedaç (1.9.1, 1.9.2...). El contingut canvia sense que ningú modifiqui el manifest ni passi per revisió de codi
Sense verificació de contingut Res no garanteix que el contingut de :1.9 sigui el que es va aprovar. Si algú amb accés al registre la reassigna, el clúster executa el nou
imagePullPolicy: Always com a falsa protecció Garanteix que totes les rèpliques executin el contingut actual de l'etiqueta. Davant d'una reassignació maliciosa, això propaga el problema a tota la plataforma en lloc de contenir-lo. A més, si el registre no respon, els pods no arrenquen

2. Ordres:

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

# a) Obtenir el digest actual de l'etiqueta
crane digest "$IMG:1.9"
sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
# b) Verificar la signatura (per digest, no per 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-per": "ci-rutasnorte",
  "data": "2026-07-28T14:02:11Z",
  "Bundle": { "SignedEntryTimestamp": "..." }
}

Nota: la data diu 2026-07-28, fa nou dies. Dins del marge de 30 dies de la política, però convé tenir-ho present.

# 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. Manifest corregit:

# k8s/entorns/pro/botiga-web.yaml (fragment)
spec:
  template:
    spec:
      containers:
        - name: nginx
          # botiga-web v1.9.3 (commit e7b4c2a, construïda 2026-07-28)
          # Signatura verificada: ci-rutasnorte / plataforma/botiga-web
          image: registry.rutasnorte.example/rutasnorte/botiga-web@sha256:7d3b2f8e1a6c9045d2e8b3f7a1c5e9d4b8f2a6c1e5d9b3f7a2c6e1d5b9f3a7c2
          imagePullPolicy: IfNotPresent   # segur amb digest i no depèn del registre

I amb Kustomize (10-04), per no escriure el digest a mà a cada entorn:

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

4. Comprovar que les tres rèpliques executen el mateix:

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

I la versió que retorna un únic valor si tot està bé:

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

Un 1 significa que totes les rèpliques executen el mateix contingut. Qualsevol número superior és un incident que cal investigar immediatament: significa que hi ha rèpliques amb continguts diferents sota el mateix nom.

Solució 3

# Regla que s'AFEGEIX a spec.rules de la política verificar-firma-imatges
    - name: firma-passarela-sms
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      verifyImages:
        - imageReferences:
            - "registry.rutasnorte.example/rutasnorte/passarela-sms*"
          mutateDigest: true      # substitueix l'etiqueta pel digest verificat
          required: true          # sense signatura verificable, es rebutja
          useCache: true          # evita consultar Rekor a cada creació de pod
          attestors:
            - count: 1
              entries:
                - keyless:
                    # Identitat EXACTA del repositori de comunicacions.
                    # Sense comodí al final: només aquest repositori, no
                    # qualsevol altre del grup comunicacions.
                    subject: "https://git.rutasnorte.example/comunicacions/passarela-sms"
                    issuer: "https://git.rutasnorte.example"
                    rekor:
                      url: https://rekor.sigstore.dev
          # Exigir SBOM signat per la mateixa identitat
          attestations:
            - type: https://spdx.dev/Document
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        subject: "https://git.rutasnorte.example/comunicacions/passarela-sms"
                        issuer: "https://git.rutasnorte.example"
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: "{{ length(packages) }}"
                      operator: GreaterThan
                      value: 0

Decisions a destacar:

  • subject sense comodí. Escriure comunicacions/* permetria que qualsevol repositori d'aquell grup signés imatges de passarela-sms. En fixar la ruta exacta, només la canalització d'aquell repositori concret pot avalar aquestes imatges. És el mateix principi de mínim privilegi de 08-01, aplicat a la signatura.
  • Regla separada, no ampliar l'existent. Podríem haver afegit comunicacions/* al subject de la regla principal, però això permetria que l'equip de comunicacions signés imatges d'api-reserves. Cada repositori signa el seu.
  • El patró passarela-sms* amb asterisc cobreix variants com passarela-sms-worker si el component creix. Si es prefereix ser estricte, es treu l'asterisc.

Desplegament sense risc:

# 1. Aplicar la regla en mode Audit (no bloqueja)
#    S'edita una còpia amb validationFailureAction: Audit
sed 's/validationFailureAction: Enforce/validationFailureAction: Audit/' \
  k8s/politiques/verificar-firma-imatges.yaml | kubectl apply -f -

# 2. Desplegar passarela-sms a rutas-norte-pre i comprovar l'informe
kubectl apply -f k8s/entorns/pre/passarela-sms.yaml

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

cosign verify \
  --certificate-identity "https://git.rutasnorte.example/comunicacions/passarela-sms" \
  --certificate-oidc-issuer "https://git.rutasnorte.example" \
  "${IMG}@${DIGEST}" | jq -r '.[0].optional.Issuer, .[0].optional.Subject'
# 4. Comprovar que una imatge SENSE signar seria rebutjada (prova negativa)
kubectl run prova-sense-signatura \
  --image=registry.rutasnorte.example/rutasnorte/passarela-sms:experimental \
  -n rutas-norte-pre --dry-run=server
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
... firma-passarela-sms: 'failed to verify image ... no signatures found'
# 5. Només aleshores, passar a Enforce
kubectl apply -f k8s/politiques/verificar-firma-imatges.yaml

Què comprovar abans d'Enforce:

# Comprovació Per què
1 L'informe de política no té cap fail a pre ni a pro Un fail en Enforce és un desplegament bloquejat
2 La canalització de comunicacions/passarela-sms signa correctament Si no signa, tots els seus desplegaments es bloquejaran
3 El SBOM es genera i s'adjunta en aquella canalització La condició d'atestació ho exigeix
4 useCache: true està actiu Sense caché, cada pod consulta Rekor: latència i dependència externa
5 La prova negativa rebutja una imatge sense signar Confirma que la política funciona de debò
6 Kyverno té 3 rèpliques, PDB i antiafinitat El motor és una dependència de l'apiserver (08-03)
7 Existeix un procediment d'emergència documentat Qui pot desactivar la política, com, i com es registra
8 rutas-norte-sistema i kube-system estan exclosos Els components d'infraestructura fan servir imatges externes

El punt 7 mereix èmfasi. Una política de verificació de signatures en Enforce pot impedir qualsevol desplegament si Rekor no respon o si la identitat de la canalització canvia. Ha d'existir un procediment escrit, amb noms, per desactivar-la en minuts, i aquell procediment ha d'haver-se assajat. Una mesura de seguretat que pot paralitzar la plataforma i no té sortida d'emergència és un risc operatiu, no una protecció.

Conclusió

Hem protegit la cadena de subministrament de les imatges de Rutas Norte:

  • Una imatge es pot enverinar en cinc punts: la imatge base, les dependències, el procés de construcció, el registre i la descàrrega. Cadascun necessita la seva defensa, i les defenses es reforcen entre si: el digest garanteix el contingut, la signatura garanteix l'aval, el SBOM garanteix l'inventari.
  • Imatges mínimes amb construcció multietapa: scratch per a binaris estàtics, distroless per a llenguatges amb runtime, alpine quan cal sistema operatiu. El cost és la depuració, que es resol amb contenidors efímers (kubectl debug).
  • Usuari no root numèric al Dockerfile, que es reforça amb el runAsNonRoot del manifest (08-02): cadascun protegeix el buit de l'altre.
  • latest és inacceptable i les etiquetes mòbils com :1.9 també. L'etiqueta immutable és acceptable; el digest és l'única referència criptogràficament reproduïble.
  • imagePullPolicy: Always no protegeix d'una etiqueta reassignada: garanteix que totes les rèpliques executin el contingut nou. Amb digest, IfNotPresent és segur i no depèn que el registre respongui.
  • El registre privat és el punt de control únic: imagePullSecrets a la ServiceAccount, separació estricta entre publicar i descarregar, immutabilitat d'etiquetes del costat del servidor, i rèplica de les imatges públiques que es fan servir.
  • Cosign signa les imatges, preferiblement sense claus amb identitat OIDC, que elimina el problema de custodiar la clau privada i permet verificar qui i des d'on va signar.
  • Signar no n'hi ha prou: cal verificar al clúster. La política de Kyverno amb verifyImages i mutateDigest: true rebutja el no signat i substitueix l'etiqueta pel digest verificat.
  • El SBOM converteix "tenim aquella llibreria vulnerable?" en una consulta de dos minuts en lloc de dies d'investigació. Les atestacions de procedència (SLSA) responen a d'on va sortir la imatge i qui la va construir.
  • Reconstruir setmanalment és més barat que apedaçar amb presses: menys delta, menys risc, i sobretot un procediment provat en lloc d'un que s'estrena el dia de la crisi.
  • La política completa de Rutas Norte estableix qui publica on i els onze requisits que ha de complir una imatge per entrar a rutas-norte-pro, cadascun verificat per un mecanisme concret.

La plataforma està ara protegida en les quatre dimensions: qui pot fer què, què pot fer un contenidor, què parla amb què, i què s'executa exactament.

Però queden dues preguntes sense resposta, i són just les que fa qualsevol auditoria. La primera és "qui va fer què?": si demà descobrim que el Secret amb les credencials de postgres-reserves va ser llegit, o que un Deployment va desaparèixer, o que una ServiceAccount va fer alguna cosa estranya a les tres de la matinada, ara mateix no tenim manera de saber-ho. Hem posat portes, però no portem registre de qui les creua. La segona és "quins forats tinc ara mateix?": sabem escanejar una imatge abans de publicar-la, però les que fa mesos que són a producció acumulen vulnerabilitats noves cada setmana, i ningú no les està mirant.

L'última lliçó del mòdul, 08-06, Auditoria, Escaneig i Gestió de Vulnerabilitats, respon a totes dues: el registre d'auditoria de l'apiserver i com consultar-lo per respondre a preguntes concretes, l'escaneig continu amb Trivy dins i fora del clúster, l'avaluació davant dels estàndards CIS amb kube-bench, la detecció en temps d'execució amb Falco integrada amb l'Alertmanager del mòdul 7, i —el que de debò falta a la majoria d'equips— la gestió de vulnerabilitats com a procés, amb terminis, responsables i excepcions amb data de caducitat.

Curs de Kubernetes

Mòdul 1: Introducció a Kubernetes

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats