Aurora Libros funciona, está bien organizada y es reproducible. Lo que no está es endurecida. En esta lección recorres las cuatro superficies de ataque de una plataforma de contenedores y aplicas, una a una, las defensas: usuario sin privilegios, sistema de ficheros de solo lectura, capacidades del kernel recortadas, seccomp, escaneo de vulnerabilidades y firma de imágenes.

Advertencia imprescindible. Todo lo que sigue son buenas prácticas técnicas, no una política de seguridad. Las decisiones concretas —qué se expone, qué CVE se acepta, qué controles son obligatorios— deben validarse con el responsable de seguridad o de compliance de tu organización. Ninguna configuración de este curso sustituye a una auditoría profesional ni al cumplimiento normativo que te aplique.

Contenido

  1. Modelo de amenazas: qué aísla un contenedor y qué no
  2. El daemon: el grupo docker es root
  3. Docker rootless
  4. --userns-remap, la alternativa intermedia
  5. La imagen: base mínima y fijada por digest
  6. distroless y scratch frente a Alpine
  7. Secretos en capas: la prueba con docker history
  8. Runtime: --read-only y --tmpfs
  9. Capacidades del kernel
  10. no-new-privileges, seccomp y AppArmor
  11. Recursos como defensa y por qué --privileged es un error
  12. Escaneo de vulnerabilidades: Scout y Trivy
  13. Cadena de suministro: SBOM, Cosign y procedencia
  14. El compose.prod.yaml endurecido
  15. Checklist de seguridad

  1. Modelo de amenazas: qué aísla un contenedor y qué no

Un contenedor no es una máquina virtual. Comparte el kernel del host, y ese kernel es la única frontera real:

Un contenedor sí aísla Un contenedor NO aísla
Procesos (no ves los del host) El kernel: un fallo del kernel afecta a todos
Sistema de ficheros (salvo lo que montes) El hardware, el reloj y los parámetros globales
Red, nombre de host, IPC, usuarios Lo que le des tú: sockets, montajes, capacidades
flowchart TB
  A["1 · IMAGEN<br/>CVE en la base, dependencias<br/>vulnerables, secretos en capas"]
  B["2 · RUNTIME<br/>root dentro, capacidades de más,<br/>escritura libre, sin límites"]
  C["3 · DAEMON<br/>grupo docker = root, socket<br/>expuesto, --privileged"]
  D["4 · REGISTRO<br/>imagen suplantada, sin firma,<br/>etiqueta mutable"]
  A --> P(("Aurora<br/>Libros"))
  B --> P
  C --> P
  D --> P

Las cuatro superficies se defienden con herramientas distintas y ninguna cubre a las otras: una imagen sin CVE ejecutada como root con --privileged sigue siendo un desastre.

  1. El daemon: el grupo docker es root

Esta es la afirmación que más resistencia genera y la más fácil de demostrar. Pertenecer al grupo docker equivale a tener root en el host, sin sudo y sin registro en los 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:::

Acabas de leer los hashes de contraseñas del host sin ser root. Y se puede ir más lejos: montando / en modo escritura, un usuario del grupo docker puede añadirse a /etc/sudoers, instalar una clave SSH en /root/.ssh/authorized_keys o modificar una unidad de systemd. El motivo es estructural: el cliente docker solo envía peticiones a /var/run/docker.sock, y el daemon corre como root. Quien pueda hablar con ese socket manda sobre el daemon, y el daemon manda sobre el host. Tres consecuencias operativas:

  • Añadir a alguien al grupo docker es concederle root. Trátalo con ese criterio en tu gestión de accesos.
  • Nunca montes /var/run/docker.sock dentro de un contenedor "por comodidad". Es la vía de escape más explotada que existe. Si una herramienta lo necesita (un agente de CI, Portainer), usa un proxy de socket que filtre las peticiones permitidas.
  • Si el equipo no necesita root en el host, la respuesta correcta es el modo rootless.

  1. Docker rootless

En modo rootless el daemon corre como tu usuario, no como root, apoyándose en espacios de nombres de usuario (lección 05-07). Si alguien se escapa del contenedor, aparece como un usuario sin privilegios 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 montaje se hace, pero el fichero es ilegible: dentro del contenedor eres root, y ese root está remapeado a tu UID real del host, que no puede leer /etc/shadow. La escalada deja de existir.

Limitación del modo rootless Detalle
Puertos < 1024 No se publican sin CAP_NET_BIND_SERVICE o net.ipv4.ip_unprivileged_port_start
Rendimiento de red La salida pasa por slirp4netns/rootlesskit: algo más lenta
Storage driver overlay2 requiere kernel ≥ 5.13; si no, fuse-overlayfs, más lento
Límites de recursos Requieren cgroups v2 con delegación por systemd
--network host, macvlan, AppArmor No disponibles o restringidos: no hay privilegios para tocar la red del host

Para Aurora Libros en un servidor compartido, rootless es la opción por defecto. Para un servidor dedicado con acceso restringido, el modo clásico endurecido es defendible; decídelo con tu responsable de seguridad.

  1. --userns-remap, la alternativa intermedia

Si no puedes migrar a rootless, --userns-remap mantiene el daemon como root pero remapea los usuarios de los contenedores a un rango sin privilegios del host. Se activa con {"userns-remap": "default"} en /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

Dentro el proceso es root (uid 0); en el host corre como el UID 165536, que no tiene ningún privilegio. El coste: los volúmenes existentes cambian de propietario efectivo, y no es compatible con --network host ni con contenedores que compartan namespaces de usuario. Es un paso intermedio razonable, no un sustituto de rootless.

  1. La imagen: base mínima y fijada por digest

Cada paquete de la imagen es un CVE en potencia. Las reglas, en orden de impacto: base oficial y mínima (node:22-alpine en vez de node:22 pasa de ~1,1 GB a ~142 MB y con ello cientos de paquetes); fijar por digest, porque una etiqueta es mutable y un digest no; no instalar lo innecesario (nada de curl, vim, git o build-essential en la imagen final); USER sin privilegios (lección 02-04); y actualizar la base periódicamente, porque fijar por digest sin renovarlo es congelar vulnerabilidades.

# Reproducible y verificable: el digest identifica un contenido exacto
FROM node:22-alpine@sha256:3f1c8d9e7a4b2c5f6e0d1a8b7c4e2f9d0a3b6c5e8f1d2a7b4c9e0f3a6b5d8c1e

El digest se obtiene con docker image inspect node:22-alpine --format '{{index .RepoDigests 0}}' y es la única referencia que garantiza que la imagen de hoy es exactamente la que auditaste. En desarrollo puedes usar etiquetas; en compose.prod.yaml, digest.

  1. distroless y scratch frente a Alpine

Base Tamaño Shell Paquetes Superficie Depuración
node:22 (Debian) ~1,1 GB apt Muy grande Cómoda
node:22-slim ~230 MB apt Media Cómoda
node:22-alpine ~142 MB sí (BusyBox) apk Pequeña Aceptable
distroless/nodejs22 ~110 MB no no Mínima Difícil: :debug o nsenter
scratch 0 MB no no Nula Muy difícil; solo binarios estáticos

distroless contiene el intérprete y sus librerías, y nada más: ni sh, ni apk, ni wget. El atacante que consiga ejecución de comandos no encuentra herramientas con las que trabajar. El precio es real: no puedes hacer docker exec ... sh, y el HEALTHCHECK con wget de aurora-api deja de funcionar (habría que sondear desde fuera o incluir un pequeño binario de salud). scratch solo sirve para binarios estáticos —Go, Rust—, no para Node.js. Para aurora-api, Alpine es el equilibrio correcto entre superficie y operabilidad; distroless es el siguiente escalón si tu organización lo exige.

  1. Secretos en capas: la prueba con docker history

Un secreto que pasa por una capa queda en la imagen para siempre, aunque lo borres después.

# ❌ Las tres formas de filtrar un secreto
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 fuga:1.0 .
docker history --no-trunc fuga:1.0 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
docker image inspect fuga:1.0 --format '{{json .Config.Env}}'
# NPM_TOKEN=npm_s3cr3t0
# ["PATH=/usr/local/bin:...","DB_PASSWORD=aurora_pass_2026"]

Los tres fallos: el ARG queda en los metadatos de construcción, el ENV en la configuración de la imagen, y el fichero borrado sigue en la capa donde se creó bajo un whiteout (lección 05-02). Cualquiera que descargue la imagen los recupera.

Las soluciones, según el momento: durante el build, RUN --mount=type=secret de BuildKit (lección 05-05); en ejecución, ficheros montados en /run/secrets/ con el patrón _FILE (lección 04-05); dentro de la imagen, nunca.

Recuerda el patrón que Aurora Libros ya usa: DB_PASSWORD_FILE=/run/secrets/db_password, y la aplicación lee el fichero al arrancar. El secreto vive en el host, no en la imagen ni en el entorno.

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

Si el sistema de ficheros del contenedor es de solo lectura, un atacante no puede dejar ningún binario ni modificar el código.

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

Casi ninguna aplicación funciona sin escribir en algún sitio: hay que dar tmpfs para esos puntos concretos.

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 escribible"; touch /app/x 2>&1'
tmp escribible
touch: /app/x: Read-only file system

Fíjate en noexec y nosuid: aunque el atacante consiga escribir un binario en /tmp, no podrá ejecutarlo. Para averiguar qué rutas necesita escribir una aplicación, arráncala en solo lectura y lee los errores; suele bastar con /tmp y algún directorio de caché.

  1. Capacidades del kernel

Ser root dentro de un contenedor no es ser root del host: Linux divide los privilegios en capacidades, y Docker concede por defecto un subconjunto de unas catorce. La buena práctica es quitarlas todas y devolver solo las necesarias.

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
Capacidad Permite ¿La necesita Aurora Libros?
NET_BIND_SERVICE Escuchar en puertos < 1024 Solo aurora-web (nginx en el 80)
CHOWN, FOWNER, DAC_OVERRIDE Cambiar propietarios y saltarse permisos aurora-db al inicializar el volumen
SETUID, SETGID Cambiar de usuario (típico de su-exec) aurora-db y aurora-web al arrancar
NET_RAW Sockets crudos: ping, y también spoofing No. Quítala siempre
SYS_ADMIN Montar, cambiar namespaces… casi root Nunca. Equivale a --privileged
SYS_PTRACE Depurar otros procesos, leer su memoria Solo en depuración puntual
SYS_MODULE Cargar módulos del kernel Nunca. Compromete el host entero

Para aurora-api, que escucha en el 3000 y corre como el usuario node, la respuesta es limpia: ninguna capacidad, es decir, cap_drop: [ALL] sin ningún cap_add.

  1. no-new-privileges, seccomp y AppArmor

no-new-privileges:true en security_opt impide que un proceso gane privilegios mediante binarios setuid, y corta en seco toda una familia de escaladas locales.

seccomp filtra las llamadas al sistema. El perfil por defecto de Docker bloquea unas 40 de las más de 300 disponibles, entre ellas las más peligrosas: mount, reboot, kexec_load, init_module, ptrace (en kernels antiguos), clone con banderas de namespace nuevas y 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 "montado: perfil desactivado"'
mount: permission denied (are you root?)
montado: perfil desactivado

Un perfil propio es un JSON con una acción por defecto y una lista de permitidas:

{
  "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"
  }]
}

Se aplica con security_opt: [seccomp:./seguridad/aurora-api-seccomp.json]. Construir un perfil a medida es trabajo fino: hay que trazar la aplicación (con strace o auditd) para descubrir qué llamadas usa realmente, y una llamada olvidada produce fallos intermitentes difíciles de diagnosticar. Empieza por el perfil por defecto —que ya es una buena defensa— y aborda uno propio solo si tu contexto lo exige.

AppArmor (Debian/Ubuntu) y SELinux (RHEL/Fedora) actúan a otro nivel: controlan qué ficheros y operaciones puede tocar un proceso. Docker aplica el perfil docker-default si AppArmor está activo. En SELinux, la opción :z / :Z en los montajes reetiqueta el contenido para que el contenedor pueda acceder (-v datos:/datos:Z), y omitirla es la causa del clásico Permission denied en Fedora con permisos aparentemente correctos. Lo que nunca debe hacerse: seccomp=unconfined o apparmor=unconfined para "arreglar" un error.

  1. Recursos como defensa y por qué --privileged es un error

Los límites de la lección 03-07 no son solo de capacidad: son defensa ante denegación de servicio. Un contenedor comprometido que mine criptomonedas, agote la memoria o lance una fork bomb queda contenido por su pids_limit: 200 y sus deploy.resources.limits.

Y --privileged, en una línea: desactiva casi todas las protecciones a la vez. Concede todas las capacidades, desactiva seccomp y AppArmor, y da acceso a todos los dispositivos del host.

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

Si algo necesita un dispositivo concreto, concédelo con --device=/dev/xxx; si necesita una capacidad concreta, con --cap-add. --privileged es el equivalente a quitar la puerta porque la llave costaba encontrarla.

  1. Escaneo de vulnerabilidades: Scout y 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

Cómo se lee un informe, por orden de decisión:

Campo Qué hacer
Severidad (CRITICAL/HIGH/MEDIUM/LOW) Empieza por las dos primeras
Fixed version Si existe corrección, actualiza. Es el dato decisivo
Paquete Dependencia tuya: npm update. De la base: cambia de base o de digest
Vector de ataque Un CVE en código que no ejecutas es menos urgente, no inexistente

Trivy da lo mismo desde línea de comandos y sirve para automatizar:

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)   -> el comando termina con código 1

--exit-code 1 es la clave de la integración: hace que el comando falle y, con él, el pipeline (lección 06-02). El flujo de trabajo sensato son tres puntos de control: al construir en local, en cada pull request y de forma periódica sobre las imágenes ya desplegadas. Y una nota sobre el socket: montar docker.sock en el escáner es exactamente lo que el apartado 2 desaconseja; es aceptable en tu máquina, pero en CI conviene escanear el tarball de la imagen o usar un escáner sin acceso al daemon.

  1. Cadena de suministro: SBOM, Cosign y procedencia

Un SBOM (Software Bill of Materials) es el inventario de todo lo que hay dentro de una imagen. Sin él, ante un CVE nuevo no puedes responder a la pregunta más básica: ¿me 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 firma imágenes con criptografía y verifica el origen:

cosign generate-key-pair                       # cosign.key (privada) y 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 firma se guarda como un artefacto adicional en el propio registro. Con keyless signing puedes firmar usando la identidad OIDC del pipeline, sin gestionar claves privadas —lo cual, en la práctica, es la mayor ventaja: la clave que nadie custodia es la que nadie filtra.

Las attestations de procedencia responden a "quién construyó esto, desde qué commit y con qué Dockerfile", y se generan durante el build: docker buildx build --provenance=mode=max --sbom=true -t auroralibros/aurora-api:1.2.0 --push ./api, consultables con docker buildx imagetools inspect.

Sobre el mecanismo antiguo: Docker Content Trust (DOCKER_CONTENT_TRUST=1) y Notary v1 fueron el primer intento de firma en Docker Hub. Siguen funcionando, pero están en desuso: la industria se ha movido a Cosign y al ecosistema Sigstore. Si empiezas hoy, empieza con Cosign.

  1. El compose.prod.yaml endurecido

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

  aurora-api:
    # Digest, no etiqueta: contenido exacto y auditado
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?fija la versión}@${AURORA_API_DIGEST:?fija el digest}
    build: !reset null           # el servidor no construye: solo descarga
    user: "1000:1000"            # sin privilegios, aunque la imagen ya lo fije
    read_only: true              # sistema de ficheros inmutable
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop: [ALL]              # ninguna capacidad: escucha en el 3000
    security_opt: [no-new-privileges:true]   # sin escalada vía binarios setuid
    pids_limit: 200              # contención ante fork bomb
    environment:
      NODE_ENV: production
      LOG_NIVEL: warn
      DB_PASSWORD_FILE: /run/secrets/db_password   # nunca el valor en el entorno
    secrets: [db_password]
    ports: !reset []             # sin acceso directo: todo pasa por el 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]  # las mínimas 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 escucha en el 80
    security_opt: [no-new-privileges:true]
    ports: ["80:80"]
    restart: always

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

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

  1. Checklist de seguridad

# Control Cómo se verifica
1 El daemon no es accesible a usuarios sin confianza getent group docker
2 Modo rootless valorado o justificado por escrito docker info | grep -i rootless
3 Ningún contenedor monta docker.sock docker ps -q | xargs docker inspect | grep docker.sock
4 Ningún --privileged en producción grep -r privileged compose*.yaml
5 Todas las imágenes fijadas por digest docker compose config | grep image:
6 Ningún contenedor corre como root docker inspect --format '{{.Config.User}}'
7 read_only: true con sus tmpfs mínimos {{.HostConfig.ReadonlyRootfs}}
8 cap_drop: [ALL] y adiciones justificadas Revisión del compose.prod.yaml
9 no-new-privileges, y seccomp/AppArmor sin unconfined {{.HostConfig.SecurityOpt}}
10 Límites de memoria, CPU y PID en todos docker stats --no-stream
11 Escaneo sin CVE críticos con corrección disponible docker scout cves --only-severity critical
12 Escaneo periódico de lo ya desplegado Tarea programada semanal
13 Secretos por fichero, nunca en ENV ni en capas docker history | grep -iE 'pass|token'
14 SBOM generado y archivado por versión Artefacto del pipeline
15 Imágenes firmadas y verificadas antes de desplegar cosign verify
16 Redes segmentadas y datos sin salida a Internet internal: true en trasera
17 Puertos publicados solo los imprescindibles docker compose ps, iptables -t nat -S DOCKER

Repasa esta lista antes de cada despliegue y revísala con tu responsable de seguridad: es un punto de partida técnico, no un certificado de cumplimiento.

Errores Comunes y Consejos

Creer que un contenedor aísla como una VM. Comparte kernel: un fallo del kernel afecta a todos.

Añadir gente al grupo docker como si fuera un permiso menor, o montar /var/run/docker.sock en un contenedor. Lo primero es conceder root sin trazas en sudo; lo segundo es la vía de escape más explotada que existe.

Pasar secretos con ARG o ENV en el Dockerfile. Quedan en las capas y en los metadatos. docker history los revela.

Usar --privileged para resolver un error de permisos, o desactivar seccomp/AppArmor para que algo funcione. Concede lo concreto que falte (--cap-add, --device); lo otro es apagar la alarma en vez de atender el incendio.

Escanear una vez y olvidarse, o fijar por digest y no renovarlo nunca. Los CVE aparecen después de publicar; una imagen limpia en marzo puede ser vulnerable en junio sin que nadie la toque.

Consejo: aplica el endurecimiento por servicio y midiendo. Añade read_only, arranca, lee el error, añade el tmpfs justo; después cap_drop: [ALL], arranca, lee el error, añade la capacidad justa. Es más lento que copiar una plantilla, y es la única forma de saber por qué está cada línea.

Ejercicios

Ejercicio 1. Demuestra la escalada de privilegios desde el grupo docker: lee un fichero del host que tu usuario no pueda leer y explica exactamente por qué funciona. Después razona qué dos configuraciones lo impedirían.

Ejercicio 2. Endurece aurora-api de forma incremental: user, read_only, cap_drop: [ALL] y no-new-privileges. Documenta qué falla en cada paso, qué tmpfs hace falta y verifica al final que /salud y /libros siguen respondiendo.

Ejercicio 3. Demuestra que un secreto pasado con ARG queda en la imagen: constrúyela, recupéralo con docker history aunque el fichero se haya borrado, y explica por qué el patrón _FILE de Aurora Libros no tiene ese problema.

Soluciones

Solución 1.

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

El mismo fichero, el mismo usuario, dos resultados opuestos. La razón está en quién ejecuta la operación: el comando docker no lee nada, solo envía una petición al socket del daemon; el daemon corre como root y monta / dentro del contenedor con sus propios privilegios. Dentro, el proceso también es root, así que lee lo que quiera. Tu usuario nunca ha necesitado permiso porque nunca ha sido él quien ha leído el fichero.

Las dos configuraciones que lo impiden son el modo rootless (el daemon corre como tu usuario y el root de dentro se remapea a tu UID real, que no puede leer /etc/shadow) y --userns-remap (el root del contenedor se remapea a un UID sin privilegios, con el mismo efecto sobre el montaje).

La conclusión de gestión es la que importa: usermod -aG docker alguien es una concesión de root y debe pasar por el mismo proceso de aprobación que conceder sudo sin contraseña.

Solución 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   # paso 2: falla
1000
Error: EROFS: read-only file system, open '/tmp/aurora-api.pid'
# Pasos 3 y 4: tmpfs mínimo, sin capacidades y sin 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/salud | jq -c '{estado, bd, cache}'
docker inspect h4 --format 'ro={{.HostConfig.ReadonlyRootfs}} caps={{.HostConfig.CapDrop}} opts={{.HostConfig.SecurityOpt}}'
docker exec h4 sh -c 'touch /app/puerta.js' 2>&1
{"estado":"ok","bd":"conectada","cache":"conectada"}
ro=true caps=[ALL] opts=[no-new-privileges:true]
touch: /app/puerta.js: Read-only file system

El único paso que ha fallado ha sido el segundo, por un motivo concreto: la aplicación escribe su fichero de PID en /tmp. Un tmpfs de 64 MB con noexec,nosuid lo resuelve sin devolver escritura al resto del sistema de ficheros. cap_drop: [ALL] no ha roto nada porque aurora-api escucha en el 3000 (no privilegiado) y ya corría como el usuario node: aquí se recoge el fruto de la decisión que tomaste en la lección 02-04.

El resultado es un contenedor donde un atacante con ejecución de código no puede escribir un binario, no puede ejecutarlo si lo escribe en /tmp, no tiene ninguna capacidad del kernel, no puede escalar por setuid y no puede lanzar más de 200 procesos. La aplicación, mientras tanto, responde exactamente igual.

Solución 3.

mkdir -p /tmp/fuga && cd /tmp/fuga
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 fuga:1.0 . >/dev/null
docker run --rm fuga:1.0 ls /token.txt 2>&1        # el fichero ya no está
docker history --no-trunc fuga:1.0 | grep -o 'API_TOKEN=[^ |]*' | head -1
docker save fuga: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 hechos encadenados: el fichero no existe en el sistema final, el token aparece en el historial de construcción, y aparece también dentro de los datos de la capa exportada. Lo primero explica por qué tanta gente cree que borrar el fichero basta; lo segundo y lo tercero, por qué no es así: el ARG queda en los metadatos y el contenido borrado sigue en su capa, oculto solo por un whiteout (lección 05-02).

El patrón _FILE de Aurora Libros no tiene ese problema porque el secreto nunca entra en la imagen. La imagen contiene solo DB_PASSWORD_FILE=/run/secrets/db_password, que es 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)

En los metadatos solo hay una ruta; el valor llega por un montaje de solo lectura que existe únicamente mientras el contenedor vive. Si mañana publicas esa imagen en Docker Hub, no viaja ningún secreto dentro. Para los secretos que se necesitan durante la construcción —un token de npm privado—, la solución equivalente es RUN --mount=type=secret de BuildKit, y es justo lo que verás en la lección 05-05.

Conclusión

Aurora Libros está endurecida y, más importante, sabes por qué lo está cada línea. Partiste del modelo de amenazas: un contenedor aísla procesos, ficheros y red, pero comparte kernel, y las cuatro superficies —imagen, runtime, daemon y registro— exigen defensas distintas que no se sustituyen entre sí. Has demostrado, leyendo /etc/shadow sin ser root, que pertenecer al grupo docker es tener root en el host, y conoces las dos respuestas: el modo rootless con sus limitaciones reales (puertos bajos, red algo más lenta, sin --network host) y --userns-remap como paso intermedio.

En la imagen aplicas base mínima fijada por digest, la comparativa entre Alpine, distroless y scratch con su coste de operabilidad, y la prueba irrefutable de que un secreto pasado por ARG o ENV queda en las capas para siempre. En el runtime, --read-only con tmpfs noexec,nosuid para lo justo, cap_drop: [ALL] con la tabla de capacidades que de verdad importan —y aurora-api sin ninguna—, no-new-privileges, el perfil seccomp por defecto y cuándo escribir uno propio, AppArmor y SELinux con su :Z, los límites de recursos como contención de denegación de servicio, y el porqué de que --privileged sea casi siempre un error. Escaneas con Docker Scout y Trivy sabiendo leer un informe —la versión corregida es el dato decisivo— e integrándolo con --exit-code 1; y aseguras la cadena de suministro con SBOM, firma con Cosign y attestations de procedencia, dejando Content Trust como el mecanismo antiguo que es. Todo condensado en un compose.prod.yaml comentado línea a línea y en una checklist de diecisiete controles verificables. Y la advertencia inicial sigue en pie: esto es un suelo técnico sólido, no una política, así que valida cada decisión con el responsable de seguridad o de compliance de tu organización.

En la lección 05-04 cambiamos de objetivo y volvemos a la imagen, ahora con la báscula delante: por qué el tamaño importa (descarga, coste, superficie de ataque, velocidad de escalado), cómo medir con history y dive para encontrar la capa gorda, qué base elegir con cifras reales, y sobre todo las construcciones multietapa, con las que esos 142 MB de aurora-api van a bajar hasta una cifra concreta sin perder nada de lo que acabas de endurecer.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados