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-reservesiinformes-ocupacioa 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
- La cadena de subministrament d'una imatge
- Construir imatges mínimes
- Usuari no root al Dockerfile
- Identificar la imatge sense ambigüitat: etiquetes i digests
imagePullPolicyi el parany de l'etiqueta reutilitzada- El registre privat
- Signatura i verificació amb Cosign
- Verificar al clúster amb una política d'admissió
- SBOM i atestacions de procedència
- Política d'imatges base i actualització periòdica
- La política completa d'imatges de Rutas Norte
- Errors comuns i consells
- Exercicis
- Conclusió
- 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çó.
- 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 cien lloc denpm install.ciinstal·la exactament el que diupackage-lock.jsoni falla si el fitxer de bloqueig no està sincronitzat.installpot 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.--chownalCOPY: els fitxers queden amb el propietari correcte sense necessitat d'unRUN chownque 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=devComparació 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í | Sí (apt) | Molt fàcil | Desenes |
node:22-slim |
~250 MB | ~120 | Sí | Sí | 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:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknownNo 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 -- 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 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.
- 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
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 | Sí | Sí |
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 10001Amb 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-rootEl 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ó
Amb distroless això falla perquè no hi ha id. L'alternativa és inspeccionar les metadades:
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"
- 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
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:
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 | Sí | Mai |
imatge:2.7 |
Sí (es reassigna en cada pedaç) | Sí | Evitar a producció |
imatge:2.7.1 |
Només si algú la reassigna | Sí | 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0O, 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# 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0Aquella 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 -uapi 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.
imagePullPolicy i el parany de l'etiqueta reutilitzada
imagePullPolicy i el parany de l'etiqueta reutilitzadaimagePullPolicy 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:
- Es desplega
api-reserves:2.7.1ambimagePullPolicy: IfNotPresent. - Els nodes A, B i C descarreguen la imatge i la guarden en caché.
- Algú reassigna l'etiqueta
2.7.1al registre a un contingut diferent. - Un pod es recrea al node D, que no tenia la imatge: descarrega la nova.
- 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í: é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 registreAmb 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.
- 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 APIkubectl 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:
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"
doneQuè 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ó.
- 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 versionSignatura amb parell de claus
El model clàssic:
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í:
- Cosign obté un token OIDC d'un proveïdor d'identitat (la mateixa canalització, GitHub Actions, GitLab CI, Google, etc.).
- 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.
- Signa la imatge amb aquella clau.
- Publica la signatura i el certificat a Rekor, un registre públic de transparència, immutable i només d'addició.
- 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 certificatesEl 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 | Sí: tota signatura queda registrada públicament |
| Funciona sense connexió | Sí | 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.envL'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.
- 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 ifailurePolicyésFail, 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# 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:9f2c1d4e8a7b6035c1e4d9a2b8f7c3e6d5a4b3c2e1f0a9b8c7d6e5f4a3b2c1d0Vam 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-proError 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.devI s'activa etiquetant el namespace, igual que el PSA:
| Kyverno | policy-controller | |
|---|---|---|
| Abast | Totes les polítiques del clúster | Només signatures i atestacions |
| Altres polítiques (etiquetes, recursos) | Sí | No |
mutateDigest |
Sí | Sí |
| 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: Faili Kyverno cau, no es pot crear cap pod.
Mitigacions obligatòries abans de posar Enforce:
- Començar amb
validationFailureAction: Auditdurant setmanes. useCache: truesempre.- Kyverno amb tres rèpliques, PDB i antiafinitat (08-03).
- Un procediment documentat per desactivar la política en una emergència, amb qui ho pot fer i com es registra.
- Excloure
kube-systemirutas-norte-sistema.
- 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.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
...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'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"
doneregistry.rutasnorte.example/api-reserves@sha256:9f2c1d... -> jsonwebtoken 9.0.2
registry.rutasnorte.example/worker-notificacions@sha256:5e8a1b... -> jsonwebtoken 9.0.2Dos 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: 0Amb 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.
- 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 |
- 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 | Té 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:
- Enumera tots els problemes de seguretat i de qualitat que té.
- Reescriu-lo amb construcció multietapa, base mínima i usuari no root.
- 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:
- Explica els tres riscos concrets d'aquesta configuració.
- Escriu les ordres per obtenir el digest, verificar-ne la signatura i inspeccionar-ne el SBOM.
- Escriu el fragment corregit del manifest.
- 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}}'# c) Sense shell (confirma que és distroless)
docker run --rm --entrypoint sh "$IMATGE" -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-notificacions:3.2.0 (debian 12.7)
Total: 0 (HIGH: 0, CRITICAL: 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"# g) SBOM present
cosign download attestation "$IMATGE" | jq -r '.payload' | base64 -d \
| jq '.predicate.packages | length'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"# 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 -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. 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 registreI 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 -lUn 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: 0Decisions a destacar:
subjectsense comodí. Escriurecomunicacions/*permetria que qualsevol repositori d'aquell grup signés imatges depassarela-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/*alsubjectde 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 compassarela-sms-workersi 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 // "")"'# 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=serverError from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
... firma-passarela-sms: 'failed to verify image ... no signatures found'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:
scratchper a binaris estàtics,distrolessper a llenguatges amb runtime,alpinequan 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
runAsNonRootdel manifest (08-02): cadascun protegeix el buit de l'altre. latestés inacceptable i les etiquetes mòbils com:1.9també. L'etiqueta immutable és acceptable; el digest és l'única referència criptogràficament reproduïble.imagePullPolicy: Alwaysno 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:
imagePullSecretsa 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
verifyImagesimutateDigest: truerebutja 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
- Què és Kubernetes?
- Arquitectura de Kubernetes
- Conceptes i Terminologia Clau
- Configuració d'un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objectes, Manifests YAML i el Model Declaratiu
- El Projecte del Curs: la Plataforma Rutas Norte
Mòdul 2: Components Principals de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualitzacions, Rollbacks i Estratègies de Desplegament
- Serveis
- Namespaces
- Etiquetes, Selectors i Anotacions
Mòdul 3: Gestió de Configuració i Secrets
- ConfigMaps
- Secrets
- Variables d'Entorn
- Quotes i Límits de Recursos
- LimitRanges i Classes de Qualitat de Servei (QoS)
- ServiceAccounts i Accés a l'API des dels Pods
Mòdul 4: Xarxes a Kubernetes
- Xarxes de Clúster
- Tipus de Serveis
- DNS Intern i Descobriment de Serveis
- Controladors d'Ingress
- TLS i Gestió de Certificats amb cert-manager
- Polítiques de Xarxa
Mòdul 5: Emmagatzematge a Kubernetes
- Volums
- Volums Persistents
- Reclamacions de Volums Persistents
- Classes d'Emmagatzematge
- Aprovisionament Dinàmic, Expansió i Snapshots
- Còpies de Seguretat i Restauració de Dades
Mòdul 6: Conceptes Avançats de Kubernetes
- StatefulSets
- DaemonSets
- Treballs i CronJobs
- Init Containers, Sidecars i Patrons Multicontenidor
- Planificació: Afinitat, Taints i Toleracions
- Definicions de Recursos Personalitzats (CRDs)
- Operadors i el Patró Controlador
Mòdul 7: Monitoratge i Registre
- Verificacions de Salut i Sondes
- Servidor de Mètriques i kubectl top
- Monitoratge amb Prometheus
- Visualització i Alertes amb Grafana i Alertmanager
- Registre Centralitzat amb Elasticsearch, Fluentd i Kibana (EFK)
- Depuració d'Aplicacions i Esdeveniments del Clúster
Mòdul 8: Seguretat a Kubernetes
- Control d'Accés Basat en Rols (RBAC)
- Contextos de Seguretat i Enduriment del Contenidor
- Polítiques de Seguretat de Pods i Pod Security Standards
- Seguretat de Xarxa
- Seguretat d'Imatges
- Auditoria, Escaneig i Gestió de Vulnerabilitats
Mòdul 9: Escalat i Rendiment
- Autoescalat Horitzontal de Pods
- Autoescalat Vertical de Pods
- Autoescalat de Clúster
- Escalat per Esdeveniments i Mètriques Personalitzades amb KEDA
- Alta Disponibilitat: PodDisruptionBudgets i Topologia
- Ajust de Rendiment
Mòdul 10: Ecosistema i Eines de Kubernetes
- Minikube i Entorns Locals amb kind
- Kubeadm
- Helm
- Kustomize
- GitOps amb Argo CD i Flux
- Kubernetes Gestionat: EKS, AKS i GKE
Mòdul 11: Estudis de Cas i Aplicacions del Món Real
- Desplegament d'una Aplicació Web
- Execució d'Aplicacions amb Estat
- CI/CD amb Kubernetes
- Estratègies de Desplegament: Blue-Green i Canary
- Gestió Multi-Clúster
- Operació en Producció: Incidències, Runbooks i Costos
