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).
This commit is contained in:
@@ -392,6 +392,51 @@ for r in results:
|
|||||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
docker push "${IMAGE_NAME}:latest"
|
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
|
# Evita que una ejecución antigua actualice frontend.yaml después de que
|
||||||
# ya exista un commit más reciente en main.
|
# ya exista un commit más reciente en main.
|
||||||
- name: Verificar promoción segura
|
- name: Verificar promoción segura
|
||||||
|
|||||||
@@ -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<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:
|
||||||
|
|
||||||
|
```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.
|
||||||
@@ -103,6 +103,7 @@ nav:
|
|||||||
- Trivy (imagen + IaC): devsecops/trivy.md
|
- Trivy (imagen + IaC): devsecops/trivy.md
|
||||||
- SAST (Semgrep): devsecops/sast.md
|
- SAST (Semgrep): devsecops/sast.md
|
||||||
- SBOM (Syft): devsecops/sbom.md
|
- SBOM (Syft): devsecops/sbom.md
|
||||||
|
- Cosign (firma de imágenes): devsecops/cosign.md
|
||||||
|
|
||||||
extra:
|
extra:
|
||||||
social:
|
social:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
|
||||||
|
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
Reference in New Issue
Block a user