Compare commits

..
Author SHA1 Message Date
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 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
15 changed files with 1124 additions and 25 deletions
+280 -4
View File
@@ -1,6 +1,10 @@
name: Build and Push Frontend
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:
branches:
- main
@@ -29,14 +33,90 @@ on:
- 'workloads/ecommerce/public/**'
- 'workloads/ecommerce/styles/**'
- '.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:
contents: write
packages: write
jobs:
# Corre en push y en pull_request, siempre antes que build. Si encuentra
# un secreto commiteado, el step termina con exit code distinto de 0 y,
# por el "needs" del job build, la imagen nunca se construye ni se sube.
gitleaks:
name: Escaneo de Secretos (Gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout del código
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Instalar Gitleaks
shell: bash
run: |
set -euo pipefail
GITLEAKS_VERSION="8.21.2"
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
chmod +x /tmp/gitleaks
/tmp/gitleaks version
# --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1,
# sin historial), no el log de commits. El scan histórico completo del
# repo se corre aparte, manualmente, no en cada push/PR.
- name: Escanear secretos en el árbol de archivos
shell: bash
run: |
set -euo pipefail
/tmp/gitleaks detect \
--source=workloads/ecommerce \
--no-git \
--redact \
--report-format=json \
--report-path=gitleaks-report.json \
--exit-code=1
- name: Publicar reporte de Gitleaks
if: always()
uses: actions/upload-artifact@v3
with:
name: gitleaks-report
path: gitleaks-report.json
if-no-files-found: ignore
build:
name: Construir y Subir Imagen
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -180,6 +260,87 @@ jobs:
echo "OK: navegación real del catálogo validada."
- name: Instalar Trivy
shell: bash
run: |
set -euo pipefail
TRIVY_VERSION="0.74.0"
curl -sSfL \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
-o /tmp/trivy.tar.gz
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
chmod +x /tmp/trivy
/tmp/trivy version
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
# el pipeline mientras revisamos juntos qué hallazgos son reales.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
# 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
uses: docker/login-action@v2
with:
@@ -188,14 +349,129 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
- name: Construir y Subir Imagen
# push: false — la imagen se queda cargada en el daemon local (load:
# true) para poder escanearla con Trivy antes de subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4
with:
context: workloads/ecommerce/
push: true
push: false
load: true
tags: |
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
# los reportes y decidimos, con calma, qué reglas deben bloquear.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# SBOM de la imagen que ya pasó el gate de CRITICAL — describe
# exactamente qué paquetes (y en qué versión) quedaron adentro.
# CycloneDX: formato más usado por herramientas de consulta/alertas
# de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell).
- name: Instalar Syft
shell: bash
run: |
set -euo pipefail
SYFT_VERSION="1.51.0"
curl -sSfL \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
-o /tmp/syft.tar.gz
tar -xzf /tmp/syft.tar.gz -C /tmp syft
chmod +x /tmp/syft
/tmp/syft version
- name: Generar SBOM (Syft)
shell: bash
run: |
set -euo pipefail
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json
- name: Publicar SBOM
if: always()
uses: actions/upload-artifact@v3
with:
name: sbom-${{ steps.vars.outputs.VERSION }}
path: |
sbom.cdx.json
sbom.spdx.json
if-no-files-found: ignore
# Login ya se hizo arriba; recién acá se sube, después de que la
# imagen pasó el gate de CRITICAL.
- name: Subir Imagen al Registry
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
docker push "${IMAGE_NAME}:latest"
- name: Instalar Cosign
shell: bash
run: |
set -euo pipefail
COSIGN_VERSION="3.1.3"
curl -sSfL \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
-o /tmp/cosign
chmod +x /tmp/cosign
/tmp/cosign version
# Firma solo la versión (no :latest, que es un tag mutable y firmarlo
# pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
# --tlog-upload=false: firma solo con el par de llaves propio, sin
# publicar metadata en el transparency log público de Sigstore — este
# registry es privado, no tiene sentido anunciar públicamente qué se
# firmó y cuándo.
- name: Firmar Imagen (Cosign)
shell: bash
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
/tmp/cosign sign \
--key env://COSIGN_PRIVATE_KEY \
--use-signing-config=false \
--tlog-upload=false \
--yes \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# Smoke test: confirma en el propio pipeline que la firma que se
# acaba de crear valida contra la llave pública commiteada en el
# repo (workloads/ecommerce/cosign.pub). Es la misma verificación
# que, más adelante, podría correr un admission controller en el
# cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md).
- name: Verificar Firma (smoke test)
shell: bash
run: |
set -euo pipefail
/tmp/cosign verify \
--key "${APP_DIR}/cosign.pub" \
--insecure-ignore-tlog=true \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# Evita que una ejecución antigua actualice frontend.yaml después de que
# ya exista un commit más reciente en main.
-8
View File
@@ -3,14 +3,6 @@ FROM python:3.12-slim AS build
WORKDIR /site
# mkdocs-git-revision-date-localized-plugin depende de GitPython, que
# necesita el binario git presente solo para poder importarse (si falta,
# el import truena y rompe el build --strict, incluso con
# fallback_to_build_date activado en mkdocs.yml).
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
- name: gitea-registry-secret
containers:
- name: docs-portal
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.0
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.98
ports:
- containerPort: 80
resources:
@@ -0,0 +1,133 @@
# Cosign — firma de imágenes
!!! info "Qué problema resuelve"
Todo lo anterior en esta sección (Gitleaks, Trivy, Semgrep, SBOM)
responde a la pregunta *"¿esta imagen es segura de construir?"*.
Cosign responde una pregunta distinta, y que pasa **después**: *"la
imagen que está corriendo ahora mismo en el cluster, ¿es
exactamente la que armó el pipeline — o pudo haber sido reemplazada,
modificada, o subida por otra vía?"*.
## Analogía simple
Pensalo como el sello de cera en un sobre antiguo. Cualquiera puede leer
la carta (la imagen es pública, cualquiera puede bajarla del registry) —
eso Cosign no lo esconde. Lo que el sello garantiza es otra cosa: que la
carta salió exactamente de donde dice que salió, y que nadie la abrió y
volvió a cerrar en el camino.
- **La llave privada** (guardada como secret de Gitea, nunca en el repo)
es el sello físico — solo el pipeline de CI puede estampar una firma
válida, porque solo él tiene el sello.
- **La llave pública** (`workloads/ecommerce/cosign.pub`, commiteada sin
problema — es pública a propósito) es la forma de reconocer el sello:
cualquiera puede mirar la carta, ver el sello, y confirmar "sí, esto lo
selló quien tiene la llave privada" — sin necesitar la llave privada
para verificarlo.
Si alguien sube una imagen distinta con el mismo tag, o modifica un solo
byte de la imagen original, la firma deja de coincidir. No es que Cosign
"detecte" la alteración activamente — es que la verificación
simplemente falla, porque la firma fue calculada sobre el digest exacto
de la imagen original.
## Cómo funciona en este pipeline
```mermaid
flowchart LR
A["docker push"] --> B["cosign sign<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.
## 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,91 @@
# DevSecOps
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
Actions, empezando por un solo repo de referencia — el frontend de ARI
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — antes
de replicarlo a `commerce-backend` y `docs-portal`.
Cinco herramientas, cada una respondiendo una pregunta distinta:
| Herramienta | Pregunta que responde | Modo |
|---|---|---|
| [Gitleaks](gitleaks.md) | ¿Hay un secreto commiteado? | **Bloquea** |
| [Trivy — imagen](trivy.md) | ¿La imagen tiene una CVE conocida? | CRITICAL **bloquea**, HIGH informa |
| [Trivy — IaC](trivy.md) | ¿El manifiesto de Kubernetes es inseguro? | Informa |
| [Semgrep (SAST)](sast.md) | ¿El código tiene un patrón inseguro conocido? | Informa (auditoría) |
| [Syft (SBOM)](sbom.md) | ¿Qué paquetes exactos quedaron en la imagen? | Informa (inventario) |
| [Cosign](cosign.md) | ¿Esta imagen es exactamente la que armó el pipeline? | **Bloquea** (smoke test) |
## El pipeline completo
```mermaid
flowchart TD
subgraph J1["job: gitleaks (push + pull_request)"]
A1[checkout] --> A2["gitleaks detect --no-git"]
end
A2 -- "secreto encontrado" --> XA["❌ pipeline detenido<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/>(frontend.yaml)"]
B1 -.informa.-> B2["Semgrep SAST<br/>(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 frontend.yaml<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.
- Replicar este mismo patrón a `commerce-backend` (Medusa) y
`docs-portal`, adaptando lo que corresponda a cada stack.
@@ -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
Este portal se mantiene vivo junto con el lab — cada página muestra su
última fecha de modificación real (`git-revision-date-localized`). 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.
Este portal se mantiene vivo junto con el lab. La idea es que cada
página muestre su última fecha de modificación real vía
`git-revision-date-localized` — por ahora ese plugin está deshabilitado
(ver TODO en `mkdocs.yml`): el build corre con `mkdocs build --strict`,
que aborta ante cualquier `WARNING`, y el plugin no tiene historial git
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.
@@ -87,7 +87,7 @@ en `medusa.yaml`.
estaban en el **mismo nodo** (`k3d-lab-cluster-server-0`).
- **Descartada para este incidente puntual** (sin tráfico cross-node
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
+19 -4
View File
@@ -43,10 +43,18 @@ theme:
plugins:
- search:
lang: es
- git-revision-date-localized:
enable_creation_date: true
type: date
fallback_to_build_date: true
# TODO (fase 2): git-revision-date-localized deshabilitado a propósito.
# El Dockerfile construye con context: workloads/docs-portal/, que no
# incluye .git (vive en la raíz del repo) — el plugin no tiene historial
# 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:
- admonition
@@ -90,6 +98,13 @@ nav:
- Guía del estudiante:
- Inicio: guia-estudiante/README.md
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
- DevSecOps:
- Resumen: devsecops/index.md
- Gitleaks (secretos): devsecops/gitleaks.md
- Trivy (imagen + IaC): devsecops/trivy.md
- SAST (Semgrep): devsecops/sast.md
- SBOM (Syft): devsecops/sbom.md
- Cosign (firma de imágenes): devsecops/cosign.md
extra:
social:
+1 -1
View File
@@ -1,3 +1,3 @@
mkdocs==1.6.*
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' \
/usr/local/bin/node
# El runtime solo ejecuta "node server.js" (standalone output de Next.js);
# nunca invoca npm/npx/corepack. Sacarlos del stage final reduce el árbol
# de dependencias escaneado por Trivy a lo que realmente corre en
# producción, en vez de arrastrar el npm completo de la imagen base
# (con sus propias deps y CVEs, ej. CVE-2026-59873 en node-tar).
RUN rm -rf \
/usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm \
/usr/local/bin/npx \
/usr/local/bin/corepack
COPY --from=builder \
--chown=nextjs:nodejs \
/app/public \
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----