Saltar al contenido principal

Seguridad de imágenes

Hay dos preguntas distintas sobre la seguridad de un contenedor:

  1. ¿Qué vulnerabilidades tiene? — se responde escaneando
  2. ¿Cómo de peligroso es si alguien entra? — se responde endureciendo

Este capítulo va de la segunda. El escaneo con Trivy lo tenemos en el curso de DevSecOps.

Nuestro contenedor corre como root​

Comprobémoslo 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 nuestra 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 nuestro usuario no puede escribir donde necesite.

También podemos 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 podemos modificar.

Puertos por debajo del 1024

Un usuario sin privilegios no puede abrir el puerto 80. La solución no es volver a root: hagamos que la aplicación escuche en el 8080 y mapeemos el puerto por fuera con -p 80:8080.

Sistema de ficheros en solo lectura​

Si nuestra aplicación no necesita escribir en disco, no la dejemos escribir. Convertimos 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.

Veámoslo 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. Pongámosla 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.

BaseTamaño aprox.Superficie
ubuntu~78 MBSistema completo, shell, gestor de paquetes
debian:slim~30 MBSistema recortado
alpine~7 MBMínimo, con shell
distroless~2-20 MBSolo el runtime. Sin shell
scratch0 MBNada. Solo nuestro 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 compensamos con buena observabilidad.

Cómo construir estas imágenes, con multi-stage, lo tenemos en el capítulo anterior.

No metamos 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 olvidemos el .dockerignore, que evita que se cuelen ficheros por accidente:

.git
.env
node_modules
*.pem
*.key

Revisemos el Dockerfile automáticamente​

Hadolint analiza nuestro 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, nuestro contenedor corre como root: comprobémoslo con docker run --rm alpine id
  • USER va después de las instalaciones, y COPY --chown para los permisos
  • Un usuario sin privilegios no abre puertos bajos: usemos el 8080 y mapeemos por fuera
  • --read-only más --tmpfs para lo temporal
  • --cap-drop=ALL y añadamos solo lo imprescindible
  • --security-opt=no-new-privileges siempre: 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​


💡 Recordemos: endurecer un contenedor no evita que nos entren. Reduce mucho lo que pueden hacer una vez dentro.