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.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user