diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 568dc08..12bc140 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -392,6 +392,51 @@ for r in results: docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" docker push "${IMAGE_NAME}:latest" + - name: Instalar Cosign + shell: bash + run: | + set -euo pipefail + COSIGN_VERSION="3.1.3" + curl -sSfL \ + "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \ + -o /tmp/cosign + chmod +x /tmp/cosign + /tmp/cosign version + + # Firma solo la versión (no :latest, que es un tag mutable y firmarlo + # pierde sentido en cuanto se vuelve a mover). --use-signing-config=false + # --tlog-upload=false: firma solo con el par de llaves propio, sin + # publicar metadata en el transparency log público de Sigstore — este + # registry es privado, no tiene sentido anunciar públicamente qué se + # firmó y cuándo. + - name: Firmar Imagen (Cosign) + shell: bash + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + /tmp/cosign sign \ + --key env://COSIGN_PRIVATE_KEY \ + --use-signing-config=false \ + --tlog-upload=false \ + --yes \ + "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" + + # Smoke test: confirma en el propio pipeline que la firma que se + # acaba de crear valida contra la llave pública commiteada en el + # repo (workloads/ecommerce/cosign.pub). Es la misma verificación + # que, más adelante, podría correr un admission controller en el + # cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md). + - name: Verificar Firma (smoke test) + shell: bash + run: | + set -euo pipefail + /tmp/cosign verify \ + --key "${APP_DIR}/cosign.pub" \ + --insecure-ignore-tlog=true \ + "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" + # Evita que una ejecución antigua actualice frontend.yaml después de que # ya exista un commit más reciente en main. - name: Verificar promoción segura diff --git a/workloads/docs-portal/docs/devsecops/cosign.md b/workloads/docs-portal/docs/devsecops/cosign.md new file mode 100644 index 0000000..fa97d2d --- /dev/null +++ b/workloads/docs-portal/docs/devsecops/cosign.md @@ -0,0 +1,133 @@ +# Cosign — firma de imágenes + +!!! info "Qué problema resuelve" + Todo lo anterior en esta sección (Gitleaks, Trivy, Semgrep, SBOM) + responde a la pregunta *"¿esta imagen es segura de construir?"*. + Cosign responde una pregunta distinta, y que pasa **después**: *"la + imagen que está corriendo ahora mismo en el cluster, ¿es + exactamente la que armó el pipeline — o pudo haber sido reemplazada, + modificada, o subida por otra vía?"*. + +## Analogía simple + +Pensalo como el sello de cera en un sobre antiguo. Cualquiera puede leer +la carta (la imagen es pública, cualquiera puede bajarla del registry) — +eso Cosign no lo esconde. Lo que el sello garantiza es otra cosa: que la +carta salió exactamente de donde dice que salió, y que nadie la abrió y +volvió a cerrar en el camino. + +- **La llave privada** (guardada como secret de Gitea, nunca en el repo) + es el sello físico — solo el pipeline de CI puede estampar una firma + válida, porque solo él tiene el sello. +- **La llave pública** (`workloads/ecommerce/cosign.pub`, commiteada sin + problema — es pública a propósito) es la forma de reconocer el sello: + cualquiera puede mirar la carta, ver el sello, y confirmar "sí, esto lo + selló quien tiene la llave privada" — sin necesitar la llave privada + para verificarlo. + +Si alguien sube una imagen distinta con el mismo tag, o modifica un solo +byte de la imagen original, la firma deja de coincidir. No es que Cosign +"detecte" la alteración activamente — es que la verificación +simplemente falla, porque la firma fue calculada sobre el digest exacto +de la imagen original. + +## Cómo funciona en este pipeline + +```mermaid +flowchart LR + A["docker push"] --> B["cosign sign
(llave privada, secret)"] + B --> C["cosign verify
(llave pública, repo)"] + C -- "firma válida" --> D["✅ pipeline termina OK"] + C -- "firma inválida/ausente" --> X["❌ pipeline falla"] +``` + +1. **Par de llaves**: generado una vez con `cosign generate-key-pair`, + protegido por password. La privada (`cosign.key`) se subió como + secret de Gitea Actions (`COSIGN_PRIVATE_KEY` + `COSIGN_PASSWORD`) — + nunca se commiteó al repo, ni existe en el disco de este equipo + después de subirla. La pública (`cosign.pub`) sí vive commiteada en + `workloads/ecommerce/cosign.pub`, porque su función es poder + compartirse. +2. **Firma**: después de subir la imagen al registry, el pipeline la + firma con la llave privada (leída desde el secret vía + `--key env://COSIGN_PRIVATE_KEY`, sin escribirla nunca a disco). +3. **Verificación (smoke test)**: en el mismo pipeline, inmediatamente + después, se verifica la firma recién creada contra la llave pública + del repo. Si algo salió mal (llave incorrecta, imagen corrupta), el + pipeline falla ahí mismo — antes de que nadie más intente confiar en + esa imagen. + +## Por qué `--tlog-upload=false` + +Cosign, por defecto, publica cada firma en el *transparency log* público +de Sigstore (Rekor) — un registro público, auditable, de "quién firmó +qué y cuándo", pensado para proyectos open source donde esa +transparencia es el punto. Este registry (`gitea.cruzcloud.net`) es +privado; no tiene sentido — y sería una fuga de metadata innecesaria — +anunciar públicamente que este lab construyó una imagen `v1.0.97` en tal +fecha. Por eso el pipeline firma solo con el par de llaves propio, +localmente, sin tocar el transparency log público +(`--use-signing-config=false --tlog-upload=false` al firmar, +`--insecure-ignore-tlog=true` al verificar). + +!!! warning "Trade-off consciente, no gratis" + Sin transparency log, la garantía es "esta firma la generó quien + tiene la llave privada" — pero no hay un registro público e + inmutable de *cuándo* se generó cada firma. Para un registry privado + de un lab personal, ese trade-off tiene sentido. Para un proyecto + open source con más de una persona firmando, seguramente no. + +## Qué NO se firma + +Solo se firma el tag versionado (`ecommerce-frontend:v1.0.X`), no +`:latest`. `:latest` es un tag mutable — se re-apunta a una imagen +distinta en cada build — así que firmarlo no significa nada útil: la +firma quedaría asociada al digest de turno, y la siguiente build la +volvería a mover. Cualquier verificación real de firma debería apuntar +siempre a un tag de versión específico (o, mejor todavía, al digest +exacto). + +## Verificar manualmente + +Con la llave pública del repo, cualquiera puede confirmar la firma de +una imagen sin necesitar acceso a nada privado: + +```bash +cosign verify \ + --key workloads/ecommerce/cosign.pub \ + --insecure-ignore-tlog=true \ + gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.97 +``` + +Si la imagen fue firmada por este pipeline, el comando termina con +`exit 0` y muestra el detalle de la firma. Si no — sea porque nunca se +firmó, porque la firmó otra llave, o porque la imagen fue modificada +después — termina con `exit 1` y un error explícito. + +## Qué falta (a propósito, todavía) + +Hoy la verificación de firma corre como smoke test **dentro del mismo +pipeline que la creó** — útil para confirmar que el mecanismo funciona, +pero no impide que alguien despliegue manualmente una imagen sin firmar +en el cluster. El siguiente paso natural, que **no** se implementó en +esta primera vuelta, sería un *admission controller* en el cluster +(ej. [Sigstore's policy-controller](https://docs.sigstore.dev/policy-controller/overview/) +o [Kyverno](https://kyverno.io/policies/other/verify-images/verify-images/) +con una política de verificación de imágenes) que rechace cualquier Pod +cuya imagen no tenga una firma válida de `cosign.pub` — momento en el +que Argo CD dejaría de poder desplegar una imagen sin firmar, no solo el +pipeline de CI. + +## Si la llave privada se compromete + +1. Generar un par nuevo (`cosign generate-key-pair`). +2. Reemplazar `COSIGN_PRIVATE_KEY` y `COSIGN_PASSWORD` en los secrets de + Gitea Actions del repo. +3. Reemplazar `workloads/ecommerce/cosign.pub` con la nueva llave + pública, en un commit normal (no es secreto, no hace falta + reescribir historial). +4. Las imágenes ya firmadas con la llave vieja **siguen verificando + contra la llave vieja** — no se "invalidan" solas. Si se sospecha + compromiso real, hay que decidir explícitamente qué imágenes ya + desplegadas se consideran no confiables, no asumir que rotar la + llave alcanza. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 67e414e..5dd51ed 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -103,6 +103,7 @@ nav: - Trivy (imagen + IaC): devsecops/trivy.md - SAST (Semgrep): devsecops/sast.md - SBOM (Syft): devsecops/sbom.md + - Cosign (firma de imágenes): devsecops/cosign.md extra: social: diff --git a/workloads/ecommerce/cosign.pub b/workloads/ecommerce/cosign.pub new file mode 100644 index 0000000..1b29af6 --- /dev/null +++ b/workloads/ecommerce/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+ +VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw== +-----END PUBLIC KEY-----