5 playbooks nuevos en docs/playbooks/ del portal: - SQLite en modo DELETE causando SQLITE_BUSY, por que WAL lo resuelve. - Anchors YAML no soportados por el parser de Gitea Actions, incluido el diagnostico equivocado inicial (indentacion) que se corrigio despues -- documentado con honestidad como leccion de proceso. - Docker-in-Docker vs sibling containers, y por que Semgrep corre nativo en un venv en vez de como container aparte. - El SNI/realm HTTP del tunel rompiendo especificamente a Cosign (docker push funcionaba, cosign sign no), conectado con el incidente ya documentado del 504 -- misma capa de ingress, dos fallas distintas. - Contencion de recursos entre Gitea y su runner: causa raiz real confirmada en vivo durante esta misma sesion (ambos contenedores reiniciados a mitad de un build, sin limites de CPU/memoria reales), con fix propuesto (ver rama fix/gitea-runner-resource-limits en scripts/) pendiente de aplicacion manual. Se actualiza el playbook del 504 para linkear hacia el nuevo hallazgo de contencion, cerrando el loop que habia quedado abierto ahi. cosign.md: nueva seccion "Limitaciones conocidas y mejoras futuras" con los dos TODO reales -- firma por tag en vez de digest (con el warning real de Cosign visto en los logs) y transparency log de Rekor deshabilitado, explicado en simple. index.md: tabla resumen de los 3 pipelines (imagen + adaptacion de Semgrep por stack), diagrama generalizado para reflejar que es el mismo patron en los 3 workflows, y "que falta" actualizado. Validado con mkdocs build --strict (0 errores, 0 warnings) antes de commitear.
9.6 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"]
- 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 enworkloads/ecommerce/cosign.pub, porque su función es poder compartirse. - 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). - 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.
Limitaciones conocidas y mejoras futuras
Dos limitaciones identificadas al validar el pipeline end-to-end, documentadas a propósito como TODO — ninguna de las dos se implementó todavía.
1. Se firma por tag, no por digest
Hoy el pipeline firma ecommerce-frontend:v1.0.104 — un tag, no un
digest (sha256:...). Un tag es una etiqueta mutable: nada impide
que, después de firmada la imagen, alguien (o un bug en el propio
pipeline) vuelva a subir contenido distinto bajo el mismo tag
v1.0.104. La firma original seguiría "verificando" — porque Cosign,
al verificar por tag, resuelve el tag al digest que tenga en ese
momento, no al que tenía cuando se firmó. Si el tag fue reasignado,
se está verificando una imagen distinta de la que realmente se firmó,
sin que nada avise.
Esto no es hipotético: el propio Cosign lo advierte en cada firma de este pipeline (visto en los logs reales de cada run):
WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102
uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one.
TODO: capturar el digest exacto que devuelve docker push (o
docker buildx build --metadata-file) y firmar/verificar contra ese
digest en vez del tag —
gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:... en vez de
:v1.0.104. No implementado todavía; requiere ajustar el step de
build para exponer el digest como output y pasarlo a los steps de
firma y verificación.
2. El transparency log (Rekor) está deshabilitado
Ya se explicó arriba, en la sección "Por qué --tlog-upload=false" de
esta misma página, la decisión consciente de no publicar en el
transparency log para este registry privado. Vale la pena nombrar en
simple qué es lo que se está dejando afuera:
Un transparency log (Rekor, el de Sigstore) es un registro público, append-only, criptográficamente verificable, de "quién firmó qué imagen y cuándo" — pensalo como un libro contable público que nadie puede editar ni borrar después de escrito, solo agregar filas nuevas. Cualquiera puede consultar ese libro para confirmar de forma independiente (sin confiar en el propio proyecto, ni en la llave privada, ni en el registry) que una firma específica existió en un momento específico.
Sin transparency log, la garantía que queda es más débil: "esta firma la generó quien tuviera la llave privada en el momento en que se verificó" — pero no hay ningún registro externo e inmutable que demuestre cuándo se generó, ni una forma de detectar si alguien con acceso a la llave privada firmó algo por fuera del pipeline sin que quede rastro. Para un lab personal con un registry privado, ese trade-off es razonable (ver la advertencia más abajo en esta misma página). Pero es una limitación real, no solo un detalle de configuración — importa especialmente el día que este mismo patrón se use en un contexto con más de una persona firmando, o con un registry que deje de ser privado.
TODO: si en algún momento el registry deja de ser exclusivamente privado, o se suma más de una persona con acceso a la llave de firma, reevaluar habilitar el transparency log público de Sigstore (o correr uno privado propio) en vez de mantenerlo deshabilitado.
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
- Generar un par nuevo (
cosign generate-key-pair). - Reemplazar
COSIGN_PRIVATE_KEYyCOSIGN_PASSWORDen los secrets de Gitea Actions del repo. - Reemplazar
workloads/ecommerce/cosign.pubcon la nueva llave pública, en un commit normal (no es secreto, no hace falta reescribir historial). - 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.