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:
@@ -235,6 +235,31 @@ jobs:
|
|||||||
|
|
||||||
echo "OK: navegación real del catálogo validada."
|
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
|
- name: Login en Gitea Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -243,14 +268,50 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
logout: true
|
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
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: workloads/ecommerce/
|
context: workloads/ecommerce/
|
||||||
push: true
|
push: false
|
||||||
|
load: true
|
||||||
tags: |
|
tags: |
|
||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
${{ 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
|
# Evita que una ejecución antigua actualice frontend.yaml después de que
|
||||||
# ya exista un commit más reciente en main.
|
# 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.
|
||||||
@@ -100,6 +100,7 @@ nav:
|
|||||||
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
|
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
|
||||||
- DevSecOps:
|
- DevSecOps:
|
||||||
- Gitleaks (secretos): devsecops/gitleaks.md
|
- Gitleaks (secretos): devsecops/gitleaks.md
|
||||||
|
- Trivy (imagen + IaC): devsecops/trivy.md
|
||||||
|
|
||||||
extra:
|
extra:
|
||||||
social:
|
social:
|
||||||
|
|||||||
@@ -305,6 +305,18 @@ RUN apt-get update \
|
|||||||
'cap_net_bind_service=+ep' \
|
'cap_net_bind_service=+ep' \
|
||||||
/usr/local/bin/node
|
/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 \
|
COPY --from=builder \
|
||||||
--chown=nextjs:nodejs \
|
--chown=nextjs:nodejs \
|
||||||
/app/public \
|
/app/public \
|
||||||
|
|||||||
Reference in New Issue
Block a user