Files
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

6.3 KiB

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

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:

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 o Kyverno 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.