Compare commits

..
Author SHA1 Message Date
devops be3e5c2a4c docs(devsecops): documentar 4+1 hallazgos de infraestructura y TODOs de Cosign
5 playbooks nuevos en docs/playbooks/ del portal:
- SQLite en modo DELETE causando SQLITE_BUSY, por que WAL lo resuelve.
- Anchors YAML no soportados por el parser de Gitea Actions, incluido
  el diagnostico equivocado inicial (indentacion) que se corrigio
  despues -- documentado con honestidad como leccion de proceso.
- Docker-in-Docker vs sibling containers, y por que Semgrep corre
  nativo en un venv en vez de como container aparte.
- El SNI/realm HTTP del tunel rompiendo especificamente a Cosign
  (docker push funcionaba, cosign sign no), conectado con el
  incidente ya documentado del 504 -- misma capa de ingress, dos
  fallas distintas.
- Contencion de recursos entre Gitea y su runner: causa raiz real
  confirmada en vivo durante esta misma sesion (ambos contenedores
  reiniciados a mitad de un build, sin limites de CPU/memoria reales),
  con fix propuesto (ver rama fix/gitea-runner-resource-limits en
  scripts/) pendiente de aplicacion manual.

Se actualiza el playbook del 504 para linkear hacia el nuevo hallazgo
de contencion, cerrando el loop que habia quedado abierto ahi.

cosign.md: nueva seccion "Limitaciones conocidas y mejoras futuras"
con los dos TODO reales -- firma por tag en vez de digest (con el
warning real de Cosign visto en los logs) y transparency log de Rekor
deshabilitado, explicado en simple.

index.md: tabla resumen de los 3 pipelines (imagen + adaptacion de
Semgrep por stack), diagrama generalizado para reflejar que es el
mismo patron en los 3 workflows, y "que falta" actualizado.

Validado con mkdocs build --strict (0 errores, 0 warnings) antes de
commitear.
2026-08-15 15:17:43 -05:00
gitea-actions 826f199ebc chore(gitops): deploy Docs Portal v1.0.110 [skip ci] 2026-08-15 19:48:50 +00:00
devops 5c47a39579 fix(docs-portal): remediar CVE CRITICAL en libssl3/libcrypto3 (Alpine)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 4m25s
Run 144 de deploy-docs.yaml encontro un CVE CRITICAL real
(CVE-2026-31789, heap buffer overflow en OpenSSL) en libssl3/libcrypto3
de la base nginx:1.27-alpine, con fix ya publicado por Alpine
(3.3.3-r0 -> 3.3.7-r0). Se agrega apk upgrade puntual de esos dos
paquetes en el Dockerfile. Verificado localmente: docker build OK +
Trivy real en exit 0 (0 vulnerabilidades).
2026-08-15 14:43:52 -05:00
devops 3ee7171178 Merge: incorporar promocion GitOps automatica de Medusa v1.0.108
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m27s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 4m56s
2026-08-15 14:30:52 -05:00
devops 81c0f99e00 Merge fix/devsecops-docs-portal: pipeline de seguridad para docs-portal 2026-08-15 14:30:37 -05:00
gitea-actions 38722b97a8 chore(gitops): deploy Medusa v1.0.108 [skip ci] 2026-08-15 19:28:44 +00:00
devops 5fc86e6ef4 chore: reintentar pipeline de commerce-backend (run 107 murio por reinicio de Gitea, no por el pipeline)
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 10m59s
2026-08-15 14:17:18 -05:00
devops 93f071c175 Merge fix/devsecops-medusa-cve-remediation: remediacion CVE + excepcion documentada
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 22m14s
2026-08-15 13:50:09 -05:00
devops 0c1076eda0 fix(commerce-backend): remediar 3/5 CVE CRITICAL, documentar excepcion para 2
Run 138 de build-medusa.yaml encontro 5 CVE CRITICAL reales (no falsos
positivos) al escanear la imagen con Trivy:

Remediados en el Dockerfile (verificado localmente con Trivy real,
exit 0):
- CVE-2026-33845 / CVE-2026-42010 (libgnutls30, base Debian): apt
  upgrade puntual del paquete.
- CVE-2026-59873 (tar vendorizado dentro del npm CLI global de la
  imagen base node:*-slim): se elimina /usr/local/lib/node_modules/npm
  completo -- no es una dependencia real del proyecto (el CMD nunca
  invoca npm en runtime), asi que no hay nada que "actualizar".

Documentados como excepcion en workloads/commerce-backend/.trivyignore
(con --ignorefile solo en este workflow, no afecta a build.yaml ni
deploy-docs.yaml):
- CVE-2024-24790 / CVE-2025-68121 (Go stdlib embebido en el binario de
  esbuild que trae [email protected], dependencia interna de
  @medusajs/admin-sdk). Se probo el fix real (override de esbuild a
  una version mas nueva) y rompio el build del admin panel --
  [email protected] fija "esbuild: ^0.21.3" como dependencia directa, y
  esbuild >=0.24 cambio el manejo de targets de transpilacion que vite
  5 espera. No existe un patch dentro de la serie 0.21.x con un Go
  toolchain mas nuevo. Requiere subir @medusajs/admin-sdk/vite en un
  cambio aparte, fuera de alcance de este pipeline de seguridad.
2026-08-15 13:49:51 -05:00
devops 80d99f9f3f Merge fix/devsecops-trivy-timeout: fix real tras primer intento fallido (run 136)
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 15m1s
2026-08-15 13:10:55 -05:00
devops 81f01c7ade fix(devsecops): timeout de 15m en Trivy imagen para commerce-backend
El run 136 (primer intento de este pipeline, recien mergeado a main)
fallo en el paso "Escanear imagen (Trivy) - CRITICAL bloquea" con
"context deadline exceeded" a los 4m52s -- no una CVE, un timeout de
herramienta. Causa: el default de Trivy (5m) no alcanza para escanear
(vuln + secret scan de la imagen completa) el node_modules de Medusa
v2, mas pesado que el del frontend. Se agrega --timeout 15m0s a ambos
escaneos de imagen (CRITICAL y HIGH).
2026-08-15 13:09:47 -05:00
devops 27a1e75a4c fix(devsecops): timeout de 15m en Trivy imagen para docs-portal
Preventivo: mismo comando que en commerce-backend, donde se vio en
vivo que el timeout default de Trivy (5m) no alcanza en este host bajo
carga ("context deadline exceeded" a los 4m52s, run 136). Ver
fix/devsecops-trivy-timeout para el fix real en commerce-backend.
2026-08-15 13:08:24 -05:00
devops 45312fc189 Merge fix/devsecops-commerce-backend: pipeline de seguridad para commerce-backend
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 9m31s
2026-08-15 12:51:41 -05:00
devops 1bcae76102 feat(devsecops): replicar pipeline de seguridad a docs-portal
Agrega a deploy-docs.yaml las mismas 6 etapas ya validadas en
build.yaml (run 134, frontend): Gitleaks, Trivy IaC, Semgrep (SAST),
Trivy imagen, Syft (SBOM) y Cosign (firma + verificación).

Adaptaciones de stack:
- Trivy IaC escanea todo workloads/docs-portal/ (varios manifiestos
  K8s separados: deployment/service/ingress), no un MANIFEST_FILE
  único como el frontend.
- Semgrep usa p/python + p/security-audit (no hay TypeScript/React
  acá) -- docs-portal no tiene código de aplicación propio hoy (solo
  Markdown + config de MkDocs), así que 0 hallazgos es el resultado
  esperado; el stage queda listo para el día que se agregue un
  hook/plugin en Python.

Mismos umbrales que el resto: Trivy bloquea en CRITICAL, Semgrep en
modo auditoría. Reutiliza el mismo par de llaves Cosign (secrets ya
existentes a nivel de repo); se agrega workloads/docs-portal/cosign.pub.
2026-08-15 12:47:43 -05:00
devops 8b49ac2bc5 feat(devsecops): replicar pipeline de seguridad a commerce-backend
Agrega a build-medusa.yaml las mismas 6 etapas ya validadas en
build.yaml (run 134, frontend): Gitleaks, Trivy IaC, Semgrep (SAST),
Trivy imagen, Syft (SBOM) y Cosign (firma + verificación).

Ruleset de Semgrep adaptado: p/typescript + p/security-audit +
p/owasp-top-ten, sin p/react/p/nextjs -- commerce-backend es la API de
Medusa v2 (Node/TypeScript), no una app con SSR. Mismos umbrales que
el frontend: Trivy bloquea en CRITICAL, Semgrep en modo auditoría
(no bloquea todavía).

Reutiliza el mismo par de llaves Cosign del frontend (secrets ya
existentes a nivel de repo); se agrega workloads/commerce-backend/cosign.pub
con la misma llave pública para que la verificación quede local a
este workflow.
2026-08-15 12:46:45 -05:00
devops c25877c700 chore: release v1.0.104 [skip ci] 2026-08-15 06:23:20 +00:00
devops 941c009c9c Merge pull request 'chore: retrigger real del pipeline (take 4)' (#25) from chore/retrigger-pipeline-take4 into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m26s
2026-08-15 06:15:09 +00:00
devops 5d3d578d36 chore: retrigger real del pipeline (comentario en build.yaml, path filtrado)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 29s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
2026-08-15 01:14:59 -05:00
devops ff1f1358a7 Merge pull request 'chore: reintentar disparo del pipeline (take 3)' (#24) from chore/retrigger-pipeline-take3 into main 2026-08-15 06:10:55 +00:00
devops f9a4b0a4ea chore: reintentar disparo del pipeline (take 3, runner reiniciado) 2026-08-15 01:10:42 -05:00
devops 69db467edd Merge pull request 'chore: reintentar disparo del pipeline (take 2)' (#23) from chore/retrigger-pipeline-take2 into main 2026-08-15 06:09:07 +00:00
devops 63de0b4ed3 chore: reintentar disparo del pipeline (la corrida anterior coincidió con inestabilidad transitoria del túnel) 2026-08-15 01:08:56 -05:00
devops 9418fb4d1a Merge pull request 'chore: re-disparar pipeline tras fix del tunel Cloudflare' (#22) from chore/retrigger-pipeline-cosign-fix into main 2026-08-15 06:03:47 +00:00
devops c492269740 chore: re-disparar pipeline tras arreglar el realm https del túnel Cloudflare→NPM
El fix fue en infraestructura (regla de ingress de cloudflared para
gitea.cruzcloud.net -> HTTPS con Origin Server Name correcto), no en
este repo -- este commit vacío es solo para generar un push nuevo y
validar el pipeline completo (incluida la firma de Cosign) de punta
a punta con las credenciales reales.
2026-08-15 01:03:33 -05:00
devops 1f467f2b8f Merge pull request 'fix(ci): correr Semgrep nativo (venv) en vez de docker run anidado' (#21) from fix/build-yaml-anchor-not-supported into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 17s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 11m59s
2026-08-15 04:50:41 +00:00
devops e3ab3f7fc0 fix(ci): correr Semgrep nativo (venv) en vez de docker run anidado
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
El runner de Gitea ejecuta cada job dentro de un contenedor propio que
habla con el daemon Docker del host (sibling containers) -- un
`docker run -v "\${{ github.workspace }}:/src"` desde ahí intenta
montar una ruta que solo existe DENTRO del contenedor del job, no en
el host real: "mkdir /workspace: read-only file system". Confirmado en
el run real (job 128, task_id 128) tras el fix del anchor.

Se valida localmente contra docker.gitea.com/runner-images:ubuntu-latest
(la misma imagen del runner real) que python3/pip3 están disponibles.
pip3 install directo falla por paquetes de apt sin metadata compatible
(PyJWT, "RECORD file not found") -- se usa un venv aislado en su lugar,
validado end-to-end con los dos steps extraídos del YAML real.
2026-08-14 23:50:23 -05:00
devops 1df1c3a3c1 Merge pull request 'fix(ci): quitar anchors/aliases YAML de build.yaml' (#20) from fix/build-yaml-anchor-not-supported into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 42s
2026-08-15 04:25:51 +00:00
devops 6773a8085d fix(ci): quitar anchors/aliases YAML de build.yaml, no soportados por Gitea Actions
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Gitea descartaba el workflow completo en TODOS los eventos (push y
pull_request), desde el primer commit de gitleaks: "unknown on type:
&yaml.Node{...Value:"frontend_paths"...}". El YAML era válido (pyyaml
lo parsea sin problema, resolviendo el alias como cualquier parser
estándar) pero el parser propio de Gitea Actions para el bloque "on:"
no resuelve &anchor/*alias antes de inspeccionar el tipo de nodo.

Confirmado en vivo: tras mergear #19 a main, deploy-docs.yaml corrió
normal (sin anchors) pero build.yaml no generó ningún action_run.

Fix: paths duplicados literalmente entre push: y pull_request:, sin
anchor. Válido con parser YAML + bash -n en los 20 steps.
2026-08-14 23:24:31 -05:00
gitea-actions ea7d5e7f47 chore(gitops): deploy Docs Portal v1.0.98 [skip ci] 2026-08-15 04:15:42 +00:00
devops 98096ab69f Merge pull request 'feat(devsecops): Gitleaks + Trivy + Semgrep + Syft + Cosign en el frontend' (#19) from fix/frontend-gitleaks-ci into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 1m36s
2026-08-15 04:14:08 +00:00
devops 2438a6ba0f fix(ci): corregir indentación YAML rota en el step de Semgrep
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.
2026-08-14 22:59:31 -05:00
devops 96be5b4b03 chore: trigger CI re-check after Gitea SQLite WAL fix 2026-08-14 22:28:22 -05:00
devops 7dc50b83e0 docs(devsecops): agregar página resumen con diagrama del pipeline
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.
2026-08-14 21:59:58 -05:00
devops c1b5f72a68 feat(devsecops): firmar la imagen del frontend con Cosign
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).
2026-08-14 21:55:03 -05:00
devops b6038e06ca feat(devsecops): generar SBOM (Syft) de la imagen del frontend
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).
2026-08-14 21:14:00 -05:00
devops 4aed9f6c5c feat(devsecops): agregar Semgrep SAST en modo auditoría al pipeline
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.
2026-08-14 21:07:05 -05:00
devops df13233a29 feat(devsecops): agregar Trivy (imagen + IaC) al pipeline del frontend
trivy config sobre frontend.yaml (CRITICAL/HIGH/MEDIUM, informativo) y
trivy image sobre la imagen recién construida (CRITICAL bloquea con
--ignore-unfixed, HIGH solo informa), entre build (push:false, load:true)
y el push real al registry.

Fix real encontrado al validar contra la imagen real: el stage runner
heredaba npm/npx/corepack completos de la imagen base de Node sin
necesitarlos en runtime, trayendo CVE-2026-59873 (CRITICAL, node-tar
empaquetado en npm). Sacarlos del stage final baja CRITICAL de 1 a 0 y
HIGH fixable de 21 a 15 (las que quedan son de la propia app, ej. next
desactualizado, documentadas como pendiente sin bloquear).

Documenta ambos scans en docs/devsecops/trivy.md con los hallazgos
reales de este repo.
2026-08-14 20:43:09 -05:00
devops 422e9d257d feat(devsecops): agregar Gitleaks al pipeline del frontend
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.
2026-08-14 19:48:44 -05:00
gitea-actions 66c44a7dad chore(gitops): deploy Docs Portal v1.0.97 [skip ci] 2026-08-14 04:10:07 +00:00
devops ef0093a406 Merge pull request 'fix(docs-portal): deshabilitar git-revision-date-localized (rompe --strict)' (#18) from fix/docs-portal-deshabilitar-plugin-fechas into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 10m7s
2026-08-14 03:29:21 +00:00
devops 0234c20463 fix(docs-portal): deshabilitar git-revision-date-localized (rompe --strict)
mkdocs build --strict aborta ante CUALQUIER WARNING, no solo ante links
rotos. El plugin, al no tener .git real en el contexto de build
(workloads/docs-portal/, sin .git de la raíz del repo), cae siempre en
fallback_to_build_date y emite un WARNING por página — suficiente para
tumbar el build bajo --strict (confirmado en run 96, job 108: 33
warnings, todos de este plugin, cero links rotos).

Se deshabilita el plugin (comentado en mkdocs.yml y requirements.txt,
con TODO fase 2: mover el build a la raíz del repo + fetch-depth:0 para
darle historial real) y se revierte la instalación de git en el
Dockerfile, que ya no hace falta. index.md actualizado para no prometer
fechas reales que hoy no se muestran.
2026-08-13 22:27:56 -05:00
devops 9ec40370bf Merge pull request 'fix(docs-portal): corregir anchor roto en playbook de crashloop de Medusa' (#17) from fix/docs-portal-anchor-roto into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 2m11s
2026-08-14 03:20:51 +00:00
devops 22534d65f3 fix(docs-portal): corregir anchor roto en playbook de crashloop de Medusa
mkdocs build --strict (run 95, job 107) falló: el link a '#deuda-técnica-pendiente'
no coincide con el anchor real que genera mkdocs, porque el slugify por
defecto de Python-Markdown normaliza tildes (NFKD + strip de combining marks)
antes de generar el id del heading. El heading '## Deuda técnica pendiente'
genera 'deuda-tecnica-pendiente' (sin tilde), no 'deuda-técnica-pendiente'.
2026-08-13 22:10:36 -05:00
devops 45924f9df2 Merge pull request 'fix(docs-portal): instalar git en la imagen de build para mkdocs-git-revision-date-localized-plugin' (#16) from fix/docs-portal-dockerfile-git-binario into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 9m50s
2026-08-14 02:57:10 +00:00
devops bb1249fd58 fix(docs-portal): instalar git en la imagen de build para mkdocs-git-revision-date-localized-plugin
El plugin depende de GitPython, que necesita el binario git para
importarse (no solo para leer fechas) — sin él, mkdocs build --strict
falla en el import del plugin antes de llegar al fallback_to_build_date
configurado en mkdocs.yml.

Detectado en el primer intento real de build en Gitea Actions (run 94,
job 106).
2026-08-13 21:56:11 -05:00
devops 10556758b2 Merge pull request 'docs(docs-portal): documentar que el pipeline de build/deploy quedó operativo' (#15) from fix/docs-portal-estado-pipeline-vivo into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 7m46s
2026-08-14 02:42:04 +00:00
devops e8712327a2 docs(docs-portal): documentar que el pipeline de build/deploy quedó operativo
Dispara el primer build real del portal via .gitea/workflows/deploy-docs.yaml
ahora que el Deployment, Service, Ingress y el secret de registry ya existen
en el namespace docs-portal.
2026-08-13 21:41:50 -05:00
29 changed files with 2412 additions and 39 deletions
+287 -8
View File
@@ -1,5 +1,15 @@
name: Build and Push Medusa name: Build and Push Medusa
# Retrigger: el run 107 (con la remediacion de CVEs ya mergeada) fue
# cancelado a mitad de camino porque el stack de Gitea (gitea +
# gitea-runner) se reinicio solo durante el build -- no por el
# contenido del pipeline. Ver hallazgo de contencion de recursos en
# docs/playbooks.
# Sin anchors/aliases de YAML (&x / *x) a propósito -- el parser de
# workflows de Gitea Actions no los resuelve en el bloque "on:" y
# descarta el archivo completo con "unknown on type". Las dos listas de
# paths quedan duplicadas literalmente (mismo criterio que build.yaml).
on: on:
push: push:
branches: branches:
@@ -7,17 +17,76 @@ on:
paths: paths:
- 'workloads/commerce-backend/**' - 'workloads/commerce-backend/**'
- '.gitea/workflows/build-medusa.yaml' - '.gitea/workflows/build-medusa.yaml'
pull_request:
branches:
- main
paths:
- 'workloads/commerce-backend/**'
- '.gitea/workflows/build-medusa.yaml'
permissions: permissions:
contents: write contents: write
packages: write packages: write
jobs: jobs:
# Corre en push y en pull_request, siempre antes que build. Mismo
# criterio que el frontend: si hay un secreto commiteado, el job build
# nunca arranca (needs: gitleaks).
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
- name: Escanear secretos en el árbol de archivos
shell: bash
run: |
set -euo pipefail
/tmp/gitleaks detect \
--source=workloads/commerce-backend \
--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: build:
name: Construir y publicar Medusa name: Construir y publicar Medusa
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
env:
APP_DIR: workloads/commerce-backend
MANIFEST_FILE: workloads/ecommerce/commerce/medusa.yaml
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-medusa
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -48,6 +117,82 @@ jobs:
exit 1 exit 1
} }
- 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 de Medusa (no la imagen).
# Informativo por ahora -- mismo criterio que el frontend, se
# revisan los hallazgos en conjunto antes de decidir qué bloquea.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
# Mismo motivo que en build.yaml: el runner ejecuta el job ya
# dentro de un contenedor propio que habla con el daemon Docker
# del host (sibling containers, no Docker-in-Docker) -- correr
# Semgrep como container aparte falla montando rutas que no
# existen en el host real. Se instala nativo en un venv.
- name: Instalar Semgrep
shell: bash
run: |
set -euo pipefail
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
/tmp/semgrep-venv/bin/semgrep --version
# Ruleset adaptado al stack real de este repo: commerce-backend es
# una API de Medusa v2 (Node.js/TypeScript sobre Express), no una
# app Next.js con SSR -- confirmado en package.json (sin
# dependencia de "next", react/react-dom solo llegan como
# dependencia transitiva del admin-sdk de Medusa, no hay código de
# UI propio en este directorio). Por eso se usa p/typescript en
# vez de p/typescript + p/react + p/nextjs del frontend.
# Modo auditoría, igual que el frontend: sin --error, primera
# vuelta para revisar hallazgos antes de decidir qué bloquea.
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
shell: bash
run: |
set -euo pipefail
/tmp/semgrep-venv/bin/semgrep scan \
--config=p/typescript \
--config=p/security-audit \
--config=p/owasp-top-ten \
--json \
--output=semgrep-report.json \
"${APP_DIR}"
echo "=== Resumen Semgrep ==="
/tmp/semgrep-venv/bin/python -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 - name: Login en Gitea Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -56,15 +201,138 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true logout: true
- name: Construir y subir imagen # push: false — igual que el frontend, la imagen se queda cargada
# en el daemon local para poder escanearla con Trivy antes de
# subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: workloads/commerce-backend/ context: ${{ env.APP_DIR }}/
file: workloads/commerce-backend/Dockerfile file: ${{ env.APP_DIR }}/Dockerfile
push: true push: false
load: true
tags: | tags: |
gitea.cruzcloud.net/devops/ecommerce-medusa:${{ steps.vars.outputs.VERSION }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-medusa:latest ${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
# --timeout 15m0s: el default de Trivy (5m) no alcanza para esta
# imagen -- a diferencia del frontend, commerce-backend arrastra
# el node_modules completo de Medusa v2 (framework + admin-sdk +
# cli), muchos más archivos que escanear tanto para vulnerabilidades
# como para el escaneo de secretos que Trivy corre por default
# dentro de la imagen. Visto en vivo: "context deadline exceeded"
# a los 4m52s con el timeout default, en un run donde el host
# venía de terminar el docker build (ver hallazgo de rendimiento
# de Gitea/runner en docs/playbooks).
# --ignorefile: excepciones puntuales y documentadas (ver
# workloads/commerce-backend/.trivyignore) para CVEs sin fix de
# bajo riesgo disponible hoy. No debilita el gate en general --
# cualquier otra CRITICAL sigue bloqueando igual.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--timeout 15m0s \
--ignorefile "${APP_DIR}/.trivyignore" \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — mismo criterio que el frontend.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- 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
# Mismo par de llaves que el frontend (COSIGN_PRIVATE_KEY /
# COSIGN_PASSWORD ya existen como secrets a nivel de repo, no hace
# falta un secret nuevo por app): una sola identidad de firma para
# todo el registry de este lab. La llave pública se commitea en
# cada directorio de app (workloads/commerce-backend/cosign.pub)
# para que la verificación quede local a cada workflow, igual que
# el frontend.
- 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 }}"
- 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 }}"
- name: Verificar promoción segura - name: Verificar promoción segura
id: promotion id: promotion
@@ -91,8 +359,8 @@ jobs:
set -euo pipefail set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}" VERSION="${{ steps.vars.outputs.VERSION }}"
MANIFEST="workloads/ecommerce/commerce/medusa.yaml" MANIFEST="${{ env.MANIFEST_FILE }}"
IMAGE="gitea.cruzcloud.net/devops/ecommerce-medusa" IMAGE="${{ env.IMAGE_NAME }}"
git config user.name "gitea-actions" git config user.name "gitea-actions"
git config user.email "[email protected]" git config user.email "[email protected]"
@@ -115,3 +383,14 @@ jobs:
-m "chore(gitops): deploy Medusa ${VERSION} [skip ci]" -m "chore(gitops): deploy Medusa ${VERSION} [skip ci]"
git push origin HEAD:main git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
run: |
echo "========================================"
echo "ARI Shopping Commerce Backend (Medusa)"
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
echo "Commit: ${{ github.sha }}"
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
echo "========================================"
+284 -4
View File
@@ -1,6 +1,14 @@
name: Build and Push Frontend name: Build and Push Frontend
# Retrigger real (take 4): los 3 intentos anteriores fueron commits vacíos
# (--allow-empty) que no tocaban ningún path filtrado -- por diseño, nunca
# iban a disparar build.yaml. Este comentario sí cuenta como cambio real
# en .gitea/workflows/build.yaml, que está en la lista de paths.
on: on:
# NOTA: sin anchors/aliases de YAML (&x / *x) a propósito -- el parser
# de workflows de Gitea Actions no los resuelve en el bloque "on:" y
# descarta el archivo completo con "unknown on type" (visto en vivo el
# 2026-08-15). Las dos listas de paths quedan duplicadas literalmente.
push: push:
branches: branches:
- main - main
@@ -29,14 +37,90 @@ on:
- 'workloads/ecommerce/public/**' - 'workloads/ecommerce/public/**'
- 'workloads/ecommerce/styles/**' - 'workloads/ecommerce/styles/**'
- '.gitea/workflows/build.yaml' - '.gitea/workflows/build.yaml'
pull_request:
branches:
- main
paths:
- 'workloads/ecommerce/Dockerfile'
- 'workloads/ecommerce/.dockerignore'
- 'workloads/ecommerce/.npmrc'
- 'workloads/ecommerce/package.json'
- 'workloads/ecommerce/package-lock.json'
- 'workloads/ecommerce/next.config.*'
- 'workloads/ecommerce/tsconfig.json'
- 'workloads/ecommerce/tailwind.config.*'
- 'workloads/ecommerce/postcss.config.*'
- 'workloads/ecommerce/eslint.config.*'
- 'workloads/ecommerce/*.js'
- 'workloads/ecommerce/*.mjs'
- 'workloads/ecommerce/*.ts'
- 'workloads/ecommerce/*.tsx'
- 'workloads/ecommerce/app/**'
- 'workloads/ecommerce/src/**'
- 'workloads/ecommerce/components/**'
- 'workloads/ecommerce/lib/**'
- 'workloads/ecommerce/public/**'
- 'workloads/ecommerce/styles/**'
- '.gitea/workflows/build.yaml'
permissions: permissions:
contents: write contents: write
packages: write packages: write
jobs: 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: build:
name: Construir y Subir Imagen name: Construir y Subir Imagen
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
@@ -180,6 +264,87 @@ jobs:
echo "OK: navegación real del catálogo validada." echo "OK: navegación real del catálogo validada."
- name: Instalar Trivy
shell: bash
run: |
set -euo pipefail
TRIVY_VERSION="0.74.0"
curl -sSfL \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
-o /tmp/trivy.tar.gz
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
chmod +x /tmp/trivy
/tmp/trivy version
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
# el pipeline mientras revisamos juntos qué hallazgos son reales.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
# Instalado directo con pip (no como container aparte): el runner de
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla
# con el daemon Docker del host (sibling containers) -- un
# `docker run -v "${{ github.workspace }}:/src"` desde acá intenta
# montar una ruta que solo existe DENTRO del contenedor del job, no
# en el host real, y falla con "read-only file system". Evitamos por
# completo el problema corriendo Semgrep nativo, igual que
# gitleaks/trivy/syft/cosign.
# venv en vez de "pip3 install --break-system-packages": la imagen del
# runner ya trae paquetes de Python instalados por apt (ej. PyJWT)
# sin metadata compatible con pip, y pip aborta al intentar
# reemplazarlos ("RECORD file not found"). Un venv aislado evita
# tocar los paquetes del sistema por completo.
- name: Instalar Semgrep
shell: bash
run: |
set -euo pipefail
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
/tmp/semgrep-venv/bin/semgrep --version
# 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
/tmp/semgrep-venv/bin/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 ==="
/tmp/semgrep-venv/bin/python -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 - name: Login en Gitea Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -188,14 +353,129 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true logout: true
- name: Construir y Subir Imagen # push: false — la imagen se queda cargada en el daemon local (load:
# true) para poder escanearla con Trivy antes de subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: workloads/ecommerce/ context: workloads/ecommerce/
push: true push: false
load: true
tags: | tags: |
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-frontend:latest ${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
# los reportes y decidimos, con calma, qué reglas deben bloquear.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# 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 # Evita que una ejecución antigua actualice frontend.yaml después de que
# ya exista un commit más reciente en main. # ya exista un commit más reciente en main.
+266 -6
View File
@@ -1,5 +1,9 @@
name: Build and Push Docs Portal name: Build and Push Docs Portal
# Sin anchors/aliases de YAML (&x / *x) a propósito -- el parser de
# workflows de Gitea Actions no los resuelve en el bloque "on:" y
# descarta el archivo completo con "unknown on type". Las dos listas de
# paths quedan duplicadas literalmente (mismo criterio que build.yaml).
on: on:
push: push:
branches: branches:
@@ -10,14 +14,71 @@ on:
- 'workloads/docs-portal/requirements.txt' - 'workloads/docs-portal/requirements.txt'
- 'workloads/docs-portal/Dockerfile' - 'workloads/docs-portal/Dockerfile'
- '.gitea/workflows/deploy-docs.yaml' - '.gitea/workflows/deploy-docs.yaml'
pull_request:
branches:
- main
paths:
- 'workloads/docs-portal/docs/**'
- 'workloads/docs-portal/mkdocs.yml'
- 'workloads/docs-portal/requirements.txt'
- 'workloads/docs-portal/Dockerfile'
- '.gitea/workflows/deploy-docs.yaml'
permissions: permissions:
contents: write contents: write
packages: write packages: write
jobs: jobs:
# Corre en push y en pull_request, siempre antes que build. Mismo
# criterio que el frontend: si hay un secreto commiteado, el job build
# nunca arranca (needs: gitleaks).
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
- name: Escanear secretos en el árbol de archivos
shell: bash
run: |
set -euo pipefail
/tmp/gitleaks detect \
--source=workloads/docs-portal \
--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: build:
name: Construir y publicar Docs Portal name: Construir y publicar Docs Portal
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
@@ -56,6 +117,83 @@ jobs:
exit 1 exit 1
} }
- 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 todo el directorio de la app (Deployment, Service,
# Ingress y Dockerfile), no un único MANIFEST_FILE como el
# frontend -- docs-portal tiene varios manifiestos K8s separados
# (deployment.yaml, service.yaml, ingress.yaml) en vez de uno
# solo, así que un único archivo dejaría fuera la mayoría del
# directorio. Informativo por ahora, mismo criterio que el resto.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${APP_DIR}"
# Mismo motivo que en build.yaml: sibling containers, no
# Docker-in-Docker -- Semgrep corre nativo en un venv.
- name: Instalar Semgrep
shell: bash
run: |
set -euo pipefail
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
/tmp/semgrep-venv/bin/semgrep --version
# Ruleset adaptado: docs-portal no tiene código de aplicación
# propio (es contenido Markdown + configuración de MkDocs), así
# que ni p/typescript ni p/react/p/nextjs del frontend aplican
# acá. Se usa p/python -- el único código ejecutable real en este
# directorio sería un hook/plugin de Python de MkDocs, si algún
# día se agrega uno. Hoy no hay ningún archivo .py en
# workloads/docs-portal, así que 0 hallazgos es el resultado
# esperado, no un falso negativo -- se deja el stage para que
# detecte código nuevo el día que se agregue, sin tener que
# recordar volver a tocar el pipeline. Modo auditoría, igual que
# el resto: no bloquea.
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
shell: bash
run: |
set -euo pipefail
/tmp/semgrep-venv/bin/semgrep scan \
--config=p/python \
--config=p/security-audit \
--json \
--output=semgrep-report.json \
"${APP_DIR}"
echo "=== Resumen Semgrep ==="
/tmp/semgrep-venv/bin/python -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 - name: Login en Gitea Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -64,18 +202,129 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true logout: true
# mkdocs build --strict corre dentro del propio Dockerfile (stage de # mkdocs build --strict corre dentro del propio Dockerfile (stage
# build), así que un nav/link roto rompe este paso antes de publicar. # de build), así que un nav/link roto rompe este paso antes de
- name: Construir y subir imagen # publicar. push: false / load: true -- igual que el frontend, la
# imagen queda cargada localmente para escanearla con Trivy antes
# de subirla.
- name: Construir Imagen
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: workloads/docs-portal/ context: ${{ env.APP_DIR }}/
file: workloads/docs-portal/Dockerfile file: ${{ env.APP_DIR }}/Dockerfile
push: true push: false
load: true
tags: | tags: |
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
# --timeout 15m0s: agregado preventivamente. Se vio en vivo, al
# correr este mismo comando sin el flag en commerce-backend, que
# el default de Trivy (5m) no alcanza en este host bajo carga
# ("context deadline exceeded" a los 4m52s) -- la imagen de
# docs-portal es chica, pero no cuesta nada blindar el mismo
# comando en los tres pipelines.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — mismo criterio que el resto.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- 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
# Mismo par de llaves que el frontend y commerce-backend (secrets
# ya existentes a nivel de repo). Llave pública commiteada en
# workloads/docs-portal/cosign.pub.
- 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 }}"
- 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 }}"
- name: Verificar promoción segura - name: Verificar promoción segura
id: promotion id: promotion
shell: bash shell: bash
@@ -123,3 +372,14 @@ jobs:
-m "chore(gitops): deploy Docs Portal ${VERSION} [skip ci]" -m "chore(gitops): deploy Docs Portal ${VERSION} [skip ci]"
git push origin HEAD:main git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
run: |
echo "========================================"
echo "CruzCloud Lab Docs Portal"
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
echo "Commit: ${{ github.sha }}"
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
echo "========================================"
+34
View File
@@ -0,0 +1,34 @@
# Excepciones documentadas al gate CRITICAL de Trivy (imagen) para
# commerce-backend. Cada entrada requiere justificación y fecha -- no
# es un mecanismo para silenciar hallazgos sin revisar.
#
# CVE-2024-24790 / CVE-2025-68121 (golang stdlib, gobinary):
# Van embebidas en el binario precompilado de esbuild
# (app/node_modules/@esbuild/linux-x64/bin/esbuild), traído
# transitivamente por [email protected], que a su vez lo trae
# @medusajs/admin-sdk para bundlear el panel de admin en build time.
# No es un binario que se ejecute en runtime del contenedor (el CMD
# corre "node .../medusa/cli start", nunca esbuild).
#
# Se intentó el fix real (bump de esbuild a una versión compilada con
# un Go toolchain más nuevo, override en package.json) y rompió el
# build del admin: [email protected] declara "esbuild: ^0.21.3" como
# dependencia directa (no rango amplio), y esbuild >=0.24 cambió el
# manejo de la lista de "target" de transpilación que vite 5 pasa
# internamente -- build.yaml falló con
# "Transforming destructuring... not supported yet" /
# PLUGIN_ERROR en vite:esbuild-transpile. No existe un patch dentro
# de la propia serie 0.21.x (0.21.5 ya es la última) que incluya un
# Go toolchain con estas CVEs corregidas.
#
# Domesticar esto de verdad requiere subir @medusajs/admin-sdk (y por
# lo tanto vite) a una versión que dependa de un esbuild más nuevo --
# fuera de alcance de este pipeline de seguridad, queda como TODO de
# dependencias en un cambio aparte, no bloqueado por CI mientras
# tanto.
#
# Documentado: 2026-08-15. Revisar en cada bump de @medusajs/* por si
# ya arrastra una versión de vite/esbuild más nueva y esta excepción
# deja de ser necesaria.
CVE-2024-24790
CVE-2025-68121
+20
View File
@@ -59,6 +59,26 @@ ENV NODE_ENV=production \
PORT=9000 \ PORT=9000 \
NPM_CONFIG_UPDATE_NOTIFIER=false NPM_CONFIG_UPDATE_NOTIFIER=false
#
# libgnutls30 en la base node:22.18.0-bookworm-slim trae dos CVE
# CRITICAL con fix ya publicado por Debian (CVE-2026-33845,
# CVE-2026-42010) -- detectado por el gate de Trivy imagen del
# pipeline (run 138). Solo se actualiza este paquete puntual, no toda
# la imagen, para minimizar el diff de superficie de la base.
RUN apt-get update \
&& apt-get upgrade -y libgnutls30 \
&& rm -rf /var/lib/apt/lists/*
#
# El CLI global de npm que trae la imagen base node:*-slim no se usa en
# runtime (el CMD invoca a Medusa directo con `node`, nunca `npm`) y
# arrastra su propia copia vendorizada de `tar` con un CVE CRITICAL
# (CVE-2026-59873, detectado por Trivy en
# usr/local/lib/node_modules/npm/node_modules/tar). Se elimina en vez
# de forzar una versión: no es una dependencia real de este proyecto,
# así que no hay nada que "actualizar" -- solo superficie sin uso.
RUN rm -rf /usr/local/lib/node_modules/npm
# #
# Solo lo necesario para ejecutar Medusa # Solo lo necesario para ejecutar Medusa
# #
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----
+8
View File
@@ -16,6 +16,14 @@ RUN mkdocs build --strict
# --- Stage 2: sirve el sitio estático generado con nginx --- # --- Stage 2: sirve el sitio estático generado con nginx ---
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
# libssl3/libcrypto3 de la base nginx:1.27-alpine traen un CVE CRITICAL
# con fix ya publicado por Alpine (CVE-2026-31789, heap buffer overflow
# en OpenSSL) -- detectado por el gate de Trivy imagen del pipeline
# (run 144). Solo se actualizan estos dos paquetes puntuales.
RUN apk update \
&& apk upgrade --no-cache libssl3 libcrypto3 \
&& rm -rf /var/cache/apk/*
COPY --from=build /site/site /usr/share/nginx/html COPY --from=build /site/site /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
- name: gitea-registry-secret - name: gitea-registry-secret
containers: containers:
- name: docs-portal - name: docs-portal
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.0 image: gitea.cruzcloud.net/devops/docs-portal:v1.0.110
ports: ports:
- containerPort: 80 - containerPort: 80
resources: resources:
@@ -0,0 +1,201 @@
# 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<br/>(llave privada, secret)"]
B --> C["cosign verify<br/>(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.
## Limitaciones conocidas y mejoras futuras
Dos limitaciones identificadas al validar el pipeline end-to-end,
documentadas a propósito como TODO — ninguna de las dos se implementó
todavía.
### 1. Se firma por tag, no por digest
Hoy el pipeline firma `ecommerce-frontend:v1.0.104` — un **tag**, no un
**digest** (`sha256:...`). Un tag es una etiqueta mutable: nada impide
que, después de firmada la imagen, alguien (o un bug en el propio
pipeline) vuelva a subir contenido distinto bajo el mismo tag
`v1.0.104`. La firma original seguiría "verificando" — porque Cosign,
al verificar por tag, resuelve el tag al digest que tenga *en ese
momento*, no al que tenía cuando se firmó. Si el tag fue reasignado,
se está verificando una imagen distinta de la que realmente se firmó,
sin que nada avise.
Esto no es hipotético: el propio Cosign lo advierte en cada firma de
este pipeline (visto en los logs reales de cada run):
```text
WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102
uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one.
```
**TODO:** capturar el digest exacto que devuelve `docker push` (o
`docker buildx build --metadata-file`) y firmar/verificar contra ese
digest en vez del tag —
`gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:...` en vez de
`:v1.0.104`. No implementado todavía; requiere ajustar el step de
build para exponer el digest como output y pasarlo a los steps de
firma y verificación.
### 2. El transparency log (Rekor) está deshabilitado
Ya se explicó arriba, en la sección "Por qué `--tlog-upload=false`" de
esta misma página, la decisión consciente de no publicar en el
transparency log para este registry privado. Vale la pena nombrar en
simple qué es lo que se está dejando afuera:
Un **transparency log** (Rekor, el de Sigstore) es un registro
público, append-only, criptográficamente verificable, de "quién firmó
qué imagen y cuándo" — pensalo como un libro contable público que
nadie puede editar ni borrar después de escrito, solo agregar filas
nuevas. Cualquiera puede consultar ese libro para confirmar de forma
independiente (sin confiar en el propio proyecto, ni en la llave
privada, ni en el registry) que una firma específica existió en un
momento específico.
Sin transparency log, la garantía que queda es más débil: *"esta firma
la generó quien tuviera la llave privada en el momento en que se
verificó"* — pero no hay ningún registro externo e inmutable que
demuestre *cuándo* se generó, ni una forma de detectar si alguien con
acceso a la llave privada firmó algo por fuera del pipeline sin que
quede rastro. Para un lab personal con un registry privado, ese
trade-off es razonable (ver la advertencia más abajo en esta misma
página). Pero es una limitación real, no solo un detalle de
configuración — importa especialmente el día que este mismo patrón se
use en un contexto con más de una persona firmando, o con un registry
que deje de ser privado.
**TODO:** si en algún momento el registry deja de ser exclusivamente
privado, o se suma más de una persona con acceso a la llave de firma,
reevaluar habilitar el transparency log público de Sigstore (o correr
uno privado propio) en vez de mantenerlo deshabilitado.
## 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.
@@ -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<br/>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.
@@ -0,0 +1,119 @@
# DevSecOps
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
Actions. Se empezó por un solo repo de referencia — el frontend de ARI
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — y ya
está replicado, con evidencia real de corridas en verde, a los tres
componentes del monorepo:
| App | Workflow | Imagen | Adaptación de Semgrep |
|---|---|---|---|
| Frontend (Next.js) | `build.yaml` | `ecommerce-frontend` | `p/typescript` + `p/react` + `p/nextjs` + `p/security-audit` |
| Backend (Medusa v2, API TypeScript) | `build-medusa.yaml` | `ecommerce-medusa` | `p/typescript` + `p/security-audit` + `p/owasp-top-ten` (sin React/Next.js — es una API, no SSR) |
| Docs Portal (MkDocs, Python) | `deploy-docs.yaml` | `docs-portal` | `p/python` + `p/security-audit` (sin código de aplicación propio hoy) |
Cada app tiene su propia llave pública de Cosign commiteada
(`cosign.pub` en su propio directorio), pero las tres comparten el
mismo par de llaves de firma (mismos secrets `COSIGN_PRIVATE_KEY` /
`COSIGN_PASSWORD` a nivel de repo) — una sola identidad de firma para
todo el registry de este lab.
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
El mismo patrón corre, con el mismo orden de etapas, en los tres
workflows (`build.yaml`, `build-medusa.yaml`, `deploy-docs.yaml`) — lo
que cambia entre ellos es el ruleset de Semgrep y qué manifiesto(s)
escanea Trivy IaC (ver tabla arriba), no la estructura del pipeline.
```mermaid
flowchart TD
subgraph J1["job: gitleaks (push + pull_request)"]
A1[checkout] --> A2["gitleaks detect --no-git"]
end
A2 -- "secreto encontrado" --> XA["❌ pipeline detenido<br/>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<br/>(manifiesto K8s de la app)"]
B1 -.informa.-> B2["Semgrep SAST<br/>(ruleset por stack, audit mode)"]
B2 -.informa.-> B3[login registry]
B3 --> B4["docker build<br/>(push: false, load: true)"]
B4 --> B5["Trivy imagen<br/>CRITICAL"]
B5 -- "CRITICAL con fix" --> XB["❌ detenido<br/>no se sube la imagen"]
B5 -- "sin CRITICAL" --> B6["Trivy imagen<br/>HIGH (informa)"]
B6 --> B7["Syft → SBOM<br/>(CycloneDX + SPDX)"]
B7 --> B8["docker push"]
B8 --> B9["cosign sign"]
B9 --> B10["cosign verify<br/>(smoke test)"]
B10 -- "firma inválida" --> XC["❌ detenido"]
B10 -- "firma válida" --> B11["actualizar manifiesto de la app<br/>(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.
- Firmar por digest en vez de por tag, y reevaluar el transparency log
de Sigstore — ver
[limitaciones conocidas de Cosign](cosign.md#limitaciones-conocidas-y-mejoras-futuras).
- `commerce-backend` tiene dos CVE CRITICAL documentados como excepción
puntual en `workloads/commerce-backend/.trivyignore` (Go stdlib
embebido en el binario de esbuild, sin fix compatible con la versión
de Vite que usa el admin-sdk de Medusa hoy) — revisar en cada bump de
`@medusajs/*` si ya deja de ser necesaria.
- Contención de recursos entre Gitea y su runner bajo carga real —
causa raíz confirmada, fix propuesto y documentado, aplicación
manual pendiente (ver
[playbook de contención de recursos](../playbooks/incidente-contencion-recursos-gitea-runner-2026-08.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<br/>+ artifact JSON"]
B -- "no" --> C
C --> D["✅ pipeline sigue<br/>(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.
@@ -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/[email protected]",
"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ó `[email protected]` — 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.
@@ -0,0 +1,187 @@
# Trivy — vulnerabilidades de imagen e IaC
!!! info "Qué problema resuelve"
Trivy es un escáner de seguridad multipropósito. En este pipeline se usa
para dos cosas **distintas**, con dos comandos distintos:
- `trivy image`: busca CVEs conocidas en los paquetes que terminan
dentro de la imagen Docker final (el sistema operativo base, y las
librerías de Node.js instaladas).
- `trivy config`: busca **misconfiguraciones** en los manifiestos de
Kubernetes (YAML) — no vulnerabilidades de código, sino configuración
insegura (contenedor como root, sin límites de recursos, etc.).
Son preguntas distintas: "¿esta imagen tiene código con bugs de
seguridad conocidos?" contra "¿esta manera de desplegar el contenedor
es insegura, aunque el código adentro esté perfecto?".
## Dónde corre cada uno en el pipeline
```mermaid
flowchart LR
A[checkout] --> B["trivy config<br/>(frontend.yaml)"]
B --> C[docker login]
C --> D["docker build<br/>(push: false, load: true)"]
D --> E["trivy image --severity CRITICAL<br/>--exit-code 1"]
E -- "CRITICAL con fix" --> X["❌ pipeline detenido<br/>no se sube la imagen"]
E -- "sin CRITICAL" --> F["trivy image --severity HIGH<br/>--exit-code 0 (informativo)"]
F --> G["docker push"]
```
El escaneo de imagen corre **después de construir la imagen, antes de
subirla al registry** — por eso el build ahora usa
`push: false, load: true` (la imagen queda en el daemon Docker del runner,
pero no sale de ahí hasta pasar el gate de CRITICAL). El escaneo de
manifiestos (`trivy config`) no depende de la imagen, así que corre antes,
junto a las otras validaciones del código.
## Umbral de severidad (y por qué)
| Severidad | Comportamiento | Por qué |
|---|---|---|
| `CRITICAL` | Bloquea (`--exit-code 1`) | Si existe un fix disponible para una CVE crítica, no tiene sentido publicar la imagen igual |
| `HIGH` | Informa, no bloquea (`--exit-code 0`) | Mientras aprendemos a leer los reportes, HIGH se revisa pero no frena el flujo — bloquear de entrada en HIGH hubiera parado el pipeline en el primer intento real (ver ejemplo abajo) |
Ambos steps usan `--ignore-unfixed`: si Trivy no tiene un `FixedVersion`
para reportar, bloquear o hasta advertir no ayuda en nada — no hay acción
posible más que esperar a que el mantenedor del paquete publique un
parche.
!!! tip "Por qué no escanear todo junto con un solo umbral"
Trivy permite pedir `--severity CRITICAL,HIGH` en una sola corrida,
pero el `--exit-code` se aplica igual a toda la corrida — no se puede
decir "bloqueá en CRITICAL, pero en HIGH solo avisá" en un solo
comando. Por eso son dos steps separados, cada uno con su propio
umbral y su propio `--exit-code`.
## Ejemplo real: un CRITICAL que sí bloqueaba
Antes de integrar este step, construimos la imagen real de
`workloads/ecommerce` y corrimos Trivy contra ella para validar el
pipeline. Encontró esto:
```text
Node.js (node-pkg)
Total: 1 (CRITICAL: 1)
tar (7.5.15 → 7.5.19) CVE-2026-59873 CRITICAL
tar: node-tar: Denial of Service via crafted gzip bomb
```
**El detalle importante no era el CVE en sí, sino dónde vivía:**
`/usr/local/lib/node_modules/npm/node_modules/tar/` — ese `tar` no es una
dependencia de ARI Shopping, es el que trae **empaquetado el propio
`npm`** dentro de la imagen base `node:24.18.0-bookworm-slim`. El
`Dockerfile` original hacía `FROM ${NODE_IMAGE} AS runner` para el stage
final, heredando el Node.js completo — con `npm`, `npx` y `corepack`
incluidos — aunque en producción el contenedor solo ejecuta
`node server.js` y **nunca** invoca `npm`.
!!! danger "No se arregla solo subiendo la versión de Node"
Antes de tocar el Dockerfile probamos si un patch más nuevo de la
imagen base ya traía el `tar` corregido: `node:24.19.0-bookworm-slim`
trae `npm` con `[email protected]` — sigue por debajo del `7.5.19` con el
fix. El problema no es "Node desactualizado", es que el runtime de
producción no necesita `npm` para nada.
**Fix aplicado** (`workloads/ecommerce/Dockerfile`, stage `runner`):
```diff
+ RUN rm -rf \
+ /usr/local/lib/node_modules/npm \
+ /usr/local/lib/node_modules/corepack \
+ /usr/local/bin/npm \
+ /usr/local/bin/npx \
+ /usr/local/bin/corepack
```
Después del fix: **0 CRITICAL**, y de paso las HIGH fixable bajaron de 21
a 15 (varias venían de dependencias de ese mismo `npm` empaquetado, no de
la app). Se validó que la imagen sigue arrancando y respondiendo
`GET /api/health` con `200` después de sacar `npm`.
**Lección:** sacar herramientas que la imagen de producción no necesita
en runtime no es solo "buena práctica" en abstracto — reduce
directamente la superficie que Trivy (y un atacante) tienen para
encontrar algo.
## Las HIGH que quedan (ejemplo real, sin arreglar todavía)
Después del fix, `trivy image --severity HIGH` sigue reportando (de
forma informativa, no bloqueante) CVEs reales en dependencias que sí son
de la app — la mayoría en `next` (15.5.10, con fixes disponibles en
15.5.16+ y 15.5.21+ según el CVE), además de `nanoid`, `postcss` y
`sharp`. Esto queda pendiente de revisar como una actualización de
dependencias normal, no como una emergencia de seguridad — es exactamente
para eso que HIGH no bloquea en esta primera vuelta: da visibilidad sin
frenar el flujo mientras se decide cuándo priorizar el bump.
## Cómo priorizar qué arreglar primero
1. **CRITICAL con fix disponible** — ya bloquea el pipeline, así que en
la práctica no se acumulan.
2. **HIGH en una dependencia que el runtime realmente carga** (como
`next`, que corre en cada request) — más prioridad que una HIGH en
una herramienta de build que ni siquiera llega a la imagen final.
3. **HIGH sin ruta de explotación realista** (ej. una librería que solo
se usa en un script de generación, no en el server) — se puede
posponer con criterio, documentando por qué.
4. **MEDIUM/LOW** — se revisan en lote, no una por una.
La pregunta que más ayuda a priorizar no es "¿qué tan grave dice la
CVSS que es?", sino "¿este paquete corre en el proceso que atiende
tráfico real, o es una herramienta de build que ni siquiera debería estar
en la imagen final?" — el propio ejemplo de arriba (`npm` dentro de la
imagen de producción) es el caso de manual del segundo.
## Escaneo de manifiestos Kubernetes (`trivy config`)
Corre contra `workloads/ecommerce/frontend.yaml` (el `Deployment` +
`Service` real del frontend) con `--severity CRITICAL,HIGH,MEDIUM`, en
modo informativo (`--exit-code 0`) por ahora.
Hallazgos reales de este manifiesto, hoy:
| Severidad | Regla | Qué significa |
|---|---|---|
| HIGH | [KSV-0014](https://avd.aquasec.com/misconfig/ksv-0014) | El filesystem raíz del contenedor no es de solo lectura |
| HIGH | [KSV-0118](https://avd.aquasec.com/misconfig/ksv-0118) | No se define `securityContext` — Kubernetes usa el default, que permite privilegios de root |
| MEDIUM | [KSV-0012](https://avd.aquasec.com/misconfig/ksv-0012) | El contenedor puede correr como root (aunque la imagen ya defina `USER nextjs` en el Dockerfile, Kubernetes no lo está *forzando* vía `runAsNonRoot`) |
| MEDIUM | [KSV-0001](https://avd.aquasec.com/misconfig/ksv-0001) | El contenedor puede escalar sus propios privilegios (falta `allowPrivilegeEscalation: false`) |
| MEDIUM | [KSV-0104](https://avd.aquasec.com/misconfig/ksv-0104) | No hay perfil de Seccomp configurado |
| MEDIUM | [KSV-0117](https://avd.aquasec.com/misconfig/ksv-0117) | El `containerPort: 80` es un puerto privilegiado (<1024) |
| MEDIUM | [KSV-0125](https://avd.aquasec.com/misconfig/ksv-0125) | La imagen viene de un registry que Trivy no reconoce como "de confianza" por defecto (es autoalojado: `gitea.cruzcloud.net`) |
!!! warning "Lo que este scan NO detecta todavía"
El pedido original incluía "falta de resource limits" y "falta de
readiness/liveness probes" como ejemplos de misconfiguración a
buscar. En la práctica, `trivy config` sí tiene reglas para límites
de recursos (`KSV-0011` CPU, `KSV-0018` memoria) pero las clasifica
como **LOW**, por debajo del piso `MEDIUM` que usa este step — y no
tiene ninguna regla propia para probes de liveness/readiness (eso lo
cubren otras herramientas, como `kube-score` o `kube-linter`, que no
forman parte de esta primera integración). Si más adelante se quiere
cubrir ese hueco específico, es una herramienta aparte, no una opción
de configuración de Trivy.
Ninguno de estos hallazgos bloquea el pipeline todavía — son reales, pero
corregirlos (agregar `securityContext`, `resources.limits`, etc. a
`frontend.yaml`) es un cambio de GitOps que conviene revisar con calma,
no como reacción automática a un scan.
## Falsos positivos y excepciones
Cuando un hallazgo de Trivy no aplica (por ejemplo, KSV-0125 marcando el
registry propio como "no confiable" — que es exactamente lo esperado en
un lab self-hosted), se documenta con un archivo `.trivyignore` en la
raíz del repo:
```text
# KSV-0125: gitea.cruzcloud.net es nuestro registry self-hosted,
# no un registry público de terceros. Excepción intencional.
KSV-0125
```
Igual que con Gitleaks, cada línea de `.trivyignore` debe poder
justificarse — no es un lugar para silenciar hallazgos incómodos sin
revisarlos primero.
+19 -6
View File
@@ -44,9 +44,22 @@ que cada una pueda entrar por su propia puerta:
## Estado de este sitio ## Estado de este sitio
Este portal se mantiene vivo junto con el lab — cada página muestra su Este portal se mantiene vivo junto con el lab. La idea es que cada
última fecha de modificación real (`git-revision-date-localized`). Si página muestre su última fecha de modificación real vía
una página dice "TODO", es contenido pendiente de una fase posterior, `git-revision-date-localized` — por ahora ese plugin está deshabilitado
no una promesa incumplida: la estructura completa se construyó primero (ver TODO en `mkdocs.yml`): el build corre con `mkdocs build --strict`,
a propósito, para que el contenido se llene sección por sección con el que aborta ante cualquier `WARNING`, y el plugin no tiene historial git
mismo criterio que el resto del lab. real que leer desde el contexto de build actual, así que se queda fuera
hasta que ese contexto incluya `.git` de verdad. Si una página dice
"TODO", es contenido pendiente de una fase posterior, no una promesa
incumplida: la estructura completa se construyó primero a propósito,
para que el contenido se llene sección por sección con el mismo
criterio que el resto del lab.
Desde este commit, el sitio se construye y publica solo: cada push a
`main` que toca `docs/` o `mkdocs.yml` dispara
`.gitea/workflows/deploy-docs.yaml`, que hace `mkdocs build --strict`,
publica la imagen en el Registry de Gitea y Argo CD sincroniza el
Deployment en el cluster (namespace `docs-portal`). Este párrafo es la
prueba: si lo estás leyendo servido desde el pod real, el pipeline
funcionó de punta a punta.
@@ -81,8 +81,7 @@ estable ~0.370.4s sostenida por 24+ minutos sin un solo 504.
servicios), **no relacionado al fix de protocolo del túnel** — no servicios), **no relacionado al fix de protocolo del túnel** — no
se reabrió como parte de este incidente. se reabrió como parte de este incidente.
**Pendiente:** vigilar si estas ventanas de degradación coinciden **Actualización 2026-08-15:** sí fue recurrente — ver
sistemáticamente con builds/deploys de Gitea Actions. Si es [contención de recursos entre Gitea y su runner](incidente-contencion-recursos-gitea-runner-2026-08.md)
recurrente, considerar mover el runner (`gitea-runner`) a otro host para la causa raíz confirmada (ninguno de los dos contenedores
o limitarle recursos para evitar que compita con el resto de las tiene límites de recursos reales) y la propuesta de fix.
apps del NAS.
@@ -0,0 +1,133 @@
# Incidente: contención de recursos entre Gitea y su runner (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, durante el primer intento real de correr el pipeline de seguridad completo en `commerce-backend` |
| **Servicio afectado** | `gitea` y `gitea-runner` (mismo host ZimaOS) |
| **Impacto** | Un job de CI en curso (build pesado de Medusa + Trivy) fue cancelado a mitad de camino porque ambos contenedores se reiniciaron solos |
## Contexto
Este hallazgo estaba **anotado como pendiente** en el playbook del
[504 del túnel](incidente-504-gitea-tunnel.md): una ventana de latencia
alta y algunos 502/530 habían coincidido, en su momento, con una
ejecución pesada de Gitea Actions — anotado como "contención de
recursos local, no relacionado al fix del túnel, vigilar si es
recurrente". Al triplicar la carga agregando el mismo pipeline de
seguridad a `commerce-backend` y `docs-portal`, se volvió a ver en
vivo, esta vez con evidencia suficiente para confirmar la causa.
## Síntoma
Un run de `build-medusa.yaml` (con Gitleaks ya en verde, build de
Docker en curso) quedó cancelado a mitad de camino. El log del runner
mostró, en la misma ventana de un par de minutos:
```text
level=error msg="failed to fetch task" error="unavailable: 502 Bad Gateway"
level=info msg="runner: ... shutdown initiated, waiting 0s for running jobs to complete before shutting down"
level=warning msg="runner: ... cancelled in progress jobs during shutdown"
level=info msg="Starting runner daemon"
```
El mismo patrón se repitió una segunda vez pocos minutos después. En
paralelo, `docker ps` mostró que el contenedor `gitea` también se
había reiniciado casi al mismo tiempo.
## Diagnóstico
`docker inspect` sobre ambos contenedores en el momento del incidente
mostró:
- `ExitCode=0` y `RestartCount=0` en los dos — **no fue un crash ni un
OOM-kill** (eso dejaría un `ExitCode` distinto de 0 o
`OOMKilled=true`, y el conteo de reinicios de la política de Docker
subiría). El `StartedAt` cambió igual, lo que indica un reinicio
limpio disparado por algo externo al propio proceso — muy
probablemente el supervisor de apps de CasaOS (`zimaos-app-management`),
aunque no se pudo confirmar por logs (`/var/log/casaos/log.log` es
de acceso root-only, sin sudo sin password disponible en esta
sesión).
- Memoria del host en el momento del incidente: **~1.3 GB libres de
15 GB totales**, con ~2 GB de swap en uso — el host estaba bajo
presión real de memoria, no solo de CPU.
- Ningún límite de recursos real en ninguno de los dos contenedores
(ver detalle abajo).
## Causa raíz
Ni `gitea` ni `gitea-runner` tienen aislamiento de recursos real
frente al resto del host:
| | `gitea` | `gitea-runner` |
|---|---|---|
| Límite de memoria | ~15.5 GB (≈ toda la RAM del host, no es un límite real) | **ninguno** |
| Límite de CPU (cuota dura) | ninguno | ninguno |
| Peso relativo de CPU (`cpu-shares`) | **90** (muy por debajo del default de Docker, 1024) | 0 → default Docker (**1024**) |
Con `gitea` en 90 y `gitea-runner` en el default de 1024, bajo
contención de CPU **el runner tiene ~11x más prioridad que el propio
servidor Gitea** — lo opuesto a lo deseable, ya que Gitea es el
servicio que necesita seguir respondiendo peticiones HTTP (incluidas
las del propio runner haciendo polling) mientras un build pesado corre.
Sumado a que `gitea-runner` no tiene techo de memoria: un job pesado
(build de Docker de una imagen con `node_modules` grande + escaneo de
Trivy con vuln + secret scanning) puede consumir memoria sin límite,
empujando al host entero a swap y dejando lento/no-responsivo a todo
lo demás — incluida la propia base de datos SQLite de Gitea (ver
[incidente de SQLite en modo DELETE](incidente-sqlite-modo-delete-2026-08.md),
que probablemente reaparezca con más frecuencia bajo este mismo tipo
de presión, aunque WAL reduce el impacto).
`gitea-runner` tampoco corre gestionado por el mismo `docker-compose.yml`
de Gitea — se crea con un `docker run` suelto desde
`scripts/deploy-lab.sh` (`create_gitea_runner_container()`), montando
`/var/run/docker.sock` directo (sibling containers, ver
[incidente de Docker-in-Docker](incidente-docker-in-docker-semgrep-2026-08.md)).
Esto no es la causa del incidente de recursos, pero significa que
cualquier ajuste de límites tiene que aplicarse en dos lugares
distintos: el `docker-compose.yml` de `gitea` y el script que crea
`gitea-runner`.
!!! info "La concurrencia del runner ya estaba bien"
`capacity: 1` en el `config.yaml` del runner ya limita la
ejecución a **un solo job a la vez** — no es un problema de
"demasiados jobs en paralelo". El problema es que incluso un solo
job pesado, sin ningún límite de recursos, puede acaparar
suficiente CPU/memoria como para dejar sin aire al resto del host.
## Fix propuesto (documentado, aplicación manual pendiente)
1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`,
pendiente de mergear): agrega `--cpu-shares 512 --memory 8g
--memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la
próxima vez que se cree el contenedor (la función es idempotente),
no al que ya está corriendo.
2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar
manualmente por fuera de este repo:
```bash
docker update --cpu-shares 1024 gitea
docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner
```
3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso
root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente
de aplicar manualmente, no se implementó todavía.
4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como
posible mejora de fondo (aislamiento real vía Kubernetes en vez de
límites de Docker sueltos), pero queda **solo como recomendación
documentada** — no implementado en esta vuelta.
## Validación
Con `gitea` y `gitea-runner` estables (sin reinicios) después del
incidente, se reintentó el mismo pipeline y corrió de punta a punta
sin interrupciones — pero eso confirma que el host se estabilizó
después del pico de carga, **no** que el fix de límites de recursos ya
esté aplicado (sigue pendiente, ver arriba). La correlación completa
carga-pesada → reinicio ya se había visto antes, en dos fechas
distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento
aislado, es un patrón recurrente que ahora tiene una causa raíz
concreta y una propuesta de fix.
@@ -0,0 +1,93 @@
# Incidente: el ingress del túnel rompió específicamente a Cosign, no al resto del pipeline (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, primer intento de firmar una imagen con Cosign en `build.yaml` |
| **Servicio afectado** | Cadena Cloudflare Tunnel → NPM (Nginx Proxy Manager) → Gitea, específicamente el endpoint del registry de contenedores |
| **Impacto** | `cosign sign` fallaba siempre, en cada intento — el resto del pipeline (Gitleaks, Trivy, Semgrep, build, push de la imagen) funcionaba normal contra el mismo registry |
## Síntoma
Todos los pasos anteriores del pipeline pasaban, incluido `docker push`
de la imagen al registry de Gitea. El paso de Cosign, inmediatamente
después, fallaba siempre con:
```text
Error: signing [gitea.cruzcloud.net/***/ecommerce-frontend:v1.0.102]:
accessing entity: invalid realm in www-authenticate: realm scheme
"http" not allowed for a secure registry; use https
```
Lo llamativo: **`docker push` a ese mismo registry, un paso antes,
funcionaba sin problema.** Si el registry estuviera mal configurado en
general, se esperaría que fallara también el push, no solo la firma.
## Diagnóstico
El mensaje da la pista exacta: al autenticar contra el registry,
Cosign recibe un header `WWW-Authenticate` cuyo campo `realm` apunta a
una URL con esquema `http://` en vez de `https://`. Cosign rechaza
explícitamente seguir un realm `http` contra lo que él considera "un
registry seguro" (un dominio público, no `localhost`) — es una
protección intencional del lado de Cosign, no un bug.
`docker push`/`docker login` son más permisivos con esto (o cachean la
sesión de otra forma) y no chocan con el mismo problema, por eso solo
Cosign lo mostraba.
## Causa raíz
El realm que arma Gitea en su respuesta `WWW-Authenticate` depende de
que Gitea sepa correctamente que la petición le llegó por HTTPS
(típicamente vía el header `X-Forwarded-Proto` o el Server Name que ve
en la conexión TLS que termina antes de llegar a él). La cadena real
acá es **Cloudflare Tunnel → NPM → Gitea** — si el proxy host de NPM
para `gitea.cruzcloud.net` no tiene el *Origin Server Name* (SNI hacia
el origen) configurado correctamente, Gitea puede terminar creyendo
que la conexión entrante fue por HTTP plano, y arma el realm con ese
esquema equivocado.
## Fix aplicado
Corrección de la regla de ingress de `cloudflared`/NPM para
`gitea.cruzcloud.net`: HTTPS hacia el origen con el *Origin Server
Name* correcto. El fix fue enteramente de infraestructura — no hubo
ningún cambio en este repo más allá de un commit vacío para volver a
disparar el pipeline y confirmar el fix contra credenciales reales.
## La misma causa raíz de fondo, dos síntomas distintos
Este incidente **no es el mismo bug** que el
[504 Gateway Timeout intermitente del túnel](incidente-504-gitea-tunnel.md)
ya documentado — ese fue inestabilidad de QUIC/UDP en `cloudflared`,
resuelto forzando `TUNNEL_TRANSPORT_PROTOCOL=http2`. Pero **ambos
viven en la misma capa**: la cadena de ingress
Cloudflare Tunnel → NPM → Gitea, mal configurada de dos formas
distintas, descubiertas en momentos distintos:
- Una regla de **transporte** (QUIC vs HTTP/2) causando 504s
intermitentes en *cualquier* petición HTTP a los dominios detrás del
túnel.
- Una regla de **SNI/origin server name** causando que Gitea generara
un realm HTTP incorrecto, rompiendo específicamente a un cliente
(Cosign) que valida ese detalle de forma estricta.
La lección compartida: en una cadena de proxies con TLS terminando en
varios saltos, un solo campo de configuración mal puesto en cualquiera
de los saltos puede manifestarse como fallas completamente distintas
según qué tan estricto sea el cliente al otro lado — la mayoría de las
herramientas HTTP normales ni lo notan, pero una herramienta de
seguridad como Cosign, que valida explícitamente el esquema del
realm, sí.
## Validación
Confirmado en una corrida real posterior al fix: `cosign sign` y
`cosign verify` completaron sin error contra el registry real, con
credenciales reales, de punta a punta.
!!! note "Nota relacionada, no parte de este incidente"
El mismo log de este fallo mostró, además del error de realm, el
warning esperado de Cosign sobre firmar por tag en vez de por
digest — ver
[limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras).
@@ -87,7 +87,7 @@ en `medusa.yaml`.
estaban en el **mismo nodo** (`k3d-lab-cluster-server-0`). estaban en el **mismo nodo** (`k3d-lab-cluster-server-0`).
- **Descartada para este incidente puntual** (sin tráfico cross-node - **Descartada para este incidente puntual** (sin tráfico cross-node
involucrado) — pero sigue siendo una condición latente real del involucrado) — pero sigue siendo una condición latente real del
cluster. Ver [nota de deuda técnica](#deuda-técnica-pendiente) abajo. cluster. Ver [nota de deuda técnica](#deuda-tecnica-pendiente) abajo.
## Causa raíz real ## Causa raíz real
@@ -0,0 +1,102 @@
# Incidente: Docker-in-Docker roto en el runner — por qué Semgrep corre nativo (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, primer intento de correr Semgrep como container aparte en `build.yaml` |
| **Servicio afectado** | Gitea Actions runner (`gitea-runner`, imagen `act_runner`) |
| **Impacto** | Cualquier step que intentara `docker run -v "$(pwd):/algo"` desde dentro de un job fallaba con `read-only file system` o rutas inexistentes |
## Docker-in-Docker vs. sibling containers, para quien recién arranca en CI/CD
Cuando un job de CI necesita usar Docker (por ejemplo, para correr una
herramienta empaquetada como imagen), hay dos formas distintas de
dárselo, y confundirlas rompe cosas de maneras difíciles de
diagnosticar:
- **Docker-in-Docker (DinD):** el contenedor del job tiene su **propio**
daemon Docker corriendo adentro, completamente aislado del daemon
del host. Cuando el job hace `docker run`, ese contenedor nuevo nace
*dentro* del contenedor del job, como una muñeca rusa. El
filesystem que ve ese daemon interno es el del contenedor del job,
no el del host real.
- **Sibling containers (contenedores hermanos):** el contenedor del
job **no** tiene su propio daemon — en cambio, monta el socket del
daemon Docker del **host** (`/var/run/docker.sock`) y habla
directamente con él. Cuando el job hace `docker run`, ese contenedor
nuevo nace como **hermano** del contenedor del job (mismo nivel,
mismo host, mismo daemon), no adentro suyo.
La imagen `act_runner` que usa este runner de Gitea Actions usa el
segundo modelo: **sibling containers**, montando el socket del daemon
del host (ver `create_gitea_runner_container()` en
`scripts/deploy-lab.sh`, que monta
`-v /var/run/docker.sock:/var/run/docker.sock`).
## Por qué eso rompe un `docker run -v` ingenuo
Con sibling containers, cuando un step dentro de un job pide
```bash
docker run -v "${{ github.workspace }}:/src" alguna-imagen
```
ese comando lo ejecuta el **daemon del host**, no el contenedor del
job. `${{ github.workspace }}` es una ruta que existe *dentro* del
contenedor del job (por ejemplo `/workspace/devops/apps-registry`) —
pero el daemon del host, al crear el contenedor hermano, intenta
montar esa misma ruta **desde el filesystem del host real**, donde
probablemente no existe (o existe otra cosa completamente distinta ahí).
## Síntoma
Al intentar correr Semgrep como container aparte (`docker run` desde
dentro del step), el error observado en el run real fue:
```text
mkdir /workspace: read-only file system
```
No un "no existe la ruta" limpio — un intento de crear el directorio
que falla porque, en el contexto real del daemon del host, esa ruta
cae en un punto de montaje de solo lectura (o simplemente no es un
lugar donde el daemon del host puede/debe escribir).
## Causa raíz
Sibling containers, no Docker-in-Docker: un `docker run -v
"${{ github.workspace }}:/src"` ejecutado desde dentro de un job
intenta montar, en el daemon del **host**, una ruta que solo tiene
sentido **dentro** del contenedor del job. El daemon del host no ve
esa ruta como el mismo directorio — la ve como una ruta arbitraria de
su propio filesystem.
## Fix aplicado
Se evita el problema de raíz: **Semgrep corre nativo**, instalado
directo en el contenedor del job (mismo criterio ya usado para
Gitleaks, Trivy, Syft y Cosign — todos se instalan como binario/paquete
dentro del job, ninguno corre como container aparte):
```bash
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
```
### Por qué un venv y no `pip3 install` directo
La imagen del runner ya trae paquetes de Python instalados por `apt`
(por ejemplo `PyJWT`) sin metadata compatible con `pip`. Un
`pip3 install --break-system-packages` sobre esa base falla al
intentar reemplazarlos (`RECORD file not found`) porque `pip` no
encuentra el registro de archivos que necesita para saber qué está
reemplazando. Un venv aislado evita tocar los paquetes del sistema por
completo — Semgrep y sus dependencias quedan en su propio directorio,
sin interferir con nada que `apt` ya haya instalado.
## Validación
Validado localmente contra `docker.gitea.com/runner-images:ubuntu-latest`
(la misma imagen que usa el runner real) que `python3`/`pip3` están
disponibles, y confirmado en una corrida real posterior al fix que
Semgrep corre y reporta hallazgos sin ningún error de Docker de por
medio.
@@ -0,0 +1,103 @@
# Incidente: SQLite en modo DELETE causando contención en Gitea (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, coincidiendo con el arranque del runner de Gitea Actions y el polling frecuente de tareas |
| **Servicio afectado** | `gitea` (base de datos SQLite propia, `gitea.db`) |
| **Impacto** | Errores intermitentes `database is locked (SQLITE_BUSY)` al escribir desde distintos componentes de Gitea (runner, web, cron) al mismo tiempo |
## Contexto: por qué Gitea usa SQLite acá
Este lab corre Gitea con `DB_TYPE = sqlite3` (no Postgres/MySQL) — una
sola base de datos, un solo archivo (`gitea.db`), sin servidor de base
de datos aparte. Es la opción correcta para un lab de un solo nodo,
pero SQLite tiene una particularidad importante: por defecto abre el
archivo en **modo DELETE** (el modo "clásico" de journaling).
## Qué es el modo DELETE y por qué molesta acá
En modo DELETE, cada transacción de escritura:
1. Crea un archivo de journal temporal (`gitea.db-journal`).
2. Toma un **lock exclusivo sobre el archivo completo** de la base de
datos mientras dura la escritura.
3. Borra el journal al terminar.
Ese lock exclusivo es el problema: **mientras una escritura está en
curso, nada más puede leer ni escribir** — ni siquiera otro proceso
que solo quiere leer una fila que no tiene nada que ver con la que se
está escribiendo.
!!! info "Por qué esto pega justo en Gitea Actions"
El runner de Gitea Actions hace *polling* constante contra la API
de Gitea (cada pocos segundos, según `fetch_interval` en su
`config.yaml`) para preguntar "¿hay una tarea nueva?", y además
escribe actualizaciones de estado de cada step casi en tiempo real
mientras un job corre. Sumale los cron jobs internos de Gitea y el
tráfico normal de la web UI. Con la base en modo DELETE, todo eso
compite por el mismo lock exclusivo — cuantas más cosas escriben
seguido, más chance de pisarse.
## Síntoma
`SQLITE_BUSY` intermitente en los logs de Gitea, típicamente en
operaciones de corta duración (actualizar el estado de un step,
registrar un heartbeat del runner) que no deberían tener motivo para
fallar por contención:
```text
[E] can't update runner status: database is locked (5) (SQLITE_BUSY)
```
## Causa raíz
Modo de journaling `DELETE` (el default de SQLite si no se configura
nada distinto) combinado con el patrón de acceso de Gitea Actions:
muchas escrituras cortas y frecuentes desde procesos distintos
(runner, servidor web, cron), cada una compitiendo por un lock
exclusivo de archivo completo.
## Fix aplicado
Se cambió el modo de journaling a **WAL** (Write-Ahead Logging) en
`app.ini`:
```ini
[database]
; journal_mode por defecto (DELETE) toma un lock exclusivo del archivo
; completo en cada escritura. WAL permite lectores concurrentes mientras
; hay una escritura en curso -- fix para "database is locked" bajo el
; polling frecuente de Actions + cron jobs (diagnosticado 2026-08-15).
SQLITE_JOURNAL_MODE = WAL
```
### Por qué WAL resuelve esto (y qué NO resuelve)
En modo WAL, las escrituras no se aplican directo al archivo principal
de la base: se van agregando a un archivo aparte (`gitea.db-wal`), y
los lectores siguen leyendo del estado consistente más reciente sin
bloquearse. La regla cambia de *"una escritura bloquea todo"* a
*"una escritura bloquea solo a otra escritura"* — los lectores dejan
de competir por el lock.
!!! warning "WAL reduce la contención, no la elimina"
WAL sigue permitiendo **una sola escritura a la vez** — dos
escrituras concurrentes todavía pueden chocar y devolver
`SQLITE_BUSY` si el proceso no reintenta. En una corrida de este
mismo playbook se vio, después de aplicado el fix, una única
ocurrencia aislada de `database is locked` coincidiendo con un
reinicio del contenedor de `gitea` (ver
[contención de recursos de Gitea/runner](incidente-contencion-recursos-gitea-runner-2026-08.md)) —
consistente con una escritura en curso justo en el momento del
reinicio, no con que WAL no esté funcionando. Frecuencia bajó de
"molesta con cierta regularidad" a "un caso aislado en varias
horas de uso intensivo".
## Validación
El cambio se aplicó directo en el `app.ini` montado como volumen del
contenedor de `gitea` (no en un `docker exec` puntual) — persiste
across reinicios del contenedor, confirmado leyendo el archivo montado
después de un reinicio real del contenedor (ver
[contención de recursos](incidente-contencion-recursos-gitea-runner-2026-08.md)),
no solo aplicado en caliente y perdido al reciclar el contenedor.
@@ -0,0 +1,122 @@
# Incidente: anchors YAML no soportados por Gitea Actions — y un diagnóstico equivocado en el camino (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, desde el primer commit que agregó Gitleaks a `build.yaml` |
| **Servicio afectado** | Gitea Actions — el workflow `build.yaml` (frontend) |
| **Impacto** | `build.yaml` no generaba **ningún** `action_run`, ni en `push` ni en `pull_request` — el pipeline entero era invisible para Gitea, sin ningún error visible en la UI |
## Qué es un anchor/alias YAML, para quien no lo conoce
YAML tiene una forma de evitar repetir el mismo bloque dos veces:
```yaml
paths: &frontend_paths
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
on:
push:
paths: *frontend_paths
pull_request:
paths: *frontend_paths
```
`&frontend_paths` es un **anchor** (marca ese nodo con un nombre).
`*frontend_paths` es un **alias** (dice "poné acá una copia de lo que
marcó ese anchor"). Es YAML 100% estándar — cualquier parser que siga
la especificación lo resuelve sin problema, y es una forma común de no
duplicar listas idénticas en el mismo archivo.
## Síntoma
`build.yaml` (con Gitleaks recién agregado, usando anchors para no
repetir la lista de `paths` entre `push:` y `pull_request:`) no
generaba ningún run en Gitea Actions. Ni en el push directo a la rama
del PR, ni al abrir el pull request. Sin mensaje de error visible en
la UI de Gitea — el workflow simplemente no aparecía en la lista de
Actions, como si no existiera.
## Primer diagnóstico (equivocado)
La primera sospecha fue una indentación rota específicamente en el
step de Semgrep: el bloque `python3 -c "..."` embebido dentro de un
`run: |` tenía código pegado a columna 0, por debajo de la
indentación esperada del block scalar de YAML. Eso ** era un
problema real — cortaba el block scalar ahí mismo y en teoría podía
hacer que Gitea rechazara el archivo con un error de parseo genérico
(`could not find expected ':'`).
Se corrigió la indentación, se validó con un parser YAML real y
`bash -n` sobre los 20 steps del archivo, se mergeó a `main` como fix
(`fix(ci): corregir indentación YAML rota en el step de Semgrep`, PR
#19)... y `build.yaml` **siguió sin generar runs**.
!!! warning "Por qué vale la pena contar el diagnóstico que no era"
El primer fix no estaba mal — la indentación efectivamente estaba
rota y corregirla era necesario. El error fue asumir que esa era
*la única* causa sin confirmarlo con una corrida real después del
fix. La lección de proceso: un fix que "tiene sentido" y que pasa
la validación local (parser YAML, `bash -n`) todavía necesita una
confirmación en vivo contra el sistema real antes de darlo por
cerrado — sobre todo cuando el síntoma es "no pasa nada" en vez de
un error explícito, que es exactamente el tipo de fallo más fácil
de dar por resuelto sin verificar.
## Causa raíz real
`deploy-docs.yaml` (sin anchors) corría normal. `build.yaml` (con
anchors, agregados junto con Gitleaks) no generaba ningún run — ni
en `push` ni en `pull_request`, desde el primer commit que los
introdujo. El log del propio Gitea lo confirmó:
```text
unknown on type: &yaml.Node{...Value:"frontend_paths"...}
```
El parser propio de Gitea Actions para el bloque `on:` de un workflow
**no resuelve `&anchor`/`*alias` antes de inspeccionar el tipo de
nodo** — encuentra un anchor donde esperaba un valor ya resuelto, no
sabe qué tipo de dato es, y descarta el archivo completo en
silencio. `pyyaml` y cualquier parser YAML estándar sí resuelven el
alias primero (por eso el archivo "parseaba bien" en cualquier
herramienta de validación genérica) — es una limitación específica del
parser de workflows de Gitea Actions, no un YAML inválido.
Esto explica por qué el síntoma no tenía ningún error visible: no es
que el job fallara, es que **Gitea nunca llegaba a registrar el
workflow como existente**.
## Fix aplicado
Se eliminaron los anchors/alias. Las dos listas de `paths` (una para
`push:`, otra para `pull_request:`) quedan **duplicadas
literalmente**, sin referencia compartida:
```yaml
on:
push:
branches: [main]
paths:
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
pull_request:
branches: [main]
paths:
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
```
Es más repetitivo, pero es el precio de que el parser de Gitea
Actions lo entienda. Este mismo criterio (sin anchors, paths
duplicados) se replicó a propósito en `build-medusa.yaml` y
`deploy-docs.yaml` al agregarles el resto del pipeline de seguridad,
para no repetir el mismo problema.
## Validación
Confirmado en vivo: tras mergear el fix, `build.yaml` generó su
primer `action_run` real. Validado también con un parser YAML +
`bash -n` sobre los 20 steps del archivo, igual que en el intento
anterior — la diferencia esta vez fue confirmarlo contra una corrida
real antes de cerrar el incidente.
+24 -4
View File
@@ -43,10 +43,18 @@ theme:
plugins: plugins:
- search: - search:
lang: es lang: es
- git-revision-date-localized: # TODO (fase 2): git-revision-date-localized deshabilitado a propósito.
enable_creation_date: true # El Dockerfile construye con context: workloads/docs-portal/, que no
type: date # incluye .git (vive en la raíz del repo) — el plugin no tiene historial
fallback_to_build_date: true # real que leer y cae siempre en fallback_to_build_date, emitiendo un
# WARNING por página. mkdocs build --strict aborta ante CUALQUIER
# WARNING, así que mientras no se le dé contexto de build con .git real
# (mover el build a la raíz del repo + fetch-depth:0 en el workflow),
# este plugin debe quedar fuera para no romper el pipeline.
# - git-revision-date-localized:
# enable_creation_date: true
# type: date
# fallback_to_build_date: true
markdown_extensions: markdown_extensions:
- admonition - admonition
@@ -85,11 +93,23 @@ nav:
- 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md - 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md
- Precios Medusa: playbooks/incidente-precios-medusa.md - Precios Medusa: playbooks/incidente-precios-medusa.md
- Imágenes NPM: playbooks/incidente-imagenes-npm.md - Imágenes NPM: playbooks/incidente-imagenes-npm.md
- SQLite modo DELETE: playbooks/incidente-sqlite-modo-delete-2026-08.md
- Anchors YAML en Gitea Actions: playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
- Docker-in-Docker y Semgrep: playbooks/incidente-docker-in-docker-semgrep-2026-08.md
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Aprendizajes: - Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md - Notas sueltas: aprendizajes/notas-sueltas.md
- Guía del estudiante: - Guía del estudiante:
- Inicio: guia-estudiante/README.md - Inicio: guia-estudiante/README.md
- Conceptos básicos: guia-estudiante/conceptos-basicos.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: extra:
social: social:
+1 -1
View File
@@ -1,3 +1,3 @@
mkdocs==1.6.* mkdocs==1.6.*
mkdocs-material==9.5.* mkdocs-material==9.5.*
mkdocs-git-revision-date-localized-plugin==1.2.* # mkdocs-git-revision-date-localized-plugin==1.2.* # deshabilitado, ver TODO en mkdocs.yml
+12
View File
@@ -305,6 +305,18 @@ RUN apt-get update \
'cap_net_bind_service=+ep' \ 'cap_net_bind_service=+ep' \
/usr/local/bin/node /usr/local/bin/node
# El runtime solo ejecuta "node server.js" (standalone output de Next.js);
# nunca invoca npm/npx/corepack. Sacarlos del stage final reduce el árbol
# de dependencias escaneado por Trivy a lo que realmente corre en
# producción, en vez de arrastrar el npm completo de la imagen base
# (con sus propias deps y CVEs, ej. CVE-2026-59873 en node-tar).
RUN rm -rf \
/usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm \
/usr/local/bin/npx \
/usr/local/bin/corepack
COPY --from=builder \ COPY --from=builder \
--chown=nextjs:nodejs \ --chown=nextjs:nodejs \
/app/public \ /app/public \
+2 -2
View File
@@ -52,7 +52,7 @@ spec:
memory: 64Mi memory: 64Mi
- name: migrations - name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- npx - npx
@@ -73,7 +73,7 @@ spec:
containers: containers:
- name: medusa - name: medusa
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
envFrom: envFrom:
- configMapRef: - configMapRef:
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
- name: gitea-registry-secret - name: gitea-registry-secret
containers: containers:
- name: web - name: web
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.104
ports: ports:
- containerPort: 80 - containerPort: 80
env: env: