Aurora Libros funciona, està ben organitzada i és reproduïble. El que no està és endurida. En aquesta lliçó recorres les quatre superfícies d'atac d'una plataforma de contenidors i hi apliques, una a una, les defenses: usuari sense privilegis, sistema de fitxers de només lectura, capacitats del kernel retallades, seccomp, escaneig de vulnerabilitats i signatura d'imatges.

Advertència imprescindible. Tot el que segueix són bones pràctiques tècniques, no una política de seguretat. Les decisions concretes —què s'exposa, quin CVE s'accepta, quins controls són obligatoris— s'han de validar amb el responsable de seguretat o de compliance de la teva organització. Cap configuració d'aquest curs no substitueix una auditoria professional ni el compliment normatiu que t'apliqui.

Contingut

  1. Model d'amenaces: què aïlla un contenidor i què no
  2. El daemon: el grup docker és root
  3. Docker rootless
  4. --userns-remap, l'alternativa intermèdia
  5. La imatge: base mínima i fixada per digest
  6. distroless i scratch davant d'Alpine
  7. Secrets en capes: la prova amb docker history
  8. Runtime: --read-only i --tmpfs
  9. Capacitats del kernel
  10. no-new-privileges, seccomp i AppArmor
  11. Recursos com a defensa i per què --privileged és un error
  12. Escaneig de vulnerabilitats: Scout i Trivy
  13. Cadena de subministrament: SBOM, Cosign i procedència
  14. El compose.prod.yaml endurit
  15. Llista de comprovació de seguretat

  1. Model d'amenaces: què aïlla un contenidor i què no

Un contenidor no és una màquina virtual. Comparteix el kernel del host, i aquell kernel és l'única frontera real:

Un contenidor sí que aïlla Un contenidor NO aïlla
Processos (no veus els del host) El kernel: una fallada del kernel afecta tothom
Sistema de fitxers (llevat del que muntis) El maquinari, el rellotge i els paràmetres globals
Xarxa, nom de host, IPC, usuaris El que li donis tu: sockets, muntatges, capacitats
flowchart TB
  A["1 · IMATGE<br/>CVE a la base, dependències<br/>vulnerables, secrets en capes"]
  B["2 · RUNTIME<br/>root a dins, capacitats de més,<br/>escriptura lliure, sense límits"]
  C["3 · DAEMON<br/>grup docker = root, socket<br/>exposat, --privileged"]
  D["4 · REGISTRE D'IMATGES<br/>imatge suplantada, sense signatura,<br/>etiqueta mutable"]
  A --> P(("Aurora<br/>Libros"))
  B --> P
  C --> P
  D --> P

Les quatre superfícies es defensen amb eines diferents i cap no cobreix les altres: una imatge sense CVE executada com a root amb --privileged continua sent un desastre.

  1. El daemon: el grup docker és root

Aquesta és l'afirmació que més resistència genera i la més fàcil de demostrar. Pertànyer al grup docker equival a tenir root al host, sense sudo i sense registre als logs de sudo.

id | tr ',' '\n' | grep docker
docker run --rm -v /:/host alpine:3 sh -c 'cat /host/etc/shadow | head -2'
1001(docker)
root:$y$j9T$Kx8...:20301:0:99999:7:::
daemon:*:20301:0:99999:7:::

Acabes de llegir els hash de contrasenyes del host sense ser root. I es pot anar més lluny: muntant / en mode escriptura, un usuari del grup docker pot afegir-se a /etc/sudoers, instal·lar una clau SSH a /root/.ssh/authorized_keys o modificar una unitat de systemd. El motiu és estructural: el client docker només envia peticions a /var/run/docker.sock, i el daemon corre com a root. Qui pugui parlar amb aquell socket mana sobre el daemon, i el daemon mana sobre el host. Tres conseqüències operatives:

  • Afegir algú al grup docker és concedir-li root. Tracta-ho amb aquest criteri a la teva gestió d'accessos.
  • Mai no muntis /var/run/docker.sock dins d'un contenidor "per comoditat". És la via d'escapada més explotada que existeix. Si una eina ho necessita (un agent de CI, Portainer), fes servir un proxy de socket que filtri les peticions permeses.
  • Si l'equip no necessita root al host, la resposta correcta és el mode rootless.

  1. Docker rootless

En mode rootless el daemon corre com el teu usuari, no com a root, recolzant-se en espais de noms d'usuari (lliçó 05-07). Si algú s'escapa del contenidor, apareix com un usuari sense privilegis del host.

sudo apt-get install -y uidmap && dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
systemctl --user enable --now docker
docker info --format 'rootless={{.SecurityOptions}} | dir: {{.DockerRootDir}}'
docker run --rm -v /:/host alpine:3 sh -c 'cat /host/etc/shadow' 2>&1 | tail -1
rootless=[name=seccomp,profile=builtin name=rootless name=cgroupns] | dir: /home/joan/.local/share/docker
cat: can't open '/host/etc/shadow': Permission denied

El muntatge es fa, però el fitxer és il·legible: dins del contenidor ets root, i aquell root està remapat al teu UID real del host, que no pot llegir /etc/shadow. L'escalada deixa d'existir.

Limitació del mode rootless Detall
Ports < 1024 No es publiquen sense CAP_NET_BIND_SERVICE o net.ipv4.ip_unprivileged_port_start
Rendiment de xarxa La sortida passa per slirp4netns/rootlesskit: una mica més lenta
Storage driver overlay2 requereix kernel ≥ 5.13; si no, fuse-overlayfs, més lent
Límits de recursos Requereixen cgroups v2 amb delegació per systemd
--network host, macvlan, AppArmor No disponibles o restringits: no hi ha privilegis per tocar la xarxa del host

Per a Aurora Libros en un servidor compartit, rootless és l'opció per defecte. Per a un servidor dedicat amb accés restringit, el mode clàssic endurit és defensable; decideix-ho amb el teu responsable de seguretat.

  1. --userns-remap, l'alternativa intermèdia

Si no pots migrar a rootless, --userns-remap manté el daemon com a root però remapa els usuaris dels contenidors a un rang sense privilegis del host. S'activa amb {"userns-remap": "default"} a /etc/docker/daemon.json:

sudo systemctl restart docker
docker run -d --name t alpine:3 sleep 300 && docker exec t id -u
ps -o user,pid,cmd -C sleep --no-headers
0
165536  8812 sleep 300

A dins el procés és root (uid 0); al host corre com l'UID 165536, que no té cap privilegi. El cost: els volums existents canvien de propietari efectiu, i no és compatible amb --network host ni amb contenidors que comparteixin namespaces d'usuari. És un pas intermedi raonable, no un substitut de rootless.

  1. La imatge: base mínima i fixada per digest

Cada paquet de la imatge és un CVE en potència. Les regles, per ordre d'impacte: base oficial i mínima (node:22-alpine en comptes de node:22 passa d'uns 1,1 GB a uns 142 MB i amb això centenars de paquets); fixar per digest, perquè una etiqueta és mutable i un digest no; no instal·lar el que és innecessari (res de curl, vim, git o build-essential a la imatge final); USER sense privilegis (lliçó 02-04); i actualitzar la base periòdicament, perquè fixar per digest sense renovar-lo és congelar vulnerabilitats.

# Reproduïble i verificable: el digest identifica un contingut exacte
FROM node:22-alpine@sha256:3f1c8d9e7a4b2c5f6e0d1a8b7c4e2f9d0a3b6c5e8f1d2a7b4c9e0f3a6b5d8c1e

El digest s'obté amb docker image inspect node:22-alpine --format '{{index .RepoDigests 0}}' i és l'única referència que garanteix que la imatge d'avui és exactament la que vas auditar. En desenvolupament pots fer servir etiquetes; a compose.prod.yaml, digest.

  1. distroless i scratch davant d'Alpine

Base Mida Shell Paquets Superfície Depuració
node:22 (Debian) ~1,1 GB apt Molt gran Còmoda
node:22-slim ~230 MB apt Mitjana Còmoda
node:22-alpine ~142 MB sí (BusyBox) apk Petita Acceptable
distroless/nodejs22 ~110 MB no no Mínima Difícil: :debug o nsenter
scratch 0 MB no no Nul·la Molt difícil; només binaris estàtics

distroless conté l'intèrpret i les seves biblioteques, i res més: ni sh, ni apk, ni wget. L'atacant que aconsegueixi execució de comandes no hi troba eines amb què treballar. El preu és real: no pots fer docker exec ... sh, i el HEALTHCHECK amb wget d'aurora-api deixa de funcionar (caldria sondejar des de fora o incloure un petit binari de salut). scratch només serveix per a binaris estàtics —Go, Rust—, no per a Node.js. Per a aurora-api, Alpine és l'equilibri correcte entre superfície i operabilitat; distroless és l'esglaó següent si la teva organització ho exigeix.

  1. Secrets en capes: la prova amb docker history

Un secret que passa per una capa queda a la imatge per sempre, encara que després l'esborris.

# ❌ Les tres maneres de filtrar un secret
ARG NPM_TOKEN
ENV DB_PASSWORD=aurora_pass_2026
RUN echo "$NPM_TOKEN" > /root/.npmrc && npm ci && rm /root/.npmrc
docker build --build-arg NPM_TOKEN=npm_s3cr3t0 -t fuita:1.0 .
docker history --no-trunc fuita:1.0 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
docker image inspect fuita:1.0 --format '{{json .Config.Env}}'
# NPM_TOKEN=npm_s3cr3t0
# ["PATH=/usr/local/bin:...","DB_PASSWORD=aurora_pass_2026"]

Les tres errades: l'ARG queda a les metadades de construcció, l'ENV a la configuració de la imatge, i el fitxer esborrat continua a la capa on es va crear sota un whiteout (lliçó 05-02). Qualsevol que descarregui la imatge els recupera.

Les solucions, segons el moment: durant el build, RUN --mount=type=secret de BuildKit (lliçó 05-05); en execució, fitxers muntats a /run/secrets/ amb el patró _FILE (lliçó 04-05); dins de la imatge, mai.

Recorda el patró que Aurora Libros ja fa servir: DB_PASSWORD_FILE=/run/secrets/db_password, i l'aplicació llegeix el fitxer en arrencar. El secret viu al host, no a la imatge ni a l'entorn.

  1. Runtime: --read-only i --tmpfs

Si el sistema de fitxers del contenidor és de només lectura, un atacant no hi pot deixar cap binari ni modificar el codi.

docker run --rm --read-only auroralibros/aurora-api:1.2.0 sh -c 'touch /app/porta.js'
touch: /app/porta.js: Read-only file system

Gairebé cap aplicació no funciona sense escriure en algun lloc: cal donar tmpfs per a aquells punts concrets.

docker run -d --name api-ro --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m auroralibros/aurora-api:1.2.0
docker exec api-ro sh -c 'touch /tmp/ok && echo "tmp escrivible"; touch /app/x 2>&1'
tmp escrivible
touch: /app/x: Read-only file system

Fixa't en noexec i nosuid: encara que l'atacant aconsegueixi escriure un binari a /tmp, no el podrà executar. Per esbrinar quines rutes necessita escriure una aplicació, arrenca-la en només lectura i llegeix els errors; sol n'hi haver prou amb /tmp i algun directori de memòria cau.

  1. Capacitats del kernel

Ser root dins d'un contenidor no és ser root del host: Linux divideix els privilegis en capacitats, i Docker en concedeix per defecte un subconjunt d'unes catorze. La bona pràctica és treure-les totes i tornar només les necessàries.

docker run --rm --cap-drop=ALL alpine:3 chown 1000 /tmp
docker run --rm --cap-drop=ALL --cap-add=CHOWN alpine:3 sh -c 'chown 1000 /tmp && echo "chown OK"'
chown: /tmp: Operation not permitted
chown OK
Capacitat Permet La necessita Aurora Libros?
NET_BIND_SERVICE Escoltar en ports < 1024 Només aurora-web (nginx al 80)
CHOWN, FOWNER, DAC_OVERRIDE Canviar propietaris i saltar-se permisos aurora-db en inicialitzar el volum
SETUID, SETGID Canviar d'usuari (típic de su-exec) aurora-db i aurora-web en arrencar
NET_RAW Sockets crus: ping, i també spoofing No. Treu-la sempre
SYS_ADMIN Muntar, canviar namespaces… gairebé root Mai. Equival a --privileged
SYS_PTRACE Depurar altres processos, llegir-ne la memòria Només en depuració puntual
SYS_MODULE Carregar mòduls del kernel Mai. Compromet el host sencer

Per a aurora-api, que escolta al 3000 i corre com l'usuari node, la resposta és neta: cap capacitat, és a dir, cap_drop: [ALL] sense cap cap_add.

  1. no-new-privileges, seccomp i AppArmor

no-new-privileges:true a security_opt impedeix que un procés guanyi privilegis mitjançant binaris setuid, i talla en sec tota una família d'escalades locals.

seccomp filtra les crides al sistema. El perfil per defecte de Docker en bloqueja unes 40 de les més de 300 disponibles, entre elles les més perilloses: mount, reboot, kexec_load, init_module, ptrace (en kernels antics), clone amb banderes de namespace noves i bpf.

docker run --rm alpine:3 sh -c 'mount -t tmpfs none /mnt' 2>&1 | tail -1
docker run --rm --security-opt seccomp=unconfined --cap-add=SYS_ADMIN alpine:3 \
  sh -c 'mount -t tmpfs none /mnt && echo "muntat: perfil desactivat"'
mount: permission denied (are you root?)
muntat: perfil desactivat

Un perfil propi és un JSON amb una acció per defecte i una llista de permeses:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [{
    "names": ["read","write","openat","close","fstat","mmap","brk","epoll_wait",
              "accept4","socket","bind","listen","futex","clock_gettime","exit_group"],
    "action": "SCMP_ACT_ALLOW"
  }]
}

S'aplica amb security_opt: [seccomp:./seguretat/aurora-api-seccomp.json]. Construir un perfil a mida és feina fina: cal traçar l'aplicació (amb strace o auditd) per descobrir quines crides fa realment, i una crida oblidada produeix fallades intermitents difícils de diagnosticar. Comença pel perfil per defecte —que ja és una bona defensa— i aborda'n un de propi només si el teu context ho exigeix.

AppArmor (Debian/Ubuntu) i SELinux (RHEL/Fedora) actuen a un altre nivell: controlen quins fitxers i operacions pot tocar un procés. Docker aplica el perfil docker-default si AppArmor està actiu. A SELinux, l'opció :z / :Z als muntatges reetiqueta el contingut perquè el contenidor hi pugui accedir (-v dades:/dades:Z), i ometre-la és la causa del clàssic Permission denied a Fedora amb permisos aparentment correctes. El que mai no s'ha de fer: seccomp=unconfined o apparmor=unconfined per "arreglar" un error.

  1. Recursos com a defensa i per què --privileged és un error

Els límits de la lliçó 03-07 no són només de capacitat: són defensa davant de denegació de servei. Un contenidor compromès que mini criptomonedes, exhaureixi la memòria o llanci una fork bomb queda contingut pel seu pids_limit: 200 i els seus deploy.resources.limits.

I --privileged, en una línia: desactiva gairebé totes les proteccions alhora. Concedeix totes les capacitats, desactiva seccomp i AppArmor, i dona accés a tots els dispositius del host.

docker run --rm --privileged alpine:3 sh -c 'ls /dev/sda*'
# /dev/sda  /dev/sda1  /dev/sda2   <- els discos del host, visibles i muntables

Si alguna cosa necessita un dispositiu concret, concedeix-l'hi amb --device=/dev/xxx; si necessita una capacitat concreta, amb --cap-add. --privileged és l'equivalent a treure la porta perquè costava trobar la clau.

  1. Escaneig de vulnerabilitats: Scout i Trivy

docker scout cves auroralibros/aurora-api:1.2.0 --only-severity critical,high
   ✗ HIGH CVE-2025-31842 [Improper Input Validation]
     Affected range : <4.19.3     Fixed version : 4.19.3
     Package        : pkg:npm/[email protected]
   ✗ HIGH CVE-2025-47101 [Out-of-bounds Write]
     Affected range : <3.20.4-r2  Fixed version : 3.20.4-r2
     Package        : pkg:apk/alpine/[email protected]
2 vulnerabilities found in 2 packages

Com es llegeix un informe, per ordre de decisió:

Camp Què cal fer
Severitat (CRITICAL/HIGH/MEDIUM/LOW) Comença per les dues primeres
Fixed version Si hi ha correcció, actualitza. És la dada decisiva
Paquet Dependència teva: npm update. De la base: canvia de base o de digest
Vector d'atac Un CVE en codi que no executes és menys urgent, no inexistent

Trivy dona el mateix des de la línia de comandes i serveix per automatitzar:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest \
  image --severity HIGH,CRITICAL --exit-code 1 auroralibros/aurora-api:1.2.0
# Total: 2 (HIGH: 2, CRITICAL: 0)   -> la comanda acaba amb codi 1

--exit-code 1 és la clau de la integració: fa que la comanda falli i, amb ella, el pipeline (lliçó 06-02). El flux de treball sensat són tres punts de control: en construir en local, a cada pull request i de manera periòdica sobre les imatges ja desplegades. I una nota sobre el socket: muntar docker.sock a l'escàner és exactament el que l'apartat 2 desaconsella; és acceptable a la teva màquina, però a CI convé escanejar el tarball de la imatge o fer servir un escàner sense accés al daemon.

  1. Cadena de subministrament: SBOM, Cosign i procedència

Un SBOM (Software Bill of Materials) és l'inventari de tot el que hi ha dins d'una imatge. Sense ell, davant d'un CVE nou no pots respondre la pregunta més bàsica: m'afecta?

docker sbom auroralibros/aurora-api:1.2.0 --format syft-json > sbom-aurora-1.2.0.json
docker sbom auroralibros/aurora-api:1.2.0 | head -4
NAME              VERSION       TYPE
alpine-baselayout 3.6.5-r0      apk
express           4.19.3        npm
node              22.11.0       binary

Cosign signa imatges amb criptografia i verifica l'origen:

cosign generate-key-pair                       # cosign.key (privada) i cosign.pub
cosign sign --key cosign.key auroralibros/aurora-api:1.2.0
cosign verify --key cosign.pub auroralibros/aurora-api:1.2.0
Verification for auroralibros/aurora-api:1.2.0 --
  - The cosign claims were validated
  - The signatures were verified against the specified public key

La signatura es desa com un artefacte addicional al registre d'imatges mateix. Amb keyless signing pots signar fent servir la identitat OIDC del pipeline, sense gestionar claus privades —cosa que, a la pràctica, és el gran avantatge: la clau que ningú no custodia és la que ningú no filtra.

Les attestations de procedència responen a "qui ha construït això, des de quin commit i amb quin Dockerfile", i es generen durant el build: docker buildx build --provenance=mode=max --sbom=true -t auroralibros/aurora-api:1.2.0 --push ./api, consultables amb docker buildx imagetools inspect.

Sobre el mecanisme antic: Docker Content Trust (DOCKER_CONTENT_TRUST=1) i Notary v1 van ser el primer intent de signatura a Docker Hub. Encara funcionen, però estan en desús: la indústria s'ha mogut a Cosign i a l'ecosistema Sigstore. Si comences avui, comença amb Cosign.

  1. El compose.prod.yaml endurit

# compose.prod.yaml — Aurora Libros S.L., servidor endurit
services:

  aurora-api:
    # Digest, no etiqueta: contingut exacte i auditat
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?fixa la versió}@${AURORA_API_DIGEST:?fixa el digest}
    build: !reset null           # el servidor no construeix: només descarrega
    user: "1000:1000"            # sense privilegis, encara que la imatge ja ho fixi
    read_only: true              # sistema de fitxers immutable
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop: [ALL]              # cap capacitat: escolta al 3000
    security_opt: [no-new-privileges:true]   # sense escalada via binaris setuid
    pids_limit: 200              # contenció davant de fork bomb
    environment:
      NODE_ENV: production
      LOG_NIVELL: warn
      DB_PASSWORD_FILE: /run/secrets/db_password   # mai el valor a l'entorn
    secrets: [db_password]
    ports: !reset []             # sense accés directe: tot passa pel proxy
    restart: always
    deploy:
      resources: { limits: { memory: 512M, cpus: "2.0" }, reservations: { memory: 128M } }
    logging:
      driver: json-file
      options: { max-size: "50m", max-file: "5" }

  aurora-db:
    image: postgres:16-alpine@sha256:9c1f2b8e5a7d...
    user: "999:999"
    read_only: true
    tmpfs: ["/tmp:rw,noexec,nosuid,size=64m", "/run/postgresql:rw,nosuid,size=16m"]
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]  # les mínimes de postgres
    security_opt: [no-new-privileges:true]
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    restart: always
    deploy:
      resources: { limits: { memory: 2G, cpus: "2.0" } }

  aurora-cache:
    image: redis:7-alpine@sha256:4b2e9c7f1a3d...
    user: "999:999"
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    command: ["redis-server","--maxmemory","200mb","--maxmemory-policy","allkeys-lru","--save",""]
    restart: always

  aurora-web:
    image: nginx:alpine@sha256:7e3a1c9d0b5f...
    read_only: true
    tmpfs: ["/var/cache/nginx:rw,nosuid,size=64m", "/var/run:rw,nosuid,size=8m"]
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE, CHOWN, SETGID, SETUID]      # nginx escolta al 80
    security_opt: [no-new-privileges:true]
    ports: ["80:80"]
    restart: always

secrets:
  db_password:
    file: ./secrets/db_password.txt     # fora de Git; permisos 0400

Verifica sempre la fusió abans de desplegar: docker compose -f compose.yaml -f compose.prod.yaml config | grep -E 'read_only|cap_drop|no-new'.

  1. Llista de comprovació de seguretat

# Control Com es verifica
1 El daemon no és accessible a usuaris sense confiança getent group docker
2 Mode rootless valorat o justificat per escrit docker info | grep -i rootless
3 Cap contenidor no munta docker.sock docker ps -q | xargs docker inspect | grep docker.sock
4 Cap --privileged en producció grep -r privileged compose*.yaml
5 Totes les imatges fixades per digest docker compose config | grep image:
6 Cap contenidor no corre com a root docker inspect --format '{{.Config.User}}'
7 read_only: true amb els seus tmpfs mínims {{.HostConfig.ReadonlyRootfs}}
8 cap_drop: [ALL] i addicions justificades Revisió del compose.prod.yaml
9 no-new-privileges, i seccomp/AppArmor sense unconfined {{.HostConfig.SecurityOpt}}
10 Límits de memòria, CPU i PID a tots docker stats --no-stream
11 Escaneig sense CVE crítics amb correcció disponible docker scout cves --only-severity critical
12 Escaneig periòdic del que ja està desplegat Tasca programada setmanal
13 Secrets per fitxer, mai a ENV ni en capes docker history | grep -iE 'pass|token'
14 SBOM generat i arxivat per versió Artefacte del pipeline
15 Imatges signades i verificades abans de desplegar cosign verify
16 Xarxes segmentades i dades sense sortida a Internet internal: true a posterior
17 Ports publicats només els imprescindibles docker compose ps, iptables -t nat -S DOCKER

Repassa aquesta llista abans de cada desplegament i revisa-la amb el teu responsable de seguretat: és un punt de partida tècnic, no un certificat de compliment.

Errors Habituals i Consells

Creure que un contenidor aïlla com una VM. Comparteix kernel: una fallada del kernel afecta tothom.

Afegir gent al grup docker com si fos un permís menor, o muntar /var/run/docker.sock en un contenidor. El primer és concedir root sense traces a sudo; el segon és la via d'escapada més explotada que existeix.

Passar secrets amb ARG o ENV al Dockerfile. Queden a les capes i a les metadades. docker history els revela.

Fer servir --privileged per resoldre un error de permisos, o desactivar seccomp/AppArmor perquè alguna cosa funcioni. Concedeix el que falti en concret (--cap-add, --device); l'altra cosa és apagar l'alarma en comptes d'atendre l'incendi.

Escanejar una vegada i oblidar-se'n, o fixar per digest i no renovar-lo mai. Els CVE apareixen després de publicar; una imatge neta al març pot ser vulnerable al juny sense que ningú no la toqui.

Consell: aplica l'enduriment per servei i mesurant. Afegeix read_only, arrenca, llegeix l'error, afegeix el tmpfs just; després cap_drop: [ALL], arrenca, llegeix l'error, afegeix la capacitat justa. És més lent que copiar una plantilla, i és l'única manera de saber per què hi ha cada línia.

Exercicis

Exercici 1. Demostra l'escalada de privilegis des del grup docker: llegeix un fitxer del host que el teu usuari no pugui llegir i explica exactament per què funciona. Després raona quines dues configuracions ho impedirien.

Exercici 2. Endureix aurora-api de manera incremental: user, read_only, cap_drop: [ALL] i no-new-privileges. Documenta què falla a cada pas, quin tmpfs cal i verifica al final que /salut i /llibres continuen responent.

Exercici 3. Demostra que un secret passat amb ARG queda a la imatge: construeix-la, recupera'l amb docker history encara que el fitxer s'hagi esborrat, i explica per què el patró _FILE d'Aurora Libros no té aquest problema.

Solucions

Solució 1.

cat /etc/shadow 2>&1 | head -1
docker run --rm -v /:/host:ro alpine:3 head -1 /host/etc/shadow
cat: /etc/shadow: Permission denied
root:$y$j9T$Kx8HvQ...:20301:0:99999:7:::

El mateix fitxer, el mateix usuari, dos resultats oposats. La raó és en qui executa l'operació: la comanda docker no llegeix res, només envia una petició al socket del daemon; el daemon corre com a root i munta / dins del contenidor amb els seus propis privilegis. A dins, el procés també és root, així que llegeix el que vulgui. El teu usuari mai no ha necessitat permís perquè mai no ha estat ell qui ha llegit el fitxer.

Les dues configuracions que ho impedeixen són el mode rootless (el daemon corre com el teu usuari i el root de dins es remapa al teu UID real, que no pot llegir /etc/shadow) i --userns-remap (el root del contenidor es remapa a un UID sense privilegis, amb el mateix efecte sobre el muntatge).

La conclusió de gestió és la que importa: usermod -aG docker algu és una concessió de root i ha de passar pel mateix procés d'aprovació que concedir sudo sense contrasenya.

Solució 2.

docker run -d --name h1 -u 1000:1000 auroralibros/aurora-api:1.2.0 && docker exec h1 id -u
docker run --rm --read-only auroralibros/aurora-api:1.2.0 2>&1 | tail -1   # pas 2: falla
1000
Error: EROFS: read-only file system, open '/tmp/aurora-api.pid'
# Passos 3 i 4: tmpfs mínim, sense capacitats i sense escalada
docker run -d --name h4 -u 1000:1000 --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop=ALL --security-opt no-new-privileges:true \
  --pids-limit 200 -p 3000:3000 auroralibros/aurora-api:1.2.0
sleep 3
curl -s http://localhost:3000/salut | jq -c '{estat, bd, cache}'
docker inspect h4 --format 'ro={{.HostConfig.ReadonlyRootfs}} caps={{.HostConfig.CapDrop}} opts={{.HostConfig.SecurityOpt}}'
docker exec h4 sh -c 'touch /app/porta.js' 2>&1
{"estat":"ok","bd":"connectada","cache":"connectada"}
ro=true caps=[ALL] opts=[no-new-privileges:true]
touch: /app/porta.js: Read-only file system

L'únic pas que ha fallat ha estat el segon, per un motiu concret: l'aplicació escriu el seu fitxer de PID a /tmp. Un tmpfs de 64 MB amb noexec,nosuid ho resol sense tornar escriptura a la resta del sistema de fitxers. cap_drop: [ALL] no ha trencat res perquè aurora-api escolta al 3000 (no privilegiat) i ja corria com l'usuari node: aquí es recull el fruit de la decisió que vas prendre a la lliçó 02-04.

El resultat és un contenidor on un atacant amb execució de codi no pot escriure un binari, no el pot executar si l'escriu a /tmp, no té cap capacitat del kernel, no pot escalar per setuid i no pot llançar més de 200 processos. L'aplicació, mentrestant, respon exactament igual.

Solució 3.

mkdir -p /tmp/fuita && cd /tmp/fuita
printf 'FROM alpine:3\nARG API_TOKEN\nRUN echo "$API_TOKEN" > /token.txt && rm /token.txt\n' > Dockerfile
docker build --build-arg API_TOKEN=aur_tok_9f3c1 -t fuita:1.0 . >/dev/null
docker run --rm fuita:1.0 ls /token.txt 2>&1        # el fitxer ja no hi és
docker history --no-trunc fuita:1.0 | grep -o 'API_TOKEN=[^ |]*' | head -1
docker save fuita:1.0 | tar -xO --wildcards '*/layer.tar' 2>/dev/null | strings | grep -o 'aur_tok_[a-z0-9]*' | head -1
ls: /token.txt: No such file or directory
API_TOKEN=aur_tok_9f3c1
aur_tok_9f3c1

Tres fets encadenats: el fitxer no existeix al sistema final, el token apareix a l'historial de construcció, i apareix també dins de les dades de la capa exportada. El primer explica per què tanta gent creu que esborrar el fitxer n'hi ha prou; el segon i el tercer, per què no és així: l'ARG queda a les metadades i el contingut esborrat continua a la seva capa, ocult només per un whiteout (lliçó 05-02).

El patró _FILE d'Aurora Libros no té aquest problema perquè el secret no entra mai a la imatge. La imatge conté només DB_PASSWORD_FILE=/run/secrets/db_password, que és una ruta, no un valor:

docker image inspect auroralibros/aurora-api:1.2.0 --format '{{json .Config.Env}}' | tr ',' '\n' | grep -iE 'pass|token'
docker compose exec aurora-api sh -c 'mount | grep run/secrets'
"DB_PASSWORD_FILE=/run/secrets/db_password"
/dev/sda2 on /run/secrets/db_password type ext4 (ro,relatime)

A les metadades només hi ha una ruta; el valor arriba per un muntatge de només lectura que existeix únicament mentre el contenidor viu. Si demà publiques aquella imatge a Docker Hub, no hi viatja cap secret a dins. Per als secrets que es necessiten durant la construcció —un token d'npm privat—, la solució equivalent és RUN --mount=type=secret de BuildKit, i és justament el que veuràs a la lliçó 05-05.

Conclusió

Aurora Libros està endurida i, més important, saps per què ho està cada línia. Vas partir del model d'amenaces: un contenidor aïlla processos, fitxers i xarxa, però comparteix kernel, i les quatre superfícies —imatge, runtime, daemon i registre d'imatges— exigeixen defenses diferents que no se substitueixen entre si. Has demostrat, llegint /etc/shadow sense ser root, que pertànyer al grup docker és tenir root al host, i coneixes les dues respostes: el mode rootless amb les seves limitacions reals (ports baixos, xarxa una mica més lenta, sense --network host) i --userns-remap com a pas intermedi.

A la imatge apliques base mínima fixada per digest, la comparativa entre Alpine, distroless i scratch amb el seu cost d'operabilitat, i la prova irrefutable que un secret passat per ARG o ENV queda a les capes per sempre. Al runtime, --read-only amb tmpfs noexec,nosuid per al just, cap_drop: [ALL] amb la taula de capacitats que de debò importen —i aurora-api sense cap—, no-new-privileges, el perfil seccomp per defecte i quan escriure'n un de propi, AppArmor i SELinux amb el seu :Z, els límits de recursos com a contenció de denegació de servei, i el perquè que --privileged sigui gairebé sempre un error. Escaneges amb Docker Scout i Trivy sabent llegir un informe —la versió corregida és la dada decisiva— i integrant-lo amb --exit-code 1; i assegures la cadena de subministrament amb SBOM, signatura amb Cosign i attestations de procedència, deixant Content Trust com el mecanisme antic que és. Tot condensat en un compose.prod.yaml comentat línia a línia i en una llista de comprovació de disset controls verificables. I l'advertència inicial continua dempeus: això és un terra tècnic sòlid, no una política, així que valida cada decisió amb el responsable de seguretat o de compliance de la teva organització.

A la lliçó 05-04 canviem d'objectiu i tornem a la imatge, ara amb la bàscula al davant: per què la mida importa (descàrrega, cost, superfície d'atac, velocitat d'escalat), com mesurar amb history i dive per trobar la capa grassa, quina base triar amb xifres reals, i sobretot les construccions multietapa, amb les quals aquells 142 MB d'aurora-api baixaran fins a una xifra concreta sense perdre res del que acabes d'endurir.

Docker: De Principiant a Avançat

Mòdul 1: Introducció a Docker

Mòdul 2: Treballant amb Imatges Docker

Mòdul 3: Contenidors Docker

Mòdul 4: Docker Compose

Mòdul 5: Conceptes Avançats de Docker

Mòdul 6: Docker en Producció

Mòdul 7: Ecosistema i Eines de Docker

© Copyright 2026. Tots els drets reservats