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
- Modelo de amenazas: qué aísla un contenedor y qué no
- El daemon: el grupo
dockeres root - Docker rootless
--userns-remap, la alternativa intermedia- La imagen: base mínima y fijada por digest
distrolessyscratchfrente a Alpine- Secretos en capas: la prueba con
docker history - Runtime:
--read-onlyy--tmpfs - Capacidades del kernel
no-new-privileges, seccomp y AppArmor- Recursos como defensa y por qué
--privilegedes un error - Escaneo de vulnerabilidades: Scout y Trivy
- Cadena de suministro: SBOM, Cosign y procedencia
- El
compose.prod.yamlendurecido - Checklist de seguridad
- 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.
- El daemon: el grupo
docker es root
docker es rootEsta 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'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
dockeres concederle root. Trátalo con ese criterio en tu gestión de accesos. - Nunca montes
/var/run/docker.sockdentro 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.
- 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 -1rootless=[name=seccomp,profile=builtin name=rootless name=cgroupns] | dir: /home/joan/.local/share/docker
cat: can't open '/host/etc/shadow': Permission deniedEl 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.
--userns-remap, la alternativa intermedia
--userns-remap, la alternativa intermediaSi 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-headersDentro 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.
- 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:3f1c8d9e7a4b2c5f6e0d1a8b7c4e2f9d0a3b6c5e8f1d2a7b4c9e0f3a6b5d8c1eEl 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.
distroless y scratch frente a Alpine
distroless y scratch frente a Alpine| Base | Tamaño | Shell | Paquetes | Superficie | Depuración |
|---|---|---|---|---|---|
node:22 (Debian) |
~1,1 GB | sí | apt | Muy grande | Cómoda |
node:22-slim |
~230 MB | sí | 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.
- Secretos en capas: la prueba con
docker history
docker historyUn 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/.npmrcdocker 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.
- Runtime:
--read-only y --tmpfs
--read-only y --tmpfsSi el sistema de ficheros del contenedor es de solo lectura, un atacante no puede dejar ningún binario ni modificar el código.
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'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é.
- 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"'| 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.
no-new-privileges, seccomp y AppArmor
no-new-privileges, seccomp y AppArmorno-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"'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.
- Recursos como defensa y por qué
--privileged es un error
--privileged es un errorLos 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 montablesSi 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.
- Escaneo de vulnerabilidades: Scout y Trivy
✗ 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 packagesCó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.
- 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 -4Cosign 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.0Verification for auroralibros/aurora-api:1.2.0 --
- The cosign claims were validated
- The signatures were verified against the specified public keyLa 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.
- El
compose.prod.yaml endurecido
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 0400Verifica 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'.
- 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.
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# 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 systemEl ú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 -1Tres 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
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
