Files
apps-registry/workloads/docs-portal/docs/devsecops/trivy.md
devops df13233a29 feat(devsecops): agregar Trivy (imagen + IaC) al pipeline del frontend
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.
2026-08-14 20:43:09 -05:00

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

  1. CRITICAL con fix disponible — ya bloquea el pipeline, así que en la práctica no se acumulan.
  2. 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.
  3. 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é.
  4. 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.