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:
2026-08-14 20:43:09 -05:00
parent 422e9d257d
commit df13233a29
4 changed files with 265 additions and 4 deletions
+65 -4
View File
@@ -235,6 +235,31 @@ jobs:
echo "OK: navegación real del catálogo validada."
- name: Instalar Trivy
shell: bash
run: |
set -euo pipefail
TRIVY_VERSION="0.74.0"
curl -sSfL \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
-o /tmp/trivy.tar.gz
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
chmod +x /tmp/trivy
/tmp/trivy version
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
# el pipeline mientras revisamos juntos qué hallazgos son reales.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
- name: Login en Gitea Registry
uses: docker/login-action@v2
with:
@@ -243,14 +268,50 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
- name: Construir y Subir Imagen
# push: false — la imagen se queda cargada en el daemon local (load:
# true) para poder escanearla con Trivy antes de subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4
with:
context: workloads/ecommerce/
push: true
push: false
load: true
tags: |
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
# los reportes y decidimos, con calma, qué reglas deben bloquear.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# Login ya se hizo arriba; recién acá se sube, después de que la
# imagen pasó el gate de CRITICAL.
- name: Subir Imagen al Registry
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
docker push "${IMAGE_NAME}:latest"
# Evita que una ejecución antigua actualice frontend.yaml después de que
# ya exista un commit más reciente en main.
@@ -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.
+1
View File
@@ -100,6 +100,7 @@ nav:
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
- DevSecOps:
- Gitleaks (secretos): devsecops/gitleaks.md
- Trivy (imagen + IaC): devsecops/trivy.md
extra:
social:
+12
View File
@@ -305,6 +305,18 @@ RUN apt-get update \
'cap_net_bind_service=+ep' \
/usr/local/bin/node
# El runtime solo ejecuta "node server.js" (standalone output de Next.js);
# nunca invoca npm/npx/corepack. Sacarlos del stage final reduce el árbol
# de dependencias escaneado por Trivy a lo que realmente corre en
# producción, en vez de arrastrar el npm completo de la imagen base
# (con sus propias deps y CVEs, ej. CVE-2026-59873 en node-tar).
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
COPY --from=builder \
--chown=nextjs:nodejs \
/app/public \