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.
188 lines
9.2 KiB
Markdown
188 lines
9.2 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```text
|
|
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`):
|
|
|
|
```diff
|
|
+ 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](https://avd.aquasec.com/misconfig/ksv-0014) | El filesystem raíz del contenedor no es de solo lectura |
|
|
| HIGH | [KSV-0118](https://avd.aquasec.com/misconfig/ksv-0118) | No se define `securityContext` — Kubernetes usa el default, que permite privilegios de root |
|
|
| MEDIUM | [KSV-0012](https://avd.aquasec.com/misconfig/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](https://avd.aquasec.com/misconfig/ksv-0001) | El contenedor puede escalar sus propios privilegios (falta `allowPrivilegeEscalation: false`) |
|
|
| MEDIUM | [KSV-0104](https://avd.aquasec.com/misconfig/ksv-0104) | No hay perfil de Seccomp configurado |
|
|
| MEDIUM | [KSV-0117](https://avd.aquasec.com/misconfig/ksv-0117) | El `containerPort: 80` es un puerto privilegiado (<1024) |
|
|
| MEDIUM | [KSV-0125](https://avd.aquasec.com/misconfig/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:
|
|
|
|
```text
|
|
# 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.
|