14

Seguridad en Docker

Vistas: 71
Tutorial: Tutorial de Docker
Seguridad en Docker

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-registry is not set
  • [PASS] 4.1 — Ensure that a user for the container has been created
  • [WARN] 5.4 — Ensure that --cap-drop=ALL is 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)
  • uidmap instalado (apt install uidmap)
  • fuse-overlayfs para el driver de almacenamiento
  • Usuario con sub-UID/sub-GID configurados en /etc/subuid y /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 authbind o redirigir con iptables)
  • Ciertos drivers de almacenamiento (overlay2) requieren fuse-overlayfs que es más lento
  • No funciona con --privileged (por definición, es rootless)
  • Imágenes que requieren SYS_ADMIN no funcionarán
  • El modo host networking 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/escritura
  • noexec: no se pueden ejecutar binarios
  • nosuid: ignora el bit setuid
  • nodev: no se permite crear dispositivos
  • size: 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

  1. No montes el socket dentro de contenedores a menos que sea estrictamente necesario (Docker-in-Docker, CI runners)
  2. 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
  1. Usa grupos de usuario: solo los miembros del grupo docker tienen 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
  1. 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 USER en el Dockerfile.
  • Error 2 — Usar --privileged sin necesidad: elimina TODAS las restricciones de seguridad. Solución: usa --cap-add especí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=ALL y 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-only con --tmpfs.
  • Error 6 — Secretos en variables de entorno o Dockerfile: las contraseñas quedan visibles en docker inspect y en el historial de capas. Solución: usa docker secret, BuildKit --secret o 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-remap en 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-limit siempre.
  • 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,

Deja una respuesta