From 422e9d257d0ae3c1cf20823f38e2caa5ab89b226 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 19:48:44 -0500 Subject: [PATCH 1/8] feat(devsecops): agregar Gitleaks al pipeline del frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corre en push y pull_request, antes del job build (needs: gitleaks). Si detecta un secreto, exit-code=1 detiene el pipeline antes de construir la imagen. Scan histórico completo del repo (249 commits) confirmado limpio, corrido aparte de forma manual. Documenta el step en docs/devsecops/gitleaks.md: qué es secret scanning, por qué corre antes del build, cómo leer un hallazgo y cómo manejar falsos positivos con allowlist. --- .gitea/workflows/build.yaml | 57 +++++++- .../docs-portal/docs/devsecops/gitleaks.md | 137 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 2 + 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 workloads/docs-portal/docs/devsecops/gitleaks.md diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index b48a783..11a6078 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -4,7 +4,7 @@ on: push: branches: - main - paths: + paths: &frontend_paths # Código y configuración real del frontend. # Los cambios exclusivos de GitOps (frontend.yaml, ingress, patches, etc.) # no vuelven a construir la imagen. @@ -29,14 +29,69 @@ on: - 'workloads/ecommerce/public/**' - 'workloads/ecommerce/styles/**' - '.gitea/workflows/build.yaml' + pull_request: + branches: + - main + paths: *frontend_paths permissions: contents: write packages: write jobs: + # Corre en push y en pull_request, siempre antes que build. Si encuentra + # un secreto commiteado, el step termina con exit code distinto de 0 y, + # por el "needs" del job build, la imagen nunca se construye ni se sube. + gitleaks: + name: Escaneo de Secretos (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout del código + uses: actions/checkout@v3 + with: + fetch-depth: 1 + + - name: Instalar Gitleaks + shell: bash + run: | + set -euo pipefail + GITLEAKS_VERSION="8.21.2" + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + -o /tmp/gitleaks.tar.gz + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + chmod +x /tmp/gitleaks + /tmp/gitleaks version + + # --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1, + # sin historial), no el log de commits. El scan histórico completo del + # repo se corre aparte, manualmente, no en cada push/PR. + - name: Escanear secretos en el árbol de archivos + shell: bash + run: | + set -euo pipefail + /tmp/gitleaks detect \ + --source=workloads/ecommerce \ + --no-git \ + --redact \ + --report-format=json \ + --report-path=gitleaks-report.json \ + --exit-code=1 + + - name: Publicar reporte de Gitleaks + if: always() + uses: actions/upload-artifact@v3 + with: + name: gitleaks-report + path: gitleaks-report.json + if-no-files-found: ignore + build: name: Construir y Subir Imagen + needs: gitleaks + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/workloads/docs-portal/docs/devsecops/gitleaks.md b/workloads/docs-portal/docs/devsecops/gitleaks.md new file mode 100644 index 0000000..501f253 --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/gitleaks.md @@ -0,0 +1,137 @@ +# Gitleaks — detección de secretos + +!!! info "Qué problema resuelve" + Gitleaks busca patrones de credenciales (API keys, tokens, contraseñas, + llaves privadas) dentro del código fuente. No sabe si una credencial es + "real" — detecta **formas** que parecen credenciales (una API key de + AWS siempre empieza con `AKIA`, una llave privada siempre tiene el + encabezado `-----BEGIN PRIVATE KEY-----`, etc.) y también cadenas con + entropía alta (aleatoriedad), que suelen ser tokens generados. + +## Por qué esto importa + +Un secreto commiteado a git **nunca deja de estar ahí**, aunque lo borres +en el siguiente commit. Sigue existiendo en el historial, en cualquier +fork, en cualquier clon local que alguien ya haya hecho. La única manera +real de "revocar" un secreto filtrado es rotarlo (generar uno nuevo e +invalidar el viejo) — borrar el commit no alcanza. + +Por eso el objetivo de gitleaks no es "arreglar" el secreto después de que +se filtró, sino **evitar que el commit con el secreto llegue a existir en +el repo remoto**. + +## Por qué corre antes del build + +En `.gitea/workflows/build.yaml`, el job `gitleaks` corre **antes** que el +job `build` (que compila la imagen Docker y la sube al registry). El job +`build` tiene `needs: gitleaks` — si el scan falla, `build` ni siquiera +arranca. + +```mermaid +flowchart LR + A[push / pull_request] --> B[gitleaks] + B -- "sin hallazgos" --> C[build] + B -- "secreto detectado" --> X["❌ pipeline detenido
build no corre"] +``` + +La lógica es simple: no tiene sentido gastar tiempo de build y minutos de +runner compilando una imagen a partir de un commit que de todas formas hay +que rechazar. Fallar rápido, fallar barato. + +También corre en **pull request**, no solo en push a `main` — así un +secreto se detecta antes de que el PR se mergee, que es el punto donde +todavía es más fácil corregirlo (basta con un `git commit --amend` o un +nuevo commit en la misma rama, sin tocar `main`). + +## Alcance de este scan + +El step de CI escanea el árbol de archivos ya *checked out* del commit +(`gitleaks detect --no-git`), no el historial completo — el checkout del +pipeline es superficial (`fetch-depth: 1`, solo el último commit), así que +no hay historial que recorrer en ese punto. + +Antes de integrar esto al pipeline corrimos un **scan histórico completo** +del repo `apps-registry` (los 249 commits, con `gitleaks detect` en modo +git normal, sin `--no-git`) para confirmar que no había secretos ya +commiteados en el pasado. Resultado: **sin hallazgos**. Ese scan histórico +es una tarea puntual, no algo que corra en cada push — si alguna vez se +sospecha una filtración vieja, se repite manualmente. + +## Cómo leer un hallazgo + +Un hallazgo de gitleaks (en el `gitleaks-report.json` que el pipeline +publica como artifact) se ve así: + +```json +{ + "Description": "AWS Access Key", + "StartLine": 14, + "File": "workloads/ecommerce/lib/config.ts", + "Match": "REDACTED", + "Secret": "REDACTED", + "RuleID": "aws-access-token", + "Commit": "a1b2c3d" +} +``` + +Campos clave: + +| Campo | Qué significa | +|---|---| +| `RuleID` | Qué tipo de secreto detectó (la regla que hizo match) | +| `File` / `StartLine` | Dónde está, exactamente | +| `Match` / `Secret` | El valor detectado — el pipeline usa `--redact`, así que en el reporte real aparece censurado, no en texto plano | +| `Commit` | En qué commit se introdujo (solo aplica al scan histórico, no al scan `--no-git` del pipeline) | + +!!! danger "Si el hallazgo es real" + 1. **No lo borres del código y listo** — el secreto sigue "filtrado" + aunque ya no esté en el archivo actual. + 2. **Rota la credencial primero** en el sistema que la emitió (AWS, + Gitea, Medusa, lo que sea). Un secreto que ya se vio en un log de + CI o en un diff de PR se trata como comprometido. + 3. Después de rotarla, sí, saca el valor viejo del código y usa una + variable de entorno / secret de Gitea Actions en su lugar. + 4. Si el secreto llegó a estar en `main` (no solo en una rama de PR), + avisa antes de reescribir historial — reescribir historial en un + repo compartido tiene sus propios riesgos y hay que decidirlo con + calma, no como reacción automática del pipeline. + +## Falsos positivos: cómo hacer allowlist + +Gitleaks detecta *formas*, no intención. Cosas que típicamente generan +falsos positivos en este proyecto: + +- Placeholders como `CAMBIAR` en `commerce/secrets.template.yaml` — **no** + deberían disparar nada porque no tienen la forma de un secreto real + (baja entropía, texto plano legible), pero si algún día se usa un + placeholder con más pinta de secreto real (ej. un UUID de ejemplo), sí + puede hacer match. +- Hashes largos o IDs opacos que no son secretos (ej. `MEDUSA_REGION_ID` + en `frontend.yaml`), si tienen entropía suficientemente alta. + +Cuando gitleaks marca algo que **no es** un secreto real, se agrega una +regla de allowlist en un archivo `.gitleaks.toml` en la raíz del repo +(todavía no existe — se crea la primera vez que haga falta): + +```toml +[allowlist] +description = "Falsos positivos conocidos del lab" +regexes = [ + '''MEDUSA_REGION_ID''', +] +paths = [ + '''workloads/ecommerce/commerce/secrets\.template\.yaml''', +] +``` + +!!! warning "No es una vía rápida para ignorar hallazgos reales" + Cada entrada de allowlist debe quedar documentada (por qué es un falso + positivo, no solo "molestaba") y revisada antes de mergear, porque una + allowlist mal escrita (una regex demasiado amplia) puede silenciar un + secreto real futuro sin que nadie se dé cuenta. + +## Dónde ver el resultado + +El job `gitleaks` publica el reporte JSON como artifact del pipeline +(`gitleaks-report`) en cada ejecución, tenga o no hallazgos — así queda +disponible para inspección incluso cuando el scan pasa limpio. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index b6e71fe..e63b6ed 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -98,6 +98,8 @@ nav: - Guía del estudiante: - Inicio: guia-estudiante/README.md - Conceptos básicos: guia-estudiante/conceptos-basicos.md + - DevSecOps: + - Gitleaks (secretos): devsecops/gitleaks.md extra: social: -- 2.54.0 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 2/8] 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 \ -- 2.54.0 From 4aed9f6c5c80425dd5665c756c4de9fe99d0c82b Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 21:07:05 -0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(devsecops):=20agregar=20Semgrep=20SAST?= =?UTF-8?q?=20en=20modo=20auditor=C3=ADa=20al=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escanea workloads/ecommerce con rulesets públicos del registro de Semgrep (p/typescript, p/react, p/nextjs, p/security-audit) vía la imagen oficial semgrep/semgrep:1.173.0. Sin --error a propósito: siempre termina en exit 0, solo reporta — primera vuelta para revisar juntos qué reglas deberían pasar a bloquear más adelante. Validado contra el código real: 0 hallazgos en 91 reglas / 72 archivos. Documenta la herramienta en docs/devsecops/sast.md, incluyendo la diferencia con gitleaks/trivy y qué significa (y qué no) un scan limpio. --- .gitea/workflows/build.yaml | 45 ++++++++ workloads/docs-portal/docs/devsecops/sast.md | 115 +++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 3 files changed, 161 insertions(+) create mode 100644 workloads/docs-portal/docs/devsecops/sast.md diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 8631094..fb3a3f1 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -260,6 +260,51 @@ jobs: --exit-code 0 \ "${MANIFEST_FILE}" + # Modo auditoría: sin --error a propósito, Semgrep siempre termina + # con exit 0 aunque reporte hallazgos. Es la primera vuelta — se + # revisan los resultados en conjunto antes de decidir qué reglas + # deberían pasar a bloquear el pipeline más adelante. + - name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea + shell: bash + run: | + set -euo pipefail + SEMGREP_IMAGE="semgrep/semgrep:1.173.0" + + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -w /src \ + "${SEMGREP_IMAGE}" \ + semgrep scan \ + --config=p/typescript \ + --config=p/react \ + --config=p/nextjs \ + --config=p/security-audit \ + --json \ + --output=semgrep-report.json \ + "${APP_DIR}" + + echo "=== Resumen Semgrep ===" + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -w /src \ + "${SEMGREP_IMAGE}" \ + python3 -c " +import json +data = json.load(open('semgrep-report.json')) +results = data.get('results', []) +print(f'Hallazgos: {len(results)}') +for r in results: + print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\") +" + + - name: Publicar reporte de Semgrep + if: always() + uses: actions/upload-artifact@v3 + with: + name: semgrep-report + path: semgrep-report.json + if-no-files-found: ignore + - name: Login en Gitea Registry uses: docker/login-action@v2 with: diff --git a/workloads/docs-portal/docs/devsecops/sast.md b/workloads/docs-portal/docs/devsecops/sast.md new file mode 100644 index 0000000..bdc4324 --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/sast.md @@ -0,0 +1,115 @@ +# SAST (Semgrep) — análisis estático de código + +!!! info "Qué problema resuelve" + SAST significa *Static Application Security Testing*: analizar el + **código fuente** en busca de patrones de programación inseguros, sin + ejecutar la aplicación. Semgrep lee cada archivo `.ts`/`.tsx` y lo + compara contra un catálogo de reglas — cada regla describe una forma + de escribir código que suele terminar en una vulnerabilidad conocida + (inyección, XSS, uso inseguro de una API, etc.). + +## En qué se diferencia de Gitleaks y Trivy + +Las tres herramientas ya integradas a este pipeline analizan cosas +completamente distintas — vale la pena tenerlo claro porque a primera +vista "escaneo de seguridad" suena como una sola categoría: + +| Herramienta | Qué mira | Pregunta que responde | +|---|---|---| +| [Gitleaks](gitleaks.md) | El texto de los archivos y el historial de git | "¿Hay una credencial commiteada?" | +| [Trivy](trivy.md) | Paquetes instalados (imagen) y configuración (YAML) | "¿Alguna dependencia tiene una CVE conocida? ¿El manifiesto de Kubernetes es inseguro?" | +| **Semgrep (SAST)** | La **lógica** del código que escribimos nosotros | "¿Esta función, tal como está escrita, abre una vulnerabilidad?" | + +Ninguna de las tres reemplaza a las otras. Una dependencia puede estar +100% al día (sin CVEs, Trivy contento) y aun así el código propio puede +construir una URL con un string sin sanitizar y quedar abierto a SSRF — +eso solo lo detecta un análisis de la lógica del código, que es +exactamente lo que hace Semgrep. + +## Qué tipo de bugs detecta (ejemplos genéricos) + +Los rulesets usados (`p/typescript`, `p/react`, `p/nextjs`, +`p/security-audit`) cubren, entre otras cosas: + +- **Inyección**: construir queries, comandos de shell o URLs concatenando + strings con datos que vienen del usuario, en vez de usar una API + parametrizada. +- **XSS en React/Next.js**: usar `dangerouslySetInnerHTML` con contenido + que no pasó por un sanitizador. +- **SSRF**: hacer un `fetch()`/request server-side hacia una URL que + construye el propio usuario, sin validar el host de destino. +- **Criptografía insegura**: algoritmos de hash débiles (`md5`, `sha1`) + usados para contraseñas o tokens, en vez de un KDF diseñado para eso. +- **`eval` / `new Function()`** sobre datos no confiables. +- **ReDoS**: expresiones regulares con backtracking exponencial que un + input malicioso puede usar para colgar el proceso. +- **Prototype pollution**: merges/asignaciones dinámicas de objetos que + permiten sobrescribir `__proto__`. + +## Resultado real de esta primera corrida + +```text +Scanning 72 files tracked by git with 292 Code rules: + ts 87 rules 39 files + js 81 rules 1 file + json 1 rule 6 files + +Ran 91 rules on 72 files: 0 findings. +``` + +`workloads/ecommerce` salió limpio con estos rulesets: **0 hallazgos**. + +!!! warning "0 hallazgos no significa 'código perfecto'" + Significa que ningún patrón conocido de estos 91 rules hizo match — + no es una garantía de ausencia de bugs, solo de ausencia de *estos* + patrones específicos. SAST tiene falsos negativos por naturaleza (un + bug de lógica de negocio nuevo, específico de esta app, no está en + ningún ruleset genérico). El valor de correr esto en cada build no es + "una vez limpio, siempre limpio" — es que si alguien introduce a + futuro uno de estos patrones conocidos (por ejemplo, un + `dangerouslySetInnerHTML` sin sanitizar en un componente nuevo), el + pipeline lo va a marcar en ese mismo push. + +## Por qué modo auditoría (no bloquea) en esta primera integración + +El step corre **sin** la flag `--error` a propósito — Semgrep siempre +termina con `exit 0`, reporta lo que encuentra pero nunca frena el +pipeline. Es la primera vez que esta herramienta corre sobre el repo: la +idea es revisar juntos qué reglas de los 91 activos generan ruido (falsos +positivos específicos de este código) antes de decidir cuáles deberían +pasar a bloquear. + +```mermaid +flowchart LR + A[Semgrep scan] --> B{hallazgos?} + B -- "sí" --> C["se reportan en el log
+ artifact JSON"] + B -- "no" --> C + C --> D["✅ pipeline sigue
(exit 0 siempre)"] +``` + +Una vez que se decida qué reglas son suficientemente confiables para +esta app, el paso natural es agregar `--error` **con un subconjunto** +de reglas (no las 91 completas) usando `--config` más específico o +`.semgrepignore` / reglas individuales marcadas como bloqueantes — +todavía no se hizo ese recorte. + +## Los rulesets elegidos + +- `p/typescript` — patrones generales de TypeScript/JavaScript. +- `p/react` — específico de componentes React (hooks mal usados, XSS vía + props/render, etc.). +- `p/nextjs` — patrones propios del framework (App Router, API routes, + middlewares). +- `p/security-audit` — catálogo transversal de seguridad (inyección, + criptografía débil, deserialización insegura) sin atarse a un + framework específico. + +Son rulesets **públicos y gratuitos** del registro de Semgrep — no +requieren cuenta ni login (`semgrep login` solo hace falta para acceder a +reglas adicionales de pago, que no se usan acá). + +## Dónde ver el resultado + +El JSON completo (`semgrep-report.json`) se publica como artifact del +pipeline (`semgrep-report`) en cada corrida, tenga o no hallazgos — +mismo patrón que Gitleaks y Trivy. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 8c02f9e..ee971a8 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -101,6 +101,7 @@ nav: - DevSecOps: - Gitleaks (secretos): devsecops/gitleaks.md - Trivy (imagen + IaC): devsecops/trivy.md + - SAST (Semgrep): devsecops/sast.md extra: social: -- 2.54.0 From b6038e06ca9974f044121c6d25add986615967a8 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 21:14:00 -0500 Subject: [PATCH 4/8] feat(devsecops): generar SBOM (Syft) de la imagen del frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corre después del gate de CRITICAL de Trivy, sobre la imagen ya construida. Genera CycloneDX y SPDX, publicados como artifact sbom-v1.0.X en cada build. Validado contra la imagen real: 3528 componentes CycloneDX / 228 paquetes SPDX. Documenta en docs/devsecops/sbom.md el propósito (trazabilidad tipo "log4shell"), diferencia entre formatos, y un hallazgo real (yarn empaquetado sin usarse, mismo patrón que el npm sacado en el fix de Trivy). --- .gitea/workflows/build.yaml | 34 +++++ workloads/docs-portal/docs/devsecops/sbom.md | 124 +++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 3 files changed, 159 insertions(+) create mode 100644 workloads/docs-portal/docs/devsecops/sbom.md diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index fb3a3f1..568dc08 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -349,6 +349,40 @@ for r in results: --ignore-unfixed \ "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" + # SBOM de la imagen que ya pasó el gate de CRITICAL — describe + # exactamente qué paquetes (y en qué versión) quedaron adentro. + # CycloneDX: formato más usado por herramientas de consulta/alertas + # de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell). + - name: Instalar Syft + shell: bash + run: | + set -euo pipefail + SYFT_VERSION="1.51.0" + curl -sSfL \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \ + -o /tmp/syft.tar.gz + tar -xzf /tmp/syft.tar.gz -C /tmp syft + chmod +x /tmp/syft + /tmp/syft version + + - name: Generar SBOM (Syft) + shell: bash + run: | + set -euo pipefail + /tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \ + -o cyclonedx-json=sbom.cdx.json \ + -o spdx-json=sbom.spdx.json + + - name: Publicar SBOM + if: always() + uses: actions/upload-artifact@v3 + with: + name: sbom-${{ steps.vars.outputs.VERSION }} + path: | + sbom.cdx.json + sbom.spdx.json + if-no-files-found: ignore + # 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 diff --git a/workloads/docs-portal/docs/devsecops/sbom.md b/workloads/docs-portal/docs/devsecops/sbom.md new file mode 100644 index 0000000..95e2198 --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/sbom.md @@ -0,0 +1,124 @@ +# SBOM (Syft) — inventario de software + +!!! info "Qué es un SBOM" + SBOM = *Software Bill of Materials* — literalmente, una "lista de + materiales" del software, igual que la lista de ingredientes de un + producto. Es un documento (JSON, en este caso) que enumera **cada + paquete que terminó dentro de la imagen final**: nombre, versión + exacta, de dónde viene (`npm`, `deb`, etc.) y, cuando aplica, su + licencia. + + [Syft](https://github.com/anchore/syft) genera ese inventario + inspeccionando la imagen Docker ya construida — no necesita acceso al + código fuente ni a `package.json`, lee directamente lo que quedó + instalado en los layers de la imagen. + +## Por qué importa: el escenario "log4shell" + +En diciembre de 2021 apareció una vulnerabilidad crítica en Log4j (una +librería de logging de Java) usada, directa o indirectamente, en una +cantidad enorme de software. La pregunta que todo equipo tuvo que +responder en horas, no en días, fue: **"¿nosotros usamos esto, en algún +lugar, aunque sea una dependencia de una dependencia?"** + +Sin un SBOM, esa respuesta implica revisar manualmente cada +`package.json`, cada imagen Docker, cada servicio — y confiar en que no +se te escapó una dependencia transitiva de tres niveles de profundidad. +Con un SBOM generado en cada build y guardado como artifact, la respuesta +es una búsqueda de texto sobre un archivo: + +```bash +grep -i "nombre-del-paquete-afectado" sbom.cdx.json +``` + +Si aparece, sabés exactamente en qué versión, y podés cruzarlo contra el +aviso de seguridad para saber si tu versión específica está afectada — +en minutos, no en una auditoría manual del repo completo. + +## Qué genera este pipeline + +Un step de Syft corre sobre la imagen ya construida (la misma que pasó +el gate de CRITICAL de Trivy) y produce **dos formatos** del mismo +inventario, publicados como artifact del pipeline: + +- `sbom.cdx.json` — [CycloneDX](https://cyclonedx.org/), el formato con + mejor soporte en herramientas de consulta/alertas automáticas de CVEs. +- `sbom.spdx.json` — [SPDX](https://spdx.dev/), el estándar ISO, más + orientado a cumplimiento de licencias y trazabilidad legal. + +No hay una razón fuerte para elegir solo uno en esta etapa — generar +ambos cuesta segundos y cada formato es mejor para un caso de uso +distinto, así que se publican los dos. + +## Resultado real de este build + +Corriendo Syft contra la imagen real de `workloads/ecommerce`: + +| Formato | Paquetes listados | +|---|---| +| CycloneDX | 3528 componentes | +| SPDX | 228 paquetes | + +!!! tip "¿Por qué el número es tan distinto entre formatos?" + No es un error — cada formato tiene un nivel de detalle distinto. + CycloneDX de Syft incluye entradas más granulares (variantes, + sub-paquetes, entradas sin versión resuelta marcadas `UNKNOWN`) + mientras que SPDX agrupa a un nivel más alto. Para "¿tengo este + paquete, sí o no?" cualquiera de los dos sirve; para conteos exactos, + hay que saber cuál se está mirando. + +Ejemplo de una entrada real (`sbom.cdx.json`, recortado): + +```json +{ + "name": "next", + "version": "15.5.10", + "licenses": [{ "license": { "id": "MIT" } }], + "purl": "pkg:npm/next@15.5.10", + "properties": [ + { "name": "syft:package:language", "value": "javascript" }, + { "name": "syft:package:type", "value": "npm" } + ] +} +``` + +El campo `purl` (*Package URL*) es el identificador estándar que usan +Trivy, Syft, GitHub Advisories y la mayoría de las bases de datos de +CVEs para referirse a "este paquete, en este ecosistema, en esta +versión" — es lo que hace posible cruzar un SBOM contra un aviso de +seguridad de forma automática, sin depender de que el nombre coincida +exactamente en texto libre. + +!!! example "El SBOM también sirve para encontrar cosas que sobran" + Revisando el inventario real apareció `yarn@1.22.22` — viene + empaquetado en la imagen base de Node igual que el `npm` que ya se + sacó del stage final en el fix de [Trivy](trivy.md). No es una + vulnerabilidad activa hoy, pero es exactamente el tipo de hallazgo + que un SBOM hace visible para revisar después: herramientas que + viajan en la imagen de producción sin que el runtime las necesite. + +## Cómo se consulta + +El SBOM se publica como artifact del pipeline (`sbom-v1.0.X`, con ambos +archivos) en cada build. Para consultarlo: + +1. Descargar el artifact de la corrida del pipeline que te interesa + (o del último build de `main`, para saber qué corre en producción + ahora mismo). +2. Buscar el paquete en cuestión: + ```bash + python3 -c " + import json + d = json.load(open('sbom.cdx.json')) + for c in d['components']: + if c['name'] == 'next': + print(c['name'], c['version']) + " + ``` + (o `grep -A3 '"name": "next"' sbom.cdx.json` si no hay Python a mano). +3. Si el paquete aparece, confirmar la versión contra el aviso de + seguridad para saber si aplica. + +No hace falta memorizar el formato — el punto de tener el SBOM ya +generado es no depender de reconstruir esta información bajo presión +el día que aparezca la próxima CVE grande. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index ee971a8..67e414e 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -102,6 +102,7 @@ nav: - Gitleaks (secretos): devsecops/gitleaks.md - Trivy (imagen + IaC): devsecops/trivy.md - SAST (Semgrep): devsecops/sast.md + - SBOM (Syft): devsecops/sbom.md extra: social: -- 2.54.0 From c1b5f72a685a07e5b526e6e83071023049c25305 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 21:55:03 -0500 Subject: [PATCH 5/8] feat(devsecops): firmar la imagen del frontend con Cosign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firma la imagen (solo el tag versionado, no :latest) después del push exitoso, usando la llave privada desde secrets de Gitea Actions (COSIGN_PRIVATE_KEY/COSIGN_PASSWORD, nunca en el repo ni en disco). Verifica la firma como smoke test en el mismo pipeline contra workloads/ecommerce/cosign.pub (pública, commiteada a propósito). Firma solo con el par de llaves propio, sin publicar en el transparency log público de Sigstore (--use-signing-config=false --tlog-upload=false / --insecure-ignore-tlog=true) — registry privado, no tiene sentido esa fuga de metadata. Par de llaves generado y validado end-to-end (firma + verificación, incluyendo que verificar con la llave incorrecta falla como se espera) contra un registry local descartable antes de tocar nada real. Docs en docs/devsecops/cosign.md, incluyendo qué falta para que un admission controller verifique esto en el cluster (no implementado todavía). --- .gitea/workflows/build.yaml | 45 ++++++ .../docs-portal/docs/devsecops/cosign.md | 133 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + workloads/ecommerce/cosign.pub | 4 + 4 files changed, 183 insertions(+) create mode 100644 workloads/docs-portal/docs/devsecops/cosign.md create mode 100644 workloads/ecommerce/cosign.pub diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 568dc08..12bc140 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -392,6 +392,51 @@ for r in results: docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" docker push "${IMAGE_NAME}:latest" + - name: Instalar Cosign + shell: bash + run: | + set -euo pipefail + COSIGN_VERSION="3.1.3" + curl -sSfL \ + "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \ + -o /tmp/cosign + chmod +x /tmp/cosign + /tmp/cosign version + + # Firma solo la versión (no :latest, que es un tag mutable y firmarlo + # pierde sentido en cuanto se vuelve a mover). --use-signing-config=false + # --tlog-upload=false: firma solo con el par de llaves propio, sin + # publicar metadata en el transparency log público de Sigstore — este + # registry es privado, no tiene sentido anunciar públicamente qué se + # firmó y cuándo. + - name: Firmar Imagen (Cosign) + shell: bash + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + /tmp/cosign sign \ + --key env://COSIGN_PRIVATE_KEY \ + --use-signing-config=false \ + --tlog-upload=false \ + --yes \ + "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" + + # Smoke test: confirma en el propio pipeline que la firma que se + # acaba de crear valida contra la llave pública commiteada en el + # repo (workloads/ecommerce/cosign.pub). Es la misma verificación + # que, más adelante, podría correr un admission controller en el + # cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md). + - name: Verificar Firma (smoke test) + shell: bash + run: | + set -euo pipefail + /tmp/cosign verify \ + --key "${APP_DIR}/cosign.pub" \ + --insecure-ignore-tlog=true \ + "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" + # Evita que una ejecución antigua actualice frontend.yaml después de que # ya exista un commit más reciente en main. - name: Verificar promoción segura diff --git a/workloads/docs-portal/docs/devsecops/cosign.md b/workloads/docs-portal/docs/devsecops/cosign.md new file mode 100644 index 0000000..fa97d2d --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/cosign.md @@ -0,0 +1,133 @@ +# Cosign — firma de imágenes + +!!! info "Qué problema resuelve" + Todo lo anterior en esta sección (Gitleaks, Trivy, Semgrep, SBOM) + responde a la pregunta *"¿esta imagen es segura de construir?"*. + Cosign responde una pregunta distinta, y que pasa **después**: *"la + imagen que está corriendo ahora mismo en el cluster, ¿es + exactamente la que armó el pipeline — o pudo haber sido reemplazada, + modificada, o subida por otra vía?"*. + +## Analogía simple + +Pensalo como el sello de cera en un sobre antiguo. Cualquiera puede leer +la carta (la imagen es pública, cualquiera puede bajarla del registry) — +eso Cosign no lo esconde. Lo que el sello garantiza es otra cosa: que la +carta salió exactamente de donde dice que salió, y que nadie la abrió y +volvió a cerrar en el camino. + +- **La llave privada** (guardada como secret de Gitea, nunca en el repo) + es el sello físico — solo el pipeline de CI puede estampar una firma + válida, porque solo él tiene el sello. +- **La llave pública** (`workloads/ecommerce/cosign.pub`, commiteada sin + problema — es pública a propósito) es la forma de reconocer el sello: + cualquiera puede mirar la carta, ver el sello, y confirmar "sí, esto lo + selló quien tiene la llave privada" — sin necesitar la llave privada + para verificarlo. + +Si alguien sube una imagen distinta con el mismo tag, o modifica un solo +byte de la imagen original, la firma deja de coincidir. No es que Cosign +"detecte" la alteración activamente — es que la verificación +simplemente falla, porque la firma fue calculada sobre el digest exacto +de la imagen original. + +## Cómo funciona en este pipeline + +```mermaid +flowchart LR + A["docker push"] --> B["cosign sign
(llave privada, secret)"] + B --> C["cosign verify
(llave pública, repo)"] + C -- "firma válida" --> D["✅ pipeline termina OK"] + C -- "firma inválida/ausente" --> X["❌ pipeline falla"] +``` + +1. **Par de llaves**: generado una vez con `cosign generate-key-pair`, + protegido por password. La privada (`cosign.key`) se subió como + secret de Gitea Actions (`COSIGN_PRIVATE_KEY` + `COSIGN_PASSWORD`) — + nunca se commiteó al repo, ni existe en el disco de este equipo + después de subirla. La pública (`cosign.pub`) sí vive commiteada en + `workloads/ecommerce/cosign.pub`, porque su función es poder + compartirse. +2. **Firma**: después de subir la imagen al registry, el pipeline la + firma con la llave privada (leída desde el secret vía + `--key env://COSIGN_PRIVATE_KEY`, sin escribirla nunca a disco). +3. **Verificación (smoke test)**: en el mismo pipeline, inmediatamente + después, se verifica la firma recién creada contra la llave pública + del repo. Si algo salió mal (llave incorrecta, imagen corrupta), el + pipeline falla ahí mismo — antes de que nadie más intente confiar en + esa imagen. + +## Por qué `--tlog-upload=false` + +Cosign, por defecto, publica cada firma en el *transparency log* público +de Sigstore (Rekor) — un registro público, auditable, de "quién firmó +qué y cuándo", pensado para proyectos open source donde esa +transparencia es el punto. Este registry (`gitea.cruzcloud.net`) es +privado; no tiene sentido — y sería una fuga de metadata innecesaria — +anunciar públicamente que este lab construyó una imagen `v1.0.97` en tal +fecha. Por eso el pipeline firma solo con el par de llaves propio, +localmente, sin tocar el transparency log público +(`--use-signing-config=false --tlog-upload=false` al firmar, +`--insecure-ignore-tlog=true` al verificar). + +!!! warning "Trade-off consciente, no gratis" + Sin transparency log, la garantía es "esta firma la generó quien + tiene la llave privada" — pero no hay un registro público e + inmutable de *cuándo* se generó cada firma. Para un registry privado + de un lab personal, ese trade-off tiene sentido. Para un proyecto + open source con más de una persona firmando, seguramente no. + +## Qué NO se firma + +Solo se firma el tag versionado (`ecommerce-frontend:v1.0.X`), no +`:latest`. `:latest` es un tag mutable — se re-apunta a una imagen +distinta en cada build — así que firmarlo no significa nada útil: la +firma quedaría asociada al digest de turno, y la siguiente build la +volvería a mover. Cualquier verificación real de firma debería apuntar +siempre a un tag de versión específico (o, mejor todavía, al digest +exacto). + +## Verificar manualmente + +Con la llave pública del repo, cualquiera puede confirmar la firma de +una imagen sin necesitar acceso a nada privado: + +```bash +cosign verify \ + --key workloads/ecommerce/cosign.pub \ + --insecure-ignore-tlog=true \ + gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.97 +``` + +Si la imagen fue firmada por este pipeline, el comando termina con +`exit 0` y muestra el detalle de la firma. Si no — sea porque nunca se +firmó, porque la firmó otra llave, o porque la imagen fue modificada +después — termina con `exit 1` y un error explícito. + +## Qué falta (a propósito, todavía) + +Hoy la verificación de firma corre como smoke test **dentro del mismo +pipeline que la creó** — útil para confirmar que el mecanismo funciona, +pero no impide que alguien despliegue manualmente una imagen sin firmar +en el cluster. El siguiente paso natural, que **no** se implementó en +esta primera vuelta, sería un *admission controller* en el cluster +(ej. [Sigstore's policy-controller](https://docs.sigstore.dev/policy-controller/overview/) +o [Kyverno](https://kyverno.io/policies/other/verify-images/verify-images/) +con una política de verificación de imágenes) que rechace cualquier Pod +cuya imagen no tenga una firma válida de `cosign.pub` — momento en el +que Argo CD dejaría de poder desplegar una imagen sin firmar, no solo el +pipeline de CI. + +## Si la llave privada se compromete + +1. Generar un par nuevo (`cosign generate-key-pair`). +2. Reemplazar `COSIGN_PRIVATE_KEY` y `COSIGN_PASSWORD` en los secrets de + Gitea Actions del repo. +3. Reemplazar `workloads/ecommerce/cosign.pub` con la nueva llave + pública, en un commit normal (no es secreto, no hace falta + reescribir historial). +4. Las imágenes ya firmadas con la llave vieja **siguen verificando + contra la llave vieja** — no se "invalidan" solas. Si se sospecha + compromiso real, hay que decidir explícitamente qué imágenes ya + desplegadas se consideran no confiables, no asumir que rotar la + llave alcanza. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 67e414e..5dd51ed 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -103,6 +103,7 @@ nav: - Trivy (imagen + IaC): devsecops/trivy.md - SAST (Semgrep): devsecops/sast.md - SBOM (Syft): devsecops/sbom.md + - Cosign (firma de imágenes): devsecops/cosign.md extra: social: diff --git a/workloads/ecommerce/cosign.pub b/workloads/ecommerce/cosign.pub new file mode 100644 index 0000000..1b29af6 --- /dev/null +++ b/workloads/ecommerce/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+ +VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw== +-----END PUBLIC KEY----- -- 2.54.0 From 7dc50b83e03aaf8ed8dd1ed81ff254059dc2c231 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 21:59:58 -0500 Subject: [PATCH 6/8] =?UTF-8?q?docs(devsecops):=20agregar=20p=C3=A1gina=20?= =?UTF-8?q?resumen=20con=20diagrama=20del=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/devsecops/index.md documenta el pipeline completo (job gitleaks + job build) con un diagrama Mermaid del orden real de ejecución, tabla de qué bloquea vs qué solo informa, y qué pasa si cada herramienta falla. Nav actualizado: "Resumen" como landing page de la sección DevSecOps. Con esto quedan las 5 herramientas de la Fase 2 (Gitleaks, Trivy, Semgrep, Syft, Cosign) integradas y documentadas para workloads/ecommerce, listas para replicar a commerce-backend y docs-portal en una próxima vuelta. --- workloads/docs-portal/docs/devsecops/index.md | 91 +++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 2 files changed, 92 insertions(+) create mode 100644 workloads/docs-portal/docs/devsecops/index.md diff --git a/workloads/docs-portal/docs/devsecops/index.md b/workloads/docs-portal/docs/devsecops/index.md new file mode 100644 index 0000000..23d5761 --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/index.md @@ -0,0 +1,91 @@ +# DevSecOps + +Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea +Actions, empezando por un solo repo de referencia — el frontend de ARI +Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — antes +de replicarlo a `commerce-backend` y `docs-portal`. + +Cinco herramientas, cada una respondiendo una pregunta distinta: + +| Herramienta | Pregunta que responde | Modo | +|---|---|---| +| [Gitleaks](gitleaks.md) | ¿Hay un secreto commiteado? | **Bloquea** | +| [Trivy — imagen](trivy.md) | ¿La imagen tiene una CVE conocida? | CRITICAL **bloquea**, HIGH informa | +| [Trivy — IaC](trivy.md) | ¿El manifiesto de Kubernetes es inseguro? | Informa | +| [Semgrep (SAST)](sast.md) | ¿El código tiene un patrón inseguro conocido? | Informa (auditoría) | +| [Syft (SBOM)](sbom.md) | ¿Qué paquetes exactos quedaron en la imagen? | Informa (inventario) | +| [Cosign](cosign.md) | ¿Esta imagen es exactamente la que armó el pipeline? | **Bloquea** (smoke test) | + +## El pipeline completo + +```mermaid +flowchart TD + subgraph J1["job: gitleaks (push + pull_request)"] + A1[checkout] --> A2["gitleaks detect --no-git"] + end + + A2 -- "secreto encontrado" --> XA["❌ pipeline detenido
build nunca arranca"] + A2 -- "limpio" --> B0 + + subgraph J2["job: build (solo push a main, needs: gitleaks)"] + B0[checkout + validaciones de código] --> B1["Trivy IaC
(frontend.yaml)"] + B1 -.informa.-> B2["Semgrep SAST
(audit mode)"] + B2 -.informa.-> B3[login registry] + B3 --> B4["docker build
(push: false, load: true)"] + B4 --> B5["Trivy imagen
CRITICAL"] + B5 -- "CRITICAL con fix" --> XB["❌ detenido
no se sube la imagen"] + B5 -- "sin CRITICAL" --> B6["Trivy imagen
HIGH (informa)"] + B6 --> B7["Syft → SBOM
(CycloneDX + SPDX)"] + B7 --> B8["docker push"] + B8 --> B9["cosign sign"] + B9 --> B10["cosign verify
(smoke test)"] + B10 -- "firma inválida" --> XC["❌ detenido"] + B10 -- "firma válida" --> B11["actualizar frontend.yaml
(GitOps, Argo CD sincroniza)"] + end +``` + +## Por qué este orden + +- **Gitleaks corre en un job aparte, antes que todo lo demás** — incluso + antes que el checkout completo del job de build. Si hay un secreto, + no tiene sentido gastar minutos de build en un commit que hay que + rechazar igual. Es el único check que corre también en `pull_request`, + no solo en push a `main`. +- **Trivy IaC y Semgrep corren antes del build de Docker.** Ninguno de + los dos necesita la imagen construida — analizan manifiestos y código + fuente respectivamente — así que si algo llamativo apareciera ahí, se + sabe temprano, sin esperar el build (que es el paso más lento del + pipeline). +- **Trivy imagen corre después del build pero antes del push.** Por eso + el build usa `push: false, load: true`: la imagen queda en el daemon + local del runner, escaneable, pero no sale hacia el registry hasta + pasar el gate de CRITICAL. +- **Syft (SBOM) corre sobre la imagen ya validada**, antes del push — + documenta exactamente lo que se está por publicar. +- **Cosign firma después del push exitoso** (no tiene sentido firmar + algo que no llegó al registry) y se verifica en el mismo pipeline como + smoke test. + +## Qué pasa si cada uno falla + +| Si falla... | El pipeline... | +|---|---| +| Gitleaks | Se detiene ahí mismo. El job `build` nunca arranca (`needs: gitleaks`). Nada se construye. | +| Trivy IaC | No se detiene — el hallazgo queda en el log, informativo. | +| Semgrep | No se detiene — mismo criterio: primera vuelta en modo auditoría. | +| Trivy imagen (CRITICAL) | Se detiene después del build, antes del push. La imagen con la CVE nunca llega al registry. | +| Trivy imagen (HIGH) | No se detiene — se reporta para revisar en conjunto. | +| Syft | Si el propio comando falla (no si "encuentra algo" — un SBOM no tiene hallazgos que bloqueen), el pipeline se detiene por error real de la herramienta. | +| Cosign sign/verify | Se detiene. Si la imagen se firmó pero no verifica, algo está mal con las llaves o con la imagen — no se continúa con la promoción GitOps. | + +## Qué falta después de esta primera vuelta + +- Revisar en conjunto los hallazgos de Trivy IaC y Semgrep (hoy + informativos) y decidir cuáles pasan a bloquear. +- Decidir si Trivy imagen sube el umbral de bloqueo a HIGH una vez que + el backlog de CVEs conocidas esté bajo control. +- Admission controller en el cluster que verifique la firma de Cosign + antes de dejar correr un Pod (ver [cosign.md](cosign.md)) — hoy la + verificación es solo un smoke test dentro del propio pipeline. +- Replicar este mismo patrón a `commerce-backend` (Medusa) y + `docs-portal`, adaptando lo que corresponda a cada stack. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 5dd51ed..dbcc013 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -99,6 +99,7 @@ nav: - Inicio: guia-estudiante/README.md - Conceptos básicos: guia-estudiante/conceptos-basicos.md - DevSecOps: + - Resumen: devsecops/index.md - Gitleaks (secretos): devsecops/gitleaks.md - Trivy (imagen + IaC): devsecops/trivy.md - SAST (Semgrep): devsecops/sast.md -- 2.54.0 From 96be5b4b03bde5d3a88c7dd773aec7d8b8c7cbe4 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 22:28:22 -0500 Subject: [PATCH 7/8] chore: trigger CI re-check after Gitea SQLite WAL fix -- 2.54.0 From 2438a6ba0fc7abb51bd9dcce3a979e0a83f95db5 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Fri, 14 Aug 2026 22:59:31 -0500 Subject: [PATCH 8/8] =?UTF-8?q?fix(ci):=20corregir=20indentaci=C3=B3n=20YA?= =?UTF-8?q?ML=20rota=20en=20el=20step=20de=20Semgrep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El python3 -c "..." embebido dentro del run: | tenía el código pegado a columna 0, por debajo de la indentación del bloque YAML -- eso corta el block scalar ahí mismo y Gitea terminaba ignorando el workflow completo ("could not find expected ':'"), silenciosamente, desde el commit de Semgrep. Validado con un parser YAML real (no solo con builds de Docker) y bash -n sobre los 20 steps del archivo. --- .gitea/workflows/build.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 12bc140..c027668 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -289,13 +289,13 @@ jobs: -w /src \ "${SEMGREP_IMAGE}" \ python3 -c " -import json -data = json.load(open('semgrep-report.json')) -results = data.get('results', []) -print(f'Hallazgos: {len(results)}') -for r in results: - print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\") -" + import json + data = json.load(open('semgrep-report.json')) + results = data.get('results', []) + print(f'Hallazgos: {len(results)}') + for r in results: + print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\") + " - name: Publicar reporte de Semgrep if: always() -- 2.54.0