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 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.

Puertos por debajo del 1024

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.

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 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
  • USER va después de las instalaciones, y COPY --chown para los permisos
  • Un usuario sin privilegios no abre puertos bajos: usa el 8080 y mapea por fuera
  • --read-only más --tmpfs para lo temporal
  • --cap-drop=ALL y añade 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


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