trivy config sobre frontend.yaml (CRITICAL/HIGH/MEDIUM, informativo) y trivy image sobre la imagen recién construida (CRITICAL bloquea con --ignore-unfixed, HIGH solo informa), entre build (push:false, load:true) y el push real al registry. Fix real encontrado al validar contra la imagen real: el stage runner heredaba npm/npx/corepack completos de la imagen base de Node sin necesitarlos en runtime, trayendo CVE-2026-59873 (CRITICAL, node-tar empaquetado en npm). Sacarlos del stage final baja CRITICAL de 1 a 0 y HIGH fixable de 21 a 15 (las que quedan son de la propia app, ej. next desactualizado, documentadas como pendiente sin bloquear). Documenta ambos scans en docs/devsecops/trivy.md con los hallazgos reales de este repo.
9.2 KiB
Trivy — vulnerabilidades de imagen e IaC
!!! info "Qué problema resuelve" Trivy es un escáner de seguridad multipropósito. En este pipeline se usa para dos cosas distintas, con dos comandos distintos:
- `trivy image`: busca CVEs conocidas en los paquetes que terminan
dentro de la imagen Docker final (el sistema operativo base, y las
librerías de Node.js instaladas).
- `trivy config`: busca **misconfiguraciones** en los manifiestos de
Kubernetes (YAML) — no vulnerabilidades de código, sino configuración
insegura (contenedor como root, sin límites de recursos, etc.).
Son preguntas distintas: "¿esta imagen tiene código con bugs de
seguridad conocidos?" contra "¿esta manera de desplegar el contenedor
es insegura, aunque el código adentro esté perfecto?".
Dónde corre cada uno en el pipeline
flowchart LR
A[checkout] --> B["trivy config<br/>(frontend.yaml)"]
B --> C[docker login]
C --> D["docker build<br/>(push: false, load: true)"]
D --> E["trivy image --severity CRITICAL<br/>--exit-code 1"]
E -- "CRITICAL con fix" --> X["❌ pipeline detenido<br/>no se sube la imagen"]
E -- "sin CRITICAL" --> F["trivy image --severity HIGH<br/>--exit-code 0 (informativo)"]
F --> G["docker push"]
El escaneo de imagen corre después de construir la imagen, antes de
subirla al registry — por eso el build ahora usa
push: false, load: true (la imagen queda en el daemon Docker del runner,
pero no sale de ahí hasta pasar el gate de CRITICAL). El escaneo de
manifiestos (trivy config) no depende de la imagen, así que corre antes,
junto a las otras validaciones del código.
Umbral de severidad (y por qué)
| Severidad | Comportamiento | Por qué |
|---|---|---|
CRITICAL |
Bloquea (--exit-code 1) |
Si existe un fix disponible para una CVE crítica, no tiene sentido publicar la imagen igual |
HIGH |
Informa, no bloquea (--exit-code 0) |
Mientras aprendemos a leer los reportes, HIGH se revisa pero no frena el flujo — bloquear de entrada en HIGH hubiera parado el pipeline en el primer intento real (ver ejemplo abajo) |
Ambos steps usan --ignore-unfixed: si Trivy no tiene un FixedVersion
para reportar, bloquear o hasta advertir no ayuda en nada — no hay acción
posible más que esperar a que el mantenedor del paquete publique un
parche.
!!! tip "Por qué no escanear todo junto con un solo umbral"
Trivy permite pedir --severity CRITICAL,HIGH en una sola corrida,
pero el --exit-code se aplica igual a toda la corrida — no se puede
decir "bloqueá en CRITICAL, pero en HIGH solo avisá" en un solo
comando. Por eso son dos steps separados, cada uno con su propio
umbral y su propio --exit-code.
Ejemplo real: un CRITICAL que sí bloqueaba
Antes de integrar este step, construimos la imagen real de
workloads/ecommerce y corrimos Trivy contra ella para validar el
pipeline. Encontró esto:
Node.js (node-pkg)
Total: 1 (CRITICAL: 1)
tar (7.5.15 → 7.5.19) CVE-2026-59873 CRITICAL
tar: node-tar: Denial of Service via crafted gzip bomb
El detalle importante no era el CVE en sí, sino dónde vivía:
/usr/local/lib/node_modules/npm/node_modules/tar/ — ese tar no es una
dependencia de ARI Shopping, es el que trae empaquetado el propio
npm dentro de la imagen base node:24.18.0-bookworm-slim. El
Dockerfile original hacía FROM ${NODE_IMAGE} AS runner para el stage
final, heredando el Node.js completo — con npm, npx y corepack
incluidos — aunque en producción el contenedor solo ejecuta
node server.js y nunca invoca npm.
!!! danger "No se arregla solo subiendo la versión de Node"
Antes de tocar el Dockerfile probamos si un patch más nuevo de la
imagen base ya traía el tar corregido: node:24.19.0-bookworm-slim
trae npm con [email protected] — sigue por debajo del 7.5.19 con el
fix. El problema no es "Node desactualizado", es que el runtime de
producción no necesita npm para nada.
Fix aplicado (workloads/ecommerce/Dockerfile, stage runner):
+ RUN rm -rf \
+ /usr/local/lib/node_modules/npm \
+ /usr/local/lib/node_modules/corepack \
+ /usr/local/bin/npm \
+ /usr/local/bin/npx \
+ /usr/local/bin/corepack
Después del fix: 0 CRITICAL, y de paso las HIGH fixable bajaron de 21
a 15 (varias venían de dependencias de ese mismo npm empaquetado, no de
la app). Se validó que la imagen sigue arrancando y respondiendo
GET /api/health con 200 después de sacar npm.
Lección: sacar herramientas que la imagen de producción no necesita en runtime no es solo "buena práctica" en abstracto — reduce directamente la superficie que Trivy (y un atacante) tienen para encontrar algo.
Las HIGH que quedan (ejemplo real, sin arreglar todavía)
Después del fix, trivy image --severity HIGH sigue reportando (de
forma informativa, no bloqueante) CVEs reales en dependencias que sí son
de la app — la mayoría en next (15.5.10, con fixes disponibles en
15.5.16+ y 15.5.21+ según el CVE), además de nanoid, postcss y
sharp. Esto queda pendiente de revisar como una actualización de
dependencias normal, no como una emergencia de seguridad — es exactamente
para eso que HIGH no bloquea en esta primera vuelta: da visibilidad sin
frenar el flujo mientras se decide cuándo priorizar el bump.
Cómo priorizar qué arreglar primero
- CRITICAL con fix disponible — ya bloquea el pipeline, así que en la práctica no se acumulan.
- HIGH en una dependencia que el runtime realmente carga (como
next, que corre en cada request) — más prioridad que una HIGH en una herramienta de build que ni siquiera llega a la imagen final. - HIGH sin ruta de explotación realista (ej. una librería que solo se usa en un script de generación, no en el server) — se puede posponer con criterio, documentando por qué.
- MEDIUM/LOW — se revisan en lote, no una por una.
La pregunta que más ayuda a priorizar no es "¿qué tan grave dice la
CVSS que es?", sino "¿este paquete corre en el proceso que atiende
tráfico real, o es una herramienta de build que ni siquiera debería estar
en la imagen final?" — el propio ejemplo de arriba (npm dentro de la
imagen de producción) es el caso de manual del segundo.
Escaneo de manifiestos Kubernetes (trivy config)
Corre contra workloads/ecommerce/frontend.yaml (el Deployment +
Service real del frontend) con --severity CRITICAL,HIGH,MEDIUM, en
modo informativo (--exit-code 0) por ahora.
Hallazgos reales de este manifiesto, hoy:
| Severidad | Regla | Qué significa |
|---|---|---|
| HIGH | KSV-0014 | El filesystem raíz del contenedor no es de solo lectura |
| HIGH | KSV-0118 | No se define securityContext — Kubernetes usa el default, que permite privilegios de root |
| MEDIUM | KSV-0012 | El contenedor puede correr como root (aunque la imagen ya defina USER nextjs en el Dockerfile, Kubernetes no lo está forzando vía runAsNonRoot) |
| MEDIUM | KSV-0001 | El contenedor puede escalar sus propios privilegios (falta allowPrivilegeEscalation: false) |
| MEDIUM | KSV-0104 | No hay perfil de Seccomp configurado |
| MEDIUM | KSV-0117 | El containerPort: 80 es un puerto privilegiado (<1024) |
| MEDIUM | KSV-0125 | La imagen viene de un registry que Trivy no reconoce como "de confianza" por defecto (es autoalojado: gitea.cruzcloud.net) |
!!! warning "Lo que este scan NO detecta todavía"
El pedido original incluía "falta de resource limits" y "falta de
readiness/liveness probes" como ejemplos de misconfiguración a
buscar. En la práctica, trivy config sí tiene reglas para límites
de recursos (KSV-0011 CPU, KSV-0018 memoria) pero las clasifica
como LOW, por debajo del piso MEDIUM que usa este step — y no
tiene ninguna regla propia para probes de liveness/readiness (eso lo
cubren otras herramientas, como kube-score o kube-linter, que no
forman parte de esta primera integración). Si más adelante se quiere
cubrir ese hueco específico, es una herramienta aparte, no una opción
de configuración de Trivy.
Ninguno de estos hallazgos bloquea el pipeline todavía — son reales, pero
corregirlos (agregar securityContext, resources.limits, etc. a
frontend.yaml) es un cambio de GitOps que conviene revisar con calma,
no como reacción automática a un scan.
Falsos positivos y excepciones
Cuando un hallazgo de Trivy no aplica (por ejemplo, KSV-0125 marcando el
registry propio como "no confiable" — que es exactamente lo esperado en
un lab self-hosted), se documenta con un archivo .trivyignore en la
raíz del repo:
# KSV-0125: gitea.cruzcloud.net es nuestro registry self-hosted,
# no un registry público de terceros. Excepción intencional.
KSV-0125
Igual que con Gitleaks, cada línea de .trivyignore debe poder
justificarse — no es un lugar para silenciar hallazgos incómodos sin
revisarlos primero.