diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
index b48a783..c027668 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
@@ -180,6 +235,76 @@ 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}"
+
+ # 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:
@@ -188,14 +313,129 @@ 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 }}"
+
+ # 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
+ shell: bash
+ run: |
+ set -euo pipefail
+ 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.
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/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/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/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/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/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 b6e71fe..dbcc013 100644
--- a/workloads/docs-portal/mkdocs.yml
+++ b/workloads/docs-portal/mkdocs.yml
@@ -98,6 +98,13 @@ nav:
- Guía del estudiante:
- 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
+ - SBOM (Syft): devsecops/sbom.md
+ - Cosign (firma de imágenes): devsecops/cosign.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 \
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-----