From df13233a2984305a7fdad0dc97971f16f671041b Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 20:43:09 -0500 Subject: [PATCH] feat(devsecops): agregar Trivy (imagen + IaC) al pipeline del frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/build.yaml | 69 ++++++- workloads/docs-portal/docs/devsecops/trivy.md | 187 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + workloads/ecommerce/Dockerfile | 12 ++ 4 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 workloads/docs-portal/docs/devsecops/trivy.md 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 \