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-----