diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
index 11a6078..8631094 100644
--- a/.gitea/workflows/build.yaml
+++ b/.gitea/workflows/build.yaml
@@ -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.
diff --git a/workloads/docs-portal/docs/devsecops/trivy.md b/workloads/docs-portal/docs/devsecops/trivy.md
new file mode 100644
index 0000000..52c0b91
--- /dev/null
+++ b/workloads/docs-portal/docs/devsecops/trivy.md
@@ -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
(frontend.yaml)"]
+ B --> C[docker login]
+ C --> D["docker build
(push: false, load: true)"]
+ D --> E["trivy image --severity CRITICAL
--exit-code 1"]
+ E -- "CRITICAL con fix" --> X["❌ pipeline detenido
no se sube la imagen"]
+ E -- "sin CRITICAL" --> F["trivy image --severity HIGH
--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 `tar@7.5.16` — 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.
diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml
index e63b6ed..8c02f9e 100644
--- a/workloads/docs-portal/mkdocs.yml
+++ b/workloads/docs-portal/mkdocs.yml
@@ -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:
diff --git a/workloads/ecommerce/Dockerfile b/workloads/ecommerce/Dockerfile
index 3868dc9..606feeb 100644
--- a/workloads/ecommerce/Dockerfile
+++ b/workloads/ecommerce/Dockerfile
@@ -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 \