Saltar al contenido principal

Seguridad de imágenes de contenedor

Cuando escribes FROM node:16, no estás importando Node. Estás importando una distribución de Linux entera: su gestor de paquetes, sus librerías del sistema, sus utilidades y todo lo que traigan de serie.

Todo eso se despliega en producción con tu aplicación. Y todo eso tiene vulnerabilidades.

De dónde salen los CVE

Una imagen tiene tres capas de riesgo, y conviene distinguirlas porque se arreglan de forma distinta:

La tercera es la que sorprende: casi nunca es culpa tuya y casi siempre es la más numerosa. La arreglas cambiando de imagen base, no tocando tu código.

Laboratorio: la comparativa que lo explica todo

Vamos a escanear dos imágenes base y comparar. Nada más:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln node:16
node:16 (debian 11.7)
Total: 990 (UNKNOWN: 2, LOW: 103, MEDIUM: 509, HIGH: 360, CRITICAL: 16)

Ahora la alternativa moderna:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln node:22-alpine
node:22-alpine (alpine 3.22)
Total: 20 (UNKNOWN: 0, LOW: 12, MEDIUM: 6, HIGH: 2, CRITICAL: 0)
node:16node:22-alpine
Total99020
Críticas160
Altas3602

De 990 a 20. De 16 críticas a ninguna. Sin tocar una línea de código: solo cambiando la línea FROM.

Dos motivos explican la diferencia. Node 16 llegó a fin de vida en 2023, así que lleva años sin recibir parches. Y Debian arrastra muchísimos más paquetes de sistema que Alpine, así que hay más superficie donde acumular fallos.

La lección práctica

Antes de invertir un día persiguiendo CVEs, mira tu línea FROM. Es habitual que una sola actualización de imagen base elimine el 95% de los hallazgos.

Escanear tu propia imagen

cd devsecops-demo
docker build -t devsecops-demo:1.0 .

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image devsecops-demo:1.0

Trivy separa los resultados en dos bloques: los paquetes del sistema operativo (los 990 de arriba) y los de tu aplicación (los 20 del capítulo 3). Un único comando cubre ambos.

Entender las severidades

Ordenar por severidad es lo primero que hay que aprender a no hacer a ciegas. Un CRITICAL en una librería que tu código nunca llega a llamar es menos urgente que un HIGH en la ruta de autenticación.

Tres preguntas que valen más que la etiqueta:

  1. ¿Hay versión corregida? Si no la hay, actualizar no es la solución.
  2. ¿Se llega a ese código? Trivy no lo sabe; tú sí.
  3. ¿Está expuesto al usuario? Una vulnerabilidad en una herramienta de build que no viaja a la imagen final es ruido.

Para el día a día, este es el filtro razonable:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
devsecops-demo:1.0

--ignore-unfixed es el más útil de los dos: oculta lo que todavía no tiene parche disponible, o sea, lo que no puedes arreglar hoy. El resto es tu lista de tareas real.

El gate: romper el pipeline

Informar está bien. Bloquear es lo que cambia el comportamiento:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity CRITICAL --exit-code 1 devsecops-demo:1.0

echo $?
1

Ese 1 es el que hace fallar el job. En el pipeline:

# .github/workflows/imagen.yml
name: Imagen
on: [push]

jobs:
escaneo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t demo:${{ github.sha }} .

# Informa de todo, sin bloquear
- uses: aquasecurity/trivy-action@master
with:
image-ref: demo:${{ github.sha }}
format: 'sarif'
output: 'trivy.sarif'

- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy.sarif'

# Bloquea solo lo crítico y con parche disponible
- uses: aquasecurity/trivy-action@master
with:
image-ref: demo:${{ github.sha }}
severity: 'CRITICAL'
ignore-unfixed: true
exit-code: '1'

El patrón de los dos pasos es deliberado: informas de todo, bloqueas por poco. Así tienes visibilidad completa sin que el equipo se pelee con el pipeline.

Falsos positivos

Se silencian en un fichero .trivyignore:

# CVE-2019-1010022: glibc, el proyecto lo disputa y no hay parche.
# Revisar: 2026-12-01
CVE-2019-1010022

Pon siempre motivo y fecha de revisión. Un .trivyignore sin comentarios se convierte en un vertedero donde acaba escondido el CVE que sí importaba.

Alternativas

HerramientaFuerte enFlojo en
TrivyTodo en uno: SO, dependencias, IaC, secretos. Rápido
GrypeMuy buena detección en paquetes de sistemaMenos alcance
Docker ScoutIntegrado en Docker Desktop, buena interfazFunciones limitadas en el plan gratuito
SnykPrioriza según explotabilidad, buenos consejosDe pago para lo interesante

Trivy cubre el 90% de los casos siendo gratis y sin cuenta. Es un buen sitio donde quedarse.

Esto es solo la mitad

Escanear te dice qué vulnerabilidades tiene la imagen. No te dice nada sobre cómo está construida: si corre como root, si tiene el sistema de ficheros en escritura, si arrastra un compilador que no necesita.

De hecho, Semgrep ya nos avisó en el capítulo anterior:

Dockerfile:22 [ERROR] missing-user

El Dockerfile del laboratorio no tiene ninguna instrucción USER, así que el proceso corre como root. Esa mitad —el endurecimiento de la imagen— la cubre el capítulo de seguridad de imágenes del curso de Docker, junto con multi-stage y distroless.

Resumen

  • Una imagen base es una distribución entera, y su sistema operativo aporta la mayoría de los CVEs
  • node:16990 vulnerabilidades; node:22-alpine20. Solo cambiando el FROM
  • Antes de perseguir CVEs, actualiza la imagen base
  • --ignore-unfixed deja a la vista lo que de verdad puedes arreglar
  • El patrón que funciona: informar de todo, bloquear solo lo crítico
  • Escanear y endurecer son cosas distintas: esto cubre la primera

Recursos adicionales


💡 Recuerda: la vulnerabilidad más barata de arreglar es la que eliminas no instalándola.

En el próximo capítulo respondemos a la pregunta de qué hay exactamente dentro de tu imagen, y cómo demostrar que nadie la ha manipulado.