Seguridad de imágenes
Hay dos preguntas distintas sobre la seguridad de un contenedor:
- ¿Qué vulnerabilidades tiene? — se responde escaneando
- ¿Cómo de peligroso es si alguien entra? — se responde endureciendo
Este capítulo va de la segunda. El escaneo con Trivy lo tienes en el curso de DevSecOps.
Tu contenedor corre como root
Compruébalo ahora mismo:
docker run --rm alpine id
uid=0(root) gid=0(root) groups=0(root),...
Ese es el comportamiento por defecto de prácticamente todas las imágenes oficiales. Y aunque el root de un contenedor no es exactamente el root del anfitrión —hay namespaces de por medio—, sigue siendo un mal punto de partida: si alguien consigue ejecutar código en tu aplicación, empieza con todos los permisos dentro del contenedor y con una superficie mucho mayor para intentar escapar.
Ejecutar como usuario sin privilegios
En el Dockerfile:
FROM node:22-alpine
# Crear un usuario propio
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001 -G app
WORKDIR /app
# Copiar ya con el dueño correcto
COPY --chown=app:app . .
RUN npm ci --omit=dev
# A partir de aquí, todo corre sin privilegios
USER app
CMD ["node", "src/index.js"]
Dos detalles que la gente pasa por alto:
El orden importa. Todo lo que va después de USER se ejecuta sin privilegios, así que las instalaciones de paquetes tienen que ir antes.
--chown en el COPY. Sin él, los ficheros pertenecen a root y tu usuario no puede escribir donde necesite.
También puedes forzarlo al ejecutar, sin tocar la imagen:
docker run --rm --user 1000:1000 alpine id
uid=1000 gid=1000 groups=1000
Es útil para imágenes de terceros que no puedes modificar.
Un usuario sin privilegios no puede abrir el puerto 80. La solución no es volver a root: haz que la aplicación escuche en el 8080 y mapea el puerto por fuera con -p 80:8080.
Sistema de ficheros en solo lectura
Si tu aplicación no necesita escribir en disco, no la dejes escribir. Convierte muchos ataques en un error:
docker run --rm --read-only alpine sh -c 'touch /prueba'
touch: /prueba: Read-only file system
Casi ninguna aplicación funciona con el disco totalmente bloqueado, porque necesita algún directorio temporal. Para eso se monta un tmpfs, que vive en memoria y desaparece al parar el contenedor:
docker run --rm --read-only --tmpfs /tmp alpine sh -c 'touch /tmp/ok && echo OK'
OK
En docker-compose.yml:
services:
app:
image: mi-app:1.0
read_only: true
tmpfs:
- /tmp
Capabilities
Root dentro de un contenedor no tiene poderes ilimitados: tiene un conjunto de capabilities, permisos concretos del kernel. Docker ya recorta unas cuantas por defecto, pero deja bastantes activas.
Míralo en la práctica:
# Por defecto puede cambiar el dueño de un fichero
docker run --rm alpine chown nobody /etc/hostname
# (funciona)
# Sin capabilities, no
docker run --rm --cap-drop=ALL alpine chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted
La estrategia correcta es quitarlas todas y añadir solo lo que haga falta:
docker run --rm \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
mi-app:1.0
NET_BIND_SERVICE es la excepción habitual: permite abrir puertos bajos sin ser root.
Impedir la escalada de privilegios
Una línea que evita que un proceso gane permisos mediante binarios setuid:
docker run --rm --security-opt=no-new-privileges mi-app:1.0
No tiene contraindicaciones para una aplicación normal. Ponla siempre.
Menos cosas dentro, menos que atacar
Cada paquete que no está en la imagen es un paquete que no puede tener un CVE. Por eso las imágenes mínimas no son solo una cuestión de tamaño.
| Base | Tamaño aprox. | Superficie |
|---|---|---|
ubuntu | ~78 MB | Sistema completo, shell, gestor de paquetes |
debian:slim | ~30 MB | Sistema recortado |
alpine | ~7 MB | Mínimo, con shell |
distroless | ~2-20 MB | Solo el runtime. Sin shell |
scratch | 0 MB | Nada. Solo tu binario |
Lo interesante de distroless y scratch es que no tienen shell. Si alguien logra ejecución remota, no hay sh que lanzar, ni curl para descargar la siguiente fase del ataque, ni gestor de paquetes para instalar nada.
El precio es que depurar dentro del contenedor deja de ser posible — y eso es una decisión consciente, no un descuido. Lo compensas con buena observabilidad.
Cómo construir estas imágenes, con multi-stage, lo tienes en el capítulo anterior.
No metas secretos en la imagen
Un error muy común y con consecuencias duraderas:
# ❌ La clave queda en la capa, para siempre
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:org/repo.git
RUN rm /root/.ssh/id_rsa # esto NO la borra de la imagen
Cada instrucción crea una capa. Borrar un fichero en una capa posterior no lo elimina de la anterior: cualquiera con la imagen puede recuperarlo.
La forma correcta usa montajes de secretos en el build, que no dejan rastro:
RUN --mount=type=secret,id=github_token \
git clone https://$(cat /run/secrets/github_token)@github.com/org/repo.git
docker build --secret id=github_token,env=GITHUB_TOKEN .
Y no olvides el .dockerignore, que evita que se cuelen ficheros por accidente:
.git
.env
node_modules
*.pem
*.key
Revisa el Dockerfile automáticamente
Hadolint analiza tu Dockerfile y avisa de los fallos habituales:
docker run --rm -i hadolint/hadolint < Dockerfile
Detecta etiquetas latest, apt-get sin --no-install-recommends, falta de USER y una lista larga de malas prácticas. Cuesta un minuto integrarlo en el pipeline.
Checklist
FROM node:22-alpine # ✅ versión fijada, base mínima
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . . # ✅ dueño correcto
RUN npm ci --omit=dev
USER app # ✅ sin privilegios
EXPOSE 8080 # ✅ puerto alto
CMD ["node", "src/index.js"] # ✅ forma exec, no shell
docker run \
--read-only --tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--memory=512m --cpus=1 \
mi-app:1.0
Los límites de memoria y CPU también son seguridad: evitan que un contenedor comprometido consuma toda la máquina. Los vimos en el capítulo de límites de recursos.
Resumen
- Por defecto, tu contenedor corre como root: compruébalo con
docker run --rm alpine id USERva después de las instalaciones, yCOPY --chownpara los permisos- Un usuario sin privilegios no abre puertos bajos: usa el 8080 y mapea por fuera
--read-onlymás--tmpfspara lo temporal--cap-drop=ALLy añade solo lo imprescindible--security-opt=no-new-privilegessiempre: no tiene contraindicaciones- Distroless y scratch no tienen shell, y eso es la mitad de su valor
- Borrar un secreto en una capa posterior no lo elimina de la imagen
Recursos adicionales
- Multi-stage y distroless — cómo construir imágenes mínimas
- Escanear imágenes con Trivy — la otra mitad del problema
- Escalar privilegios en Docker
- Hadolint
- Secretos en Docker
💡 Recuerda: endurecer un contenedor no evita que te entren. Reduce mucho lo que pueden hacer una vez dentro.