From 1bcae76102e84b5c247a5c9101bc8116dc6fcd55 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 15 Aug 2026 12:47:43 -0500 Subject: [PATCH] feat(devsecops): replicar pipeline de seguridad a docs-portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/deploy-docs.yaml | 264 +++++++++++++++++++++++++++++- workloads/docs-portal/cosign.pub | 4 + 2 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 workloads/docs-portal/cosign.pub diff --git a/.gitea/workflows/deploy-docs.yaml b/.gitea/workflows/deploy-docs.yaml index 03e8b72..31a3b4b 100644 --- a/.gitea/workflows/deploy-docs.yaml +++ b/.gitea/workflows/deploy-docs.yaml @@ -1,5 +1,9 @@ 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: push: branches: @@ -10,14 +14,71 @@ on: - 'workloads/docs-portal/requirements.txt' - 'workloads/docs-portal/Dockerfile' - '.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: contents: write packages: write 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: name: Construir y publicar Docs Portal + needs: gitleaks + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 20 @@ -56,6 +117,83 @@ jobs: 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 uses: docker/login-action@v2 with: @@ -64,18 +202,121 @@ jobs: password: ${{ secrets.REGISTRY_PASSWORD }} logout: true - # mkdocs build --strict corre dentro del propio Dockerfile (stage de - # build), así que un nav/link roto rompe este paso antes de publicar. - - name: Construir y subir imagen + # mkdocs build --strict corre dentro del propio Dockerfile (stage + # de build), así que un nav/link roto rompe este paso antes de + # 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 with: - context: workloads/docs-portal/ - file: workloads/docs-portal/Dockerfile - push: true + context: ${{ env.APP_DIR }}/ + file: ${{ env.APP_DIR }}/Dockerfile + push: false + load: true tags: | ${{ 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 — 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 \ + "${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 id: promotion shell: bash @@ -123,3 +364,14 @@ jobs: -m "chore(gitops): deploy Docs Portal ${VERSION} [skip ci]" 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 "========================================" diff --git a/workloads/docs-portal/cosign.pub b/workloads/docs-portal/cosign.pub new file mode 100644 index 0000000..1b29af6 --- /dev/null +++ b/workloads/docs-portal/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+ +VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw== +-----END PUBLIC KEY-----