En el capítulo anterior de este tutorial de docker viste buenas prácticas para optimizar y mantener tus imágenes y contenedores. Pero hay un aspecto que a menudo se pasa por alto hasta que es demasiado tarde: la seguridad.
Docker, por diseño, prioriza la facilidad de uso sobre la seguridad. El contenedor se ejecuta como root por defecto, tiene un montón de capabilities Linux, y el socket de Docker suele estar al alcance de cualquiera que pertenezca al grupo docker. Esto no significa que Docker sea inseguro, sino que tienes que configurarlo conscientemente para que sea seguro.
En este capítulo te acompañaré en un recorrido completo por las técnicas de seguridad. Partiremos de un escenario realista: un contenedor web con Nginx que iremos endureciendo paso a paso, aplicando cada una de las técnicas que veremos. Al final tendrás un conjunto de herramientas y buenas prácticas para que tus contenedores sean más seguros.
Docker Bench Security y los CIS Benchmarks
El CIS Docker Benchmark es un estándar de referencia que define más de 100 controles de seguridad agrupados en categorías: configuración del host, configuración del daemon, imágenes, contenedores, redes, etc. Piensa en ello como una lista de comprobación para saber si tu instalación de Docker está bien atada.
Docker Bench Security es la herramienta oficial que automatiza la verificación de estos controles. Se ejecuta como un contenedor que inspecciona el host y el daemon de Docker para identificar desviaciones de seguridad. Te recomiendo que la ejecutes en tu sistema para ver qué tal estás.
docker run --rm \
--net host \
--pid host \
--userns host \
--cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /usr/lib/systemd:/usr/lib/systemd \
-v /etc:/etc \
--label docker_bench_security \
docker/docker-bench-security
El resultado es un informe donde cada control aparece marcado como [PASS], [WARN], [FAIL] o [INFO]. Algunos de los controles más importantes que verifica:
[PASS] 1.1.1— Ensure a separate partition for containers (AIDE)[WARN] 2.1— Ensure network traffic is restricted between containers[FAIL] 2.5— Ensure that--disable-legacy-registryis not set[PASS] 4.1— Ensure that a user for the container has been created[WARN] 5.4— Ensure that--cap-drop=ALLis used
Puedes integrarlo en tu pipeline de CI/CD para que falle si hay controles críticos sin superar:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
docker/docker-bench-security \
| grep FAIL
if [ $? -eq 0 ]; then
echo "Hay controles FAIL en el benchmark"
exit 1
fi
Linux Capabilities
Pero, ¿qué son las Linux capabilities? Se trata de unidades atómicas de privilegio que dividen los poderes del superusuario root en piezas más pequeñas. En lugar de tener que elegir entre root (todo o nada), puedes otorgar capabilities específicas a un contenedor.
Lista de capabilities comunes
- CHOWN — cambiar propietario de archivos, riesgo bajo
- NET_BIND_SERVICE — vincular puertos menores de 1024, riesgo bajo
- SYS_ADMIN — múltiples operaciones del kernel, riesgo muy alto
- SYS_PTRACE — depurar procesos con ptrace, riesgo alto
- NET_RAW — usar sockets RAW, riesgo medio
- DAC_OVERRIDE — eludir permisos de archivos, riesgo alto
- SYS_MODULE — cargar módulos del kernel, riesgo crítico
- SYS_BOOT — reiniciar el sistema, riesgo crítico
- KILL — enviar señales a cualquier proceso, riesgo bajo si no hay otros privilegios
Docker concede un conjunto de unas 14 capabilities por defecto a cada contenedor. La estrategia de seguridad es drop all y luego add solo las necesarias:
# Lo minimo para un servidor web
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE --cap-add=CHOWN nginx
# Para un contenedor que hace ping
docker run --cap-drop=ALL --cap-add=NET_RAW alpine ping 8.8.8.8
# Para un contenedor que necesita establecer limites de recursos
docker run --cap-drop=ALL --cap-add=SYS_RESOURCE mi-app
En docker-compose se define así:
services:
web:
image: nginx:alpine
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
¿Cómo saber qué capabilities necesita tu aplicación? Usa strace o ejecuta con la capability denegada y observa los errores:
# Intenta ejecutar sin capabilities
docker run --rm --cap-drop=ALL nginx
# Error: chown /var/cache/nginx/client_temp failed (1: Operation not permitted)
# Necesita CHOWN
O usa la herramienta capsh dentro del contenedor:
docker run --rm --cap-drop=ALL alpine sh -c "apk add libcap && capsh --print"
–privileged: el botón rojo
El flag --privileged es el equivalente a darle acceso root completo al host al contenedor. Elimina TODAS las restricciones de capabilities, acceso a dispositivos, seccomp y AppArmor.
docker run --privileged ubuntu bash
Un contenedor privilegiado puede:
- Montar y desmontar sistemas de archivos del host
- Cargar módulos del kernel
- Acceder a todos los dispositivos (
/dev/sda,/dev/mem, etc.) - Modificar configuraciones de red del host
- Escapar del contenedor trivialmente
El ejemplo de escape con contenedor privilegiado habla por sí solo:
# Dentro del contenedor privilegiado
mount /dev/sda1 /mnt
chroot /mnt
# Ya estas en el host
NUNCA uses --privileged en producción. Si necesitas una capability específica, añádela con --cap-add. Si necesitas acceso a un dispositivo específico, usa --device:
# En lugar de --privileged
docker run --device /dev/snd:/dev/snd mi-app-audio
# O para GPU
docker run --gpus all mi-app-gpu
Perfiles seccomp
Seccomp (Secure Computing Mode) es una característica del kernel Linux que permite restringir las llamadas al sistema (syscalls) que un proceso puede hacer. Docker incluye un perfil por defecto que bloquea unas 44 syscalls peligrosas (como mount, reboot, kexec_load, bpf, perf_event_open). Pero puedes crear perfiles personalizados para ser aún más restrictivo.
Para ver el perfil por defecto de Docker:
docker run --rm alpine cat /etc/containers/seccomp.json 2>/dev/null || \
echo "No disponible en Alpine; consulta la documentacion de Docker"
O descárgalo del repositorio de Docker:
wget https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json
Perfil personalizado
Crea un archivo custom-seccomp.json:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["accept", "bind", "clone", "close", "connect",
"exit", "exit_group", "fcntl", "fstat", "fsync",
"getdents", "getpeername", "getsockname", "listen",
"lseek", "mkdir", "mmap", "mprotect", "munmap",
"nanosleep", "open", "openat", "poll", "read",
"readlink", "recvfrom", "recvmsg", "rename",
"rt_sigaction", "rt_sigprocmask", "sendmmsg",
"sendto", "set_robust_list", "setsockopt", "shutdown",
"signal", "socket", "stat", "write", "writev"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Este perfil solo permite las syscalls mínimas para una aplicación web simple. Cualquier syscall no listada genera un error.
docker run --security-opt seccomp=custom-seccomp.json nginx
Cómo depurar seccomp
Usa strace para identificar qué syscalls necesita tu aplicación:
# En el host
strace -c -p $(docker inspect --format '{{.State.Pid}}' mi-contenedor)
O usa la herramienta audit2allow adaptada:
# Instala auditd en el contenedor
apt-get install -y auditd
# Monitorea syscalls denegadas
auditctl -a exclude,always -F msgtype=SYSCALL
ausearch -m SECCOMP -ts recent
Estrategias seccomp de menos a más restrictivas:
- Perfil por defecto de Docker (
default.json): unas 300 syscalls permitidas, seguridad alta - Perfil restrictivo personalizado (el que tú definas): 40-50 syscalls, seguridad muy alta
- Sin seccomp (
unconfined): todas las syscalls (más de 560), seguridad baja - Runtime de Go/Rust: perfil mínimo de 20-30 syscalls, seguridad máxima
AppArmor y SELinux
Tanto AppArmor como SELinux son sistemas de Mandatory Access Control (MAC) que permiten definir políticas de seguridad a nivel de sistema. Docker puede integrarse con ambos.
AppArmor (Ubuntu, Debian)
AppArmor asocia perfiles de seguridad a ejecutables. Docker instala un perfil por defecto (docker-default) que se aplica a todos los contenedores.
Para ver el perfil activo:
docker inspect --format '{{.AppArmorProfile}}' mi-contenedor
Crear un perfil personalizado:
# /etc/apparmor.d/docker-nginx
#include <tunables/global>
profile docker-nginx flags=(attach_disconnected, mediate_deleted) {
#include <abstractions/base>
# Red
network tcp,
network udp,
# Archivos
/etc/nginx/** r,
/var/log/nginx/** w,
/var/cache/nginx/** rw,
/usr/sbin/nginx rix,
# Denegar todo lo demas
deny /** w,
deny /proc/** rwx,
}
Cargar el perfil:
apparmor_parser -r /etc/apparmor.d/docker-nginx
Ejecutar el contenedor con el perfil:
docker run --security-opt apparmor=docker-nginx nginx
SELinux (RHEL, CentOS, Fedora)
SELinux usa etiquetas de contexto (type enforcement). Docker asigna un tipo container_t por defecto.
# Ver contexto SELinux del contenedor
docker inspect --format '{{.ProcessLabel}}' mi-contenedor
# Salida: system_u:system_r:container_t:s0:c1,c2
Para aplicar políticas más restrictivas:
# Sin SELinux (no recomendado)
docker run --security-opt label=disable nginx
# Con tipo especifico
docker run --security-opt label=type:container_nginx_t nginx
# Nivel de sensibilidad
docker run --security-opt label=level:s0:c100,c200 nginx
Comparativa AppArmor vs SELinux
- AppArmor: usa perfiles por programa, disponible en Ubuntu/Debian/SUSE, curva de aprendizaje media, flexibilidad buena, integración nativa con Docker, impacto en rendimiento casi nulo
- SELinux: usa etiquetas por objeto, disponible en RHEL/CentOS/Fedora, curva de aprendizaje alta, flexibilidad muy alta, integración nativa con Docker, impacto en rendimiento casi nulo
Rootless mode
El rootless mode permite ejecutar el demonio de Docker y todos los contenedores sin privilegios de root en el host. Esto elimina de raíz la mayoría de vectores de ataque asociados a contenedores.
Requisitos
- Linux kernel >= 4.11 (idealmente >= 5.13 para
newuidmap/newgidmap) uidmapinstalado (apt install uidmap)fuse-overlayfspara el driver de almacenamiento- Usuario con sub-UID/sub-GID configurados en
/etc/subuidy/etc/subgid
Instalación
# Añadir sub-UIDs y sub-GIDs para el usuario
echo "lorenzo:100000:65536" >> /etc/subuid
echo "lorenzo:100000:65536" >> /etc/subgid
# Instalar Docker rootless
curl -fsSL https://get.docker.com/rootless | sh
Configuración
Las variables de entorno necesarias (se añaden a ~/.bashrc):
export PATH=/home/lorenzo/bin:$PATH
export DOCKER_HOST=unix:///run/user/1000/docker.sock
Inicio del demonio
systemctl --user start docker
systemctl --user enable docker
Comprobación
docker info | grep -i rootless
# Security Options: rootless
Limitaciones
- No puedes mapear puertos menores de 1024 directamente (necesitas
authbindo redirigir con iptables) - Ciertos drivers de almacenamiento (
overlay2) requierenfuse-overlayfsque es más lento - No funciona con
--privileged(por definición, es rootless) - Imágenes que requieren
SYS_ADMINno funcionarán - El modo
hostnetworking no está disponible
Pero te lo digo claro: el rootless mode elimina el mayor riesgo de seguridad de Docker. Para entornos de producción donde la seguridad es crítica, es la configuración recomendada. Dale caña.
userns-remap (User Namespaces)
Si no puedes o no quieres usar rootless mode, --userns-remap es una alternativa excelente. Remapea los UIDs/GIDs dentro del contenedor a UIDs no privilegiados en el host.
Configuración
Edita /etc/docker/daemon.json:
{
"userns-remap": "default"
}
O mapea a un usuario específico:
{
"userns-remap": "dockremap:50000"
}
Reinicia Docker:
systemctl restart docker
Cómo funciona
- Sin userns-remap: UID 0 dentro del contenedor = 0 (root) en el host
- Con userns-remap: UID 0 dentro del contenedor = 165536 (dockremap) en el host
- Con userns-remap: UID 1000 dentro del contenedor = 166536 en el host
Esto significa que aunque un atacante escape del contenedor como root, en el host será un usuario sin privilegios (UID 165536).
Impacto en volúmenes
Los volúmenes montados desde el host cambian de propietario:
# Con userns-remap, root dentro del contenedor = dockremap en el host
# Necesitas reasignar permisos del volumen
chown -R 165536:165536 /ruta/al/volumen
O usa --userns=host para contenedores específicos que necesiten acceder a volúmenes con UIDs reales:
docker run --userns=host -v /data:/data mi-app
read-only y tmpfs
Ejecutar contenedores con el sistema de archivos raíz en modo solo lectura es una de las medidas de seguridad más efectivas. Impide que un atacante modifique binarios, escriba scripts maliciosos o altere configuraciones.
docker run --read-only nginx
# Error: nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
La mayoría de las aplicaciones necesitan escribir en algunos directorios (logs, temporales, cachés). Para eso usas --tmpfs:
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/run:rw,noexec,nosuid,size=32m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=16m \
nginx
Flags de tmpfs
rw: lectura/escrituranoexec: no se pueden ejecutar binariosnosuid: ignora el bit setuidnodev: no se permite crear dispositivossize: tamaño máximo (64m = 64 MB)
En docker-compose
services:
nginx:
image: nginx:alpine
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /var/run:rw,noexec,nosuid,size=32m
- /var/cache/nginx:rw,noexec,nosuid,size=16m
Cómo identificar directorios de escritura
Usa strace o prueba con --read-only y observa los errores:
docker run --rm --read-only nginx 2>&1 | grep "Read-only file system"
# nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
Cada error te indica un directorio que necesita ser montado como tmpfs o como volumen externo.
Gestión de secretos
Los secretos (contraseñas, API keys, tokens) nunca deben estar en el Dockerfile, ni en variables de entorno visibles en docker inspect, ni en capas de imagen accesibles en el historial.
BuildKit –secret (en tiempo de build)
El problema clásico:
# MAL: el token queda en el historial de capas
RUN echo "ghp_abc123" > /tmp/token.txt && \
curl -H "Authorization: token $(cat /tmp/token.txt)" https://api.github.com && \
rm /tmp/token.txt
Aunque borres el archivo, la capa intermedia existe. Cualquiera con acceso a la imagen puede ver el token:
docker history mi-app
docker run --rm mi-app cat /tmp/token.txt
Con BuildKit:
# syntax = docker/dockerfile:1.4
FROM python:3.12-slim
RUN --mount=type=secret,id=github_token \
curl -H "Authorization: token $(cat /run/secrets/github_token)" https://api.github.com
docker build --secret id=github_token,src=./token.txt -t mi-app .
Docker Secrets (en tiempo de ejecución Swarm)
# Crear un secreto
echo "SuperSecret123!" | docker secret create db_password -
# Usarlo en un servicio
docker service create \
--name mi-app \
--secret db_password \
-e DB_PASSWORD_FILE=/run/secrets/db_password \
mi-app:latest
Docker Secrets en Compose
services:
app:
image: mi-app:latest
secrets:
- db_password
- api_key
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
- API_KEY_FILE=/run/secrets/api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
Los secretos se montan en /run/secrets/<nombre> dentro del contenedor, en memoria (tmpfs), nunca en disco.
Alternativa segura sin Swarm
Si no usas Swarm, puedes montar secretos desde archivos del host con permisos restrictivos:
docker run -v /etc/secrets/db_password:/run/secrets/db_password:ro mi-app
O usar un gestor externo como HashiCorp Vault o AWS Secrets Manager.
Docker Content Trust (DCT)
DOCKER_CONTENT_TRUST permite firmar y verificar imágenes usando el framework TUF (The Update Framework). Cuando está activado, Docker solo permite hacer pull de imágenes firmadas.
Activación
export DOCKER_CONTENT_TRUST=1
Firmar y subir
# La primera vez solicitara crear una passphrase para la clave raiz y de firma
docker push usuario/mi-app:latest
Verificar en pull
export DOCKER_CONTENT_TRUST=1
docker pull usuario/mi-app:latest
# Si no esta firmada:
# Error: remote trust data does not exist for docker.io/usuario/mi-app: latest
Claves de DCT
- Clave raíz: la más importante, firma otras claves. Guárdala offline en un USB cifrado
- Clave de firma (repository): firma tags individuales, se guarda en
~/.docker/trust/ - Clave timestamp: actualiza metadatos de tiempo, se guarda en el servidor de notary
Limitaciones
- Solo funciona con registros que soporten Notary (Docker Hub, registros con Notary)
- No es compatible con registros tipo Harbor sin configuración adicional
- La verificación solo ocurre en pull, no en ejecución
- No impide que ejecutes imágenes no firmadas si desactivas DCT
- Es más complejo que Cosign (lo viste en el capítulo 13)
Escaneo de vulnerabilidades
El escaneo de imágenes Docker debe ser parte obligatoria de tu pipeline de CI/CD. Tres herramientas dominan el panorama: Trivy, Docker Scout y Snyk.
Trivy Open source y rápido
# Instalacion
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Escaneo completo
trivy image nginx:alpine
# Escaneo con severidad minima y salida JSON
trivy image --severity CRITICAL,HIGH --format json --output trivy-report.json nginx:alpine
# Fallar si hay CRITICAL
trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed nginx:alpine
Docker Scout
# Escaneo rapido
docker scout quickview nginx:alpine
# Recomendaciones de imagen base
docker scout recommendations nginx:alpine
# Ver CVEs detallados
docker scout cves nginx:alpine
Image scanning en CI (GitHub Actions)
name: Image Security Scan
on: [push]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mi-app:${{ github.sha }} .
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: mi-app:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
- name: Docker Scout scan
run: |
docker scout cves mi-app:${{ github.sha }}
Comparativa de herramientas
- Trivy: open source, velocidad alta, escanea OS + librerías + IaC, ideal para CI/CD y equipos DevOps
- Docker Scout: parcialmente open source, velocidad media, da recomendaciones de imagen base, ideal para usuarios de Docker Desktop
- Snyk: parcialmente open source, velocidad media, escanea dependencias e infraestructura, ideal para equipos de seguridad
- Anchore Grype: open source, velocidad alta, escanea OS + librerías, alternativa a Trivy
Seguridad del socket de Docker
El socket de Docker (/var/run/docker.sock) es el acceso al daemon de Docker. Cualquier persona o proceso que pueda leer y escribir en este socket tiene acceso root completo al host.
El problema:
# Montar el socket dentro de un contenedor
docker run -v /var/run/docker.sock:/var/run/docker.sock ubuntu bash
# Dentro del contenedor: eres root del host
docker ps # Lista todos los contenedores del host
docker run --privileged -v /:/host ubuntu chroot /host # Escapar
Buenas prácticas
- No montes el socket dentro de contenedores a menos que sea estrictamente necesario (Docker-in-Docker, CI runners)
- Usa un socket proxy que limite las operaciones permitidas:
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
tecnativa/docker-socket-proxy \
-e CONTAINERS=1 -e IMAGES=1 -e NETWORKS=1
- Usa grupos de usuario: solo los miembros del grupo
dockertienen acceso al socket. Limita quién pertenece a ese grupo.
# Ver miembros del grupo docker
grep docker /etc/group
# Añadir usuario al grupo (requiere re-login)
sudo usermod -aG docker lorenzo
- Rootless mode: el socket rootless solo es accesible por el usuario que ejecuta el demonio, no por todo el grupo docker
Alternativa: API REST con TLS
En lugar del socket Unix, configura Docker para escuchar en una API con TLS:
{
"hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"],
"tlsverify": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem"
}
Límites de recursos como medida de seguridad
Los límites de recursos no solo evitan que un contenedor consuma todo el servidor, también son una medida de seguridad contra ataques de denegación de servicio (DoS).
Memory limits
# Limite estricto (si excede, OOM kill)
docker run --memory=256m mi-app
# Reserva garantizada
docker run --memory-reservation=128m mi-app
# Swap (0 = sin swap)
docker run --memory=256m --memory-swap=256m mi-app
CPU limits
# Usar como maximo 1.5 CPUs
docker run --cpus=1.5 mi-app
# Prioridad relativa (por defecto 1024, mas alto = mas prioridad)
docker run --cpu-shares=512 mi-app # Menor prioridad
Reinicio controlado
# No reiniciar nunca (seguro contra ataques de crash-loop)
docker run --restart=no mi-app
# Reiniciar solo si el contenedor falla, no si se detiene manualmente
docker run --restart=unless-stopped mi-app
# Con backoff exponencial (maximo 5s entre reintentos)
docker run --restart=on-failure:5 mi-app
PIDs limit
Previene fork bombs dentro del contenedor:
docker run --pids-limit=100 mi-app
En docker-compose
services:
app:
image: mi-app:latest
deploy:
resources:
limits:
cpus: '0.50'
memory: 256M
pids: 100
reservations:
cpus: '0.25'
memory: 128M
restart: unless-stopped
Seguridad de redes
El aislamiento de red es una capa fundamental de seguridad en Docker. Usa redes personalizadas para aislar servicios:
services:
web:
image: nginx:alpine
networks:
- frontend
- backend
api:
image: mi-api
networks:
- backend
- database
db:
image: postgres:16-alpine
networks:
database:
aliases:
- postgres
networks:
frontend:
backend:
internal: true # Sin acceso a internet
database:
internal: true
Políticas de tráfico con iptables
Docker gestiona iptables automáticamente, pero puedes añadir reglas adicionales:
# Bloquear trafico saliente del contenedor (excepto DNS)
iptables -I FORWARD -i docker_br0 -p tcp --dport 80 -j DROP
iptables -I FORWARD -i docker_br0 -p tcp --dport 443 -j DROP
# O usar --iptables=false en el daemon para control manual
No uses --network host sin necesidad. Expón solo los puertos necesarios:
# NO hagas esto
docker run --network host nginx
# En su lugar
docker run -p 80:80 nginx
Funciones de utilidad
verificar_capabilities_necesarias
import subprocess
import os
import sys
import json
def verificar_capabilities_necesarias(imagen):
"""
Ejecuta un contenedor con --cap-drop=ALL y reporta que capabilities
necesita basandose en los errores de permisos.
"""
cmd = [
"docker", "run", "--rm",
"--cap-drop=ALL",
"--entrypoint", "sh",
imagen,
"-c", "echo 'Contenedor iniciado sin capabilities'"
]
try:
resultado = subprocess.run(
cmd, capture_output=True, text=True, timeout=30
)
if resultado.returncode == 0:
return {
"necesita_capabilities": False,
"mensaje": "El contenedor se ejecuta sin capabilities adicionales"
}
else:
errores = resultado.stderr.lower()
capabilities_sugeridas = []
if "operation not permitted" in errores:
if "chown" in errores or "permission denied" in errores:
capabilities_sugeridas.append("CHOWN")
if "bind" in errores or "permission denied" in errores:
capabilities_sugeridas.append("NET_BIND_SERVICE")
if "raw" in errores or "socket" in errores:
capabilities_sugeridas.append("NET_RAW")
if "setuid" in errores or "setgid" in errores:
capabilities_sugeridas.append("SETUID")
capabilities_sugeridas.append("SETGID")
return {
"necesita_capabilities": True,
"sugeridas": capabilities_sugeridas,
"error": resultado.stderr.strip()
}
except subprocess.TimeoutExpired:
return {"error": "Timeout al ejecutar el contenedor"}
except FileNotFoundError:
return {"error": "Docker no esta instalado o no accesible"}
generar_docker_run_seguro
def generar_docker_run_seguro(imagen, puertos=None,
capabilities=None, read_only=True,
memory="256m", tmpfs_dirs=None):
"""
Genera un comando docker run con las mejores practicas de seguridad.
"""
cmd = ["docker", "run", "--rm"]
# Capabilities minimas
cmd.append("--cap-drop=ALL")
if capabilities:
for cap in capabilities:
cmd.append(f"--cap-add={cap}")
# Read-only filesystem
if read_only:
cmd.append("--read-only")
if tmpfs_dirs is None:
tmpfs_dirs = ["/tmp:rw,noexec,nosuid,size=64m"]
for tmpfs in tmpfs_dirs:
cmd.append(f"--tmpfs={tmpfs}")
# Limites de recursos
cmd.append(f"--memory={memory}")
cmd.append("--memory-swap={}".format(memory))
cmd.append("--pids-limit=100")
# Puertos
if puertos:
for host, container in puertos.items():
cmd.append(f"-p {host}:{container}")
cmd.append(imagen)
return " ".join(cmd)
evaluar_benchmark_seguridad
def evaluar_benchmark_seguridad():
"""
Evalua la seguridad del host Docker usando reglas CIS simplificadas.
Retorna puntuacion sobre 100.
"""
checks = []
puntuacion = 100
# Check 1: Version de Docker
try:
result = subprocess.run(
["docker", "version", "--format", "{{.Server.Version}}"],
capture_output=True, text=True, timeout=10
)
version = result.stdout.strip()
major, minor = version.split(".")[:2]
if int(major) < 20 or (int(major) == 20 and int(minor) < 10):
puntuacion -= 10
checks.append(("FAIL", "Version de Docker desactualizada"))
else:
checks.append(("PASS", f"Version Docker {version} actualizada"))
except Exception:
puntuacion -= 15
checks.append(("FAIL", "No se pudo verificar la version de Docker"))
# Check 2: userns-remap
try:
result = subprocess.run(
["docker", "info", "--format", "{{.SecurityOptions}}"],
capture_output=True, text=True, timeout=10
)
if "userns" in result.stdout.lower():
checks.append(("PASS", "userns-remap activo"))
else:
puntuacion -= 15
checks.append(("WARN", "userns-remap no esta activo"))
except Exception:
puntuacion -= 5
checks.append(("INFO", "No se pudo comprobar userns-remap"))
# Check 3: AppArmor/SELinux
try:
with open("/sys/kernel/security/apparmor/profiles") as f:
if "docker-default" in f.read():
checks.append(("PASS", "AppArmor con perfil docker-default"))
else:
puntuacion -= 10
checks.append(("WARN", "AppArmor sin perfil docker"))
except FileNotFoundError:
puntuacion -= 5
checks.append(("INFO", "AppArmor no disponible"))
# Check 4: Grupo docker no vacio
try:
import grp
docker_group = grp.getgrnam("docker")
if len(docker_group.gr_mem) > 5:
puntuacion -= 10
checks.append(("WARN", f"Grupo docker con {len(docker_group.gr_mem)} miembros"))
else:
checks.append(("PASS", f"Grupo docker con {len(docker_group.gr_mem)} miembros"))
except KeyError:
checks.append(("INFO", "Grupo docker no existe (rootless?)"))
# Check 5: Experimental features
try:
result = subprocess.run(
["docker", "version", "--format", "{{.Server.Experimental}}"],
capture_output=True, text=True, timeout=10
)
if result.stdout.strip() == "true":
puntuacion -= 5
checks.append(("WARN", "Modo experimental activado en el daemon"))
else:
checks.append(("PASS", "Modo experimental desactivado"))
except Exception:
pass
return {
"puntuacion": max(0, puntuacion),
"nivel": "Excelente" if puntuacion >= 80 else "Bueno" if puntuacion >= 60 else "Mejorable",
"checks": checks
}
auditar_contenedor
def auditar_contenedor(nombre_o_id):
"""
Audita un contenedor en ejecucion y reporta su postura de seguridad.
"""
try:
result = subprocess.run(
["docker", "inspect", nombre_o_id],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return {"error": f"Contenedor no encontrado: {nombre_o_id}"}
data = json.loads(result.stdout)[0]
host_config = data.get("HostConfig", {})
config = data.get("Config", {})
hallazgos = []
# Verificar privilegios
if host_config.get("Privileged", False):
hallazgos.append({
"tipo": "CRITICAL",
"hallazgo": "Contenedor privilegiado",
"recomendacion": "Eliminar --privileged. Usar --cap-add especificas"
})
# Verificar capabilities
cap_drop = host_config.get("CapDrop", [])
if "ALL" not in cap_drop:
hallazgos.append({
"tipo": "HIGH",
"hallazgo": "No se dropean todas las capabilities",
"recomendacion": "Anadir --cap-drop=ALL y luego --cap-add especificas"
})
# Verificar read-only
if not host_config.get("ReadonlyRootfs", False):
hallazgos.append({
"tipo": "MEDIUM",
"hallazgo": "Sistema de archivos raiz en modo lectura/escritura",
"recomendacion": "Usar --read-only con --tmpfs para directorios de escritura"
})
# Verificar usuario
user = config.get("User", "")
if not user or user == "root" or user == "":
hallazgos.append({
"tipo": "HIGH",
"hallazgo": "Contenedor ejecutandose como root",
"recomendacion": "Definir USER en el Dockerfile con UID no root"
})
# Verificar limites de memoria
memory = host_config.get("Memory", 0)
if memory == 0:
hallazgos.append({
"tipo": "MEDIUM",
"hallazgo": "Sin limite de memoria",
"recomendacion": "Usar --memory para limitar RAM"
})
# Verificar seccomp
seccomp = host_config.get("SecurityOpt", [])
if not any("seccomp" in s for s in seccomp):
if not host_config.get("Privileged", False):
hallazgos.append({
"tipo": "INFO",
"hallazgo": "Usando perfil seccomp por defecto de Docker",
"recomendacion": "Considerar perfil seccomp personalizado"
})
# Verificar puertos expuestos
ports = host_config.get("PortBindings", {})
for port in ports:
host_ip = ports[port][0].get("HostIp", "0.0.0.0")
if host_ip == "0.0.0.0":
hallazgos.append({
"tipo": "LOW",
"hallazgo": f"Puerto {port} expuesto en 0.0.0.0 (todas las interfaces)",
"recomendacion": "Vincular a 127.0.0.1 si es posible"
})
# Puntuacion
pesos = {"CRITICAL": 25, "HIGH": 15, "MEDIUM": 10, "LOW": 5, "INFO": 2}
puntuacion = 100
for h in hallazgos:
puntuacion -= pesos.get(h["tipo"], 5)
return {
"contenedor": nombre_o_id,
"imagen": config.get("Image", ""),
"estado": data.get("State", {}).get("Status", "unknown"),
"puntuacion_seguridad": max(0, puntuacion),
"hallazgos": hallazgos,
"nivel": "Seguro" if puntuacion >= 80 else "Aceptable" if puntuacion >= 60 else "Inseguro"
}
except FileNotFoundError:
return {"error": "Docker no esta instalado"}
except json.JSONDecodeError:
return {"error": "Error al analizar la inspeccion del contenedor"}
crear_perfil_seccomp_minimo
def crear_perfil_seccomp_minimo(syscalls_permitidas, nombre="custom"):
"""
Crea un perfil seccomp personalizado que solo permite las syscalls
especificadas. El resto son denegadas con ERRNO.
"""
perfil = {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": sorted(set(syscalls_permitidas)),
"action": "SCMP_ACT_ALLOW"
}
]
}
nombre_archivo = f"seccomp-{nombre}.json"
with open(nombre_archivo, 'w') as f:
json.dump(perfil, f, indent=2)
return {
"archivo": nombre_archivo,
"syscalls_permitidas": len(syscalls_permitidas),
"accion_defecto": "SCMP_ACT_ERRNO (denegar todo)"
}
def listar_syscalls_de_aplicacion(imagen, comando=None, duracion=10):
"""
Ejecuta un contenedor con strace para capturar las syscalls que usa.
Requiere que strace este disponible en la imagen.
"""
if comando is None:
comando = ["sh", "-c", "sleep 2 && echo 'App started'"]
# Construir entrypoint con strace
strace_cmd = ["strace", "-c"] + comando
cmd = [
"docker", "run", "--rm", "--cap-add=SYS_PTRACE",
"--entrypoint", strace_cmd[0],
imagen
] + strace_cmd[1:]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=duracion + 5
)
return {"output": result.stdout, "error": result.stderr}
except subprocess.TimeoutExpired:
return {"error": f"Timeout tras {duracion} segundos"}
except FileNotFoundError:
return {"error": "Docker no instalado o strace no disponible en la imagen"}
Errores comunes de seguridad en Docker
- Error 1 — Ejecutar contenedores como root: el atacante que accede al contenedor tiene privilegios máximos en el host. Solución: define usuario no root con
USERen el Dockerfile. - Error 2 — Usar
--privilegedsin necesidad: elimina TODAS las restricciones de seguridad. Solución: usa--cap-addespecífica o--device. - Error 3 — No dropear capabilities innecesarias: el contenedor tiene capabilities que no necesita (SYS_ADMIN, SYS_MODULE). Solución: siempre usa
--cap-drop=ALLy añade solo las necesarias. - Error 4 — Montar el socket Docker dentro del contenedor: cualquier proceso dentro tiene acceso root al host. Solución: no montes
/var/run/docker.sock. Usa socket proxy si es necesario. - Error 5 — No usar
--read-only: el contenedor puede modificar binarios y escribir scripts maliciosos. Solución: usa--read-onlycon--tmpfs. - Error 6 — Secretos en variables de entorno o Dockerfile: las contraseñas quedan visibles en
docker inspecty en el historial de capas. Solución: usadocker secret, BuildKit--secreto monta archivos con permisos restringidos. - Error 7 — No activar userns-remap o rootless mode: root dentro del contenedor = root en el host. Solución: activa
userns-remapen daemon.json o migra a rootless mode. - Error 8 — No escanear vulnerabilidades en imágenes: despliegas imágenes con CVEs conocidos (Log4Shell, etc.). Solución: integra Trivy, Docker Scout o Snyk en el pipeline CI/CD.
- Error 9 — No limitar recursos (memoria, CPU, PIDs): un contenedor comprometido puede hacer DoS al host con fork bomb o RAM overflow. Solución: define
--memory,--cpus,--pids-limitsiempre. - Error 10 — No usar Docker Content Trust: puedes hacer pull de imágenes no firmadas con malware. Solución: activa
export DOCKER_CONTENT_TRUST=1.
Verificación
Antes de dar por bueno lo que has configurado, ejecuta estas verificaciones para asegurarte de que tu entorno Docker está seguro:
1. Ejecutar Docker Bench Security
El primer paso siempre es ejecutar la herramienta oficial de auditoría:
docker run --rm \
--net host \
--pid host \
--userns host \
--cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /usr/lib/systemd:/usr/lib/systemd \
-v /etc:/etc \
--label docker_bench_security \
docker/docker-bench-security
Busca en el informe los controles marcados como [FAIL]. Deberías tener cero Fails en un sistema correctamente configurado. Los [WARN] son aceptables pero conviene revisarlos.
2. Verificar capabilities de un contenedor en ejecución
Selecciona un contenedor que tengas corriendo y comprueba sus capabilities:
# Inspeccionar capabilities
docker inspect --format '{{json .HostConfig.CapDrop}}' nombre-contenedor
docker inspect --format '{{json .HostConfig.CapAdd}}' nombre-contenedor
Deberías ver "ALL" en CapDrop y solo las capabilities estrictamente necesarias en CapAdd. Si ves "CapDrop": null, el contenedor no tiene las capacidades mínimas.
3. Verificar modo read-only
Comprueba que el sistema de archivos raíz del contenedor está en modo solo lectura:
docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' nombre-contenedor
Debería devolver true. También puedes ejecutar dentro del contenedor:
docker exec nombre-contenedor touch /test.txt
# Deberia fallar con "Read-only file system"
4. Verificar usuario no root
Comprueba que el contenedor no se ejecuta como root:
docker inspect --format '{{.Config.User}}' nombre-contenedor
Si devuelve una cadena vacía o root, el contenedor se ejecuta con privilegios de superusuario. Esto es uno de los errores más comunes.
5. Verificar rootless mode
Si has configurado Docker en modo rootless:
docker info | grep -i rootless
Deberías ver Security Options: rootless. Si no aparece, Docker se está ejecutando con el daemon tradicional con privilegios.
6. Verificar límites de recursos
Comprueba que los límites de memoria y PIDs están configurados:
docker inspect --format '{{.HostConfig.Memory}}' nombre-contenedor
docker inspect --format '{{.HostConfig.NanoCpus}}' nombre-contenedor
docker inspect --format '{{.HostConfig.PidsLimit}}' nombre-contenedor
Memory debería ser un valor positivo (en bytes, por ejemplo 268435456 para 256 MB). NanoCpus debería reflejar el límite de CPU. PidsLimit debería ser un número positivo.
7. Verificar que el socket Docker no está montado
Comprueba qué volúmenes tiene montados un contenedor:
docker inspect --format '{{json .Mounts}}' nombre-contenedor | python3 -m json.tool
Si ves /var/run/docker.sock en la lista de montajes, el contenedor tiene acceso al daemon de Docker. Asegúrate de que sea estrictamente necesario.
Más información,
- Documentación oficial de Docker: Seguridad
- CIS Docker Benchmark
- Docker Bench Security (GitHub)
- Perfiles seccomp en Docker
- AppArmor con Docker
- Rootless mode
- Docker Content Trust
- Docker Secrets
- Trivy — Escáner de vulnerabilidades
- Docker Scout
- Snyk Container
- Anchore Grype
- Falco — Seguridad en tiempo de ejecución
- Hadolint — Linter de Dockerfiles
- Dive — Inspector de capas de imágenes
- OWASP Docker Security Cheat Sheet
- Linux Capabilities (man page)
- Tutorial de atareao.es: Docker
- Tutorial de atareao.es: Traefik v3
- Tutorial de atareao.es: Seguridad self-hosted
- Tutorial de atareao.es: Podman
- Docker Security: Are your containers really isolated? — Docker Blog
- The Container Security Guide — Trail of Bits
- Buenas prácticas para asegurar contenedores — Red Hat