fix(devsecops): firmar y verificar imagenes por digest, no por tag
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 58s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 35s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 18m58s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m0s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m43s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 58s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 35s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 18m58s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m0s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m43s
Los 3 pipelines (frontend, commerce-backend, docs-portal) ahora capturan el digest real del docker push y lo usan como referencia para cosign sign/verify, en vez del tag mutable :VERSION. Documenta ademas la decision de mantener Rekor deshabilitado (registry privado, evita dependencia de red saliente hacia rekor.sigstore.dev via el tunel). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FqYP3Wf1W63Qmh7qgXAAT
This commit is contained in:
@@ -287,12 +287,21 @@ jobs:
|
|||||||
# Login ya se hizo arriba; recién acá se sube, después de que la
|
# Login ya se hizo arriba; recién acá se sube, después de que la
|
||||||
# imagen pasó el gate de CRITICAL.
|
# imagen pasó el gate de CRITICAL.
|
||||||
- name: Subir Imagen al Registry
|
- name: Subir Imagen al Registry
|
||||||
|
id: push
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")"
|
||||||
|
echo "${PUSH_OUTPUT}"
|
||||||
docker push "${IMAGE_NAME}:latest"
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||||
|
test -n "${DIGEST}" || {
|
||||||
|
echo "ERROR: no se pudo extraer el digest del push."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Instalar Cosign
|
- name: Instalar Cosign
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -310,7 +319,8 @@ jobs:
|
|||||||
# todo el registry de este lab. La llave pública se commitea en
|
# todo el registry de este lab. La llave pública se commitea en
|
||||||
# cada directorio de app (workloads/commerce-backend/cosign.pub)
|
# cada directorio de app (workloads/commerce-backend/cosign.pub)
|
||||||
# para que la verificación quede local a cada workflow, igual que
|
# para que la verificación quede local a cada workflow, igual que
|
||||||
# el frontend.
|
# el frontend. Firma por digest (no por tag), mismo criterio que
|
||||||
|
# build.yaml.
|
||||||
- name: Firmar Imagen (Cosign)
|
- name: Firmar Imagen (Cosign)
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -323,7 +333,7 @@ jobs:
|
|||||||
--use-signing-config=false \
|
--use-signing-config=false \
|
||||||
--tlog-upload=false \
|
--tlog-upload=false \
|
||||||
--yes \
|
--yes \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verificar Firma (smoke test)
|
- name: Verificar Firma (smoke test)
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -332,7 +342,7 @@ jobs:
|
|||||||
/tmp/cosign verify \
|
/tmp/cosign verify \
|
||||||
--key "${APP_DIR}/cosign.pub" \
|
--key "${APP_DIR}/cosign.pub" \
|
||||||
--insecure-ignore-tlog=true \
|
--insecure-ignore-tlog=true \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verificar promoción segura
|
- name: Verificar promoción segura
|
||||||
id: promotion
|
id: promotion
|
||||||
|
|||||||
@@ -426,12 +426,21 @@ jobs:
|
|||||||
# Login ya se hizo arriba; recién acá se sube, después de que la
|
# Login ya se hizo arriba; recién acá se sube, después de que la
|
||||||
# imagen pasó el gate de CRITICAL.
|
# imagen pasó el gate de CRITICAL.
|
||||||
- name: Subir Imagen al Registry
|
- name: Subir Imagen al Registry
|
||||||
|
id: push
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")"
|
||||||
|
echo "${PUSH_OUTPUT}"
|
||||||
docker push "${IMAGE_NAME}:latest"
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||||
|
test -n "${DIGEST}" || {
|
||||||
|
echo "ERROR: no se pudo extraer el digest del push."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Instalar Cosign
|
- name: Instalar Cosign
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -443,12 +452,13 @@ jobs:
|
|||||||
chmod +x /tmp/cosign
|
chmod +x /tmp/cosign
|
||||||
/tmp/cosign version
|
/tmp/cosign version
|
||||||
|
|
||||||
# Firma solo la versión (no :latest, que es un tag mutable y firmarlo
|
# Firma por digest, no por tag: :VERSION y :latest son punteros que
|
||||||
# pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
|
# en teoría podrían volver a moverse, pero el digest identifica el
|
||||||
# --tlog-upload=false: firma solo con el par de llaves propio, sin
|
# contenido exacto de la imagen que Trivy ya escaneó unos pasos
|
||||||
# publicar metadata en el transparency log público de Sigstore — este
|
# atrás. --use-signing-config=false --tlog-upload=false: firma solo
|
||||||
# registry es privado, no tiene sentido anunciar públicamente qué se
|
# con el par de llaves propio, sin publicar metadata en el
|
||||||
# firmó y cuándo.
|
# 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)
|
- name: Firmar Imagen (Cosign)
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -461,7 +471,7 @@ jobs:
|
|||||||
--use-signing-config=false \
|
--use-signing-config=false \
|
||||||
--tlog-upload=false \
|
--tlog-upload=false \
|
||||||
--yes \
|
--yes \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
# Smoke test: confirma en el propio pipeline que la firma que se
|
# Smoke test: confirma en el propio pipeline que la firma que se
|
||||||
# acaba de crear valida contra la llave pública commiteada en el
|
# acaba de crear valida contra la llave pública commiteada en el
|
||||||
@@ -475,7 +485,7 @@ jobs:
|
|||||||
/tmp/cosign verify \
|
/tmp/cosign verify \
|
||||||
--key "${APP_DIR}/cosign.pub" \
|
--key "${APP_DIR}/cosign.pub" \
|
||||||
--insecure-ignore-tlog=true \
|
--insecure-ignore-tlog=true \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -282,12 +282,21 @@ jobs:
|
|||||||
# Login ya se hizo arriba; recién acá se sube, después de que la
|
# Login ya se hizo arriba; recién acá se sube, después de que la
|
||||||
# imagen pasó el gate de CRITICAL.
|
# imagen pasó el gate de CRITICAL.
|
||||||
- name: Subir Imagen al Registry
|
- name: Subir Imagen al Registry
|
||||||
|
id: push
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")"
|
||||||
|
echo "${PUSH_OUTPUT}"
|
||||||
docker push "${IMAGE_NAME}:latest"
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||||
|
test -n "${DIGEST}" || {
|
||||||
|
echo "ERROR: no se pudo extraer el digest del push."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Instalar Cosign
|
- name: Instalar Cosign
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -301,7 +310,8 @@ jobs:
|
|||||||
|
|
||||||
# Mismo par de llaves que el frontend y commerce-backend (secrets
|
# Mismo par de llaves que el frontend y commerce-backend (secrets
|
||||||
# ya existentes a nivel de repo). Llave pública commiteada en
|
# ya existentes a nivel de repo). Llave pública commiteada en
|
||||||
# workloads/docs-portal/cosign.pub.
|
# workloads/docs-portal/cosign.pub. Firma por digest (no por tag),
|
||||||
|
# mismo criterio que build.yaml.
|
||||||
- name: Firmar Imagen (Cosign)
|
- name: Firmar Imagen (Cosign)
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -314,7 +324,7 @@ jobs:
|
|||||||
--use-signing-config=false \
|
--use-signing-config=false \
|
||||||
--tlog-upload=false \
|
--tlog-upload=false \
|
||||||
--yes \
|
--yes \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verificar Firma (smoke test)
|
- name: Verificar Firma (smoke test)
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -323,7 +333,7 @@ jobs:
|
|||||||
/tmp/cosign verify \
|
/tmp/cosign verify \
|
||||||
--key "${APP_DIR}/cosign.pub" \
|
--key "${APP_DIR}/cosign.pub" \
|
||||||
--insecure-ignore-tlog=true \
|
--insecure-ignore-tlog=true \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}@${{ steps.push.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verificar promoción segura
|
- name: Verificar promoción segura
|
||||||
id: promotion
|
id: promotion
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Firma de imágenes con Cosign
|
||||||
|
|
||||||
|
Los 3 pipelines que construyen y publican imágenes hacia `gitea.cruzcloud.net`
|
||||||
|
(frontend en `.gitea/workflows/build.yaml`, commerce-backend en
|
||||||
|
`build-medusa.yaml`, docs-portal en `deploy-docs.yaml`) firman la imagen con
|
||||||
|
Cosign (par de llaves propio, sin CA externa) inmediatamente después del
|
||||||
|
`docker push` y antes de que cualquier manifiesto pueda referenciarla.
|
||||||
|
|
||||||
|
## Firma por digest, no por tag
|
||||||
|
|
||||||
|
Desde 2026-08-16 los tres workflows firman y verifican por **digest**
|
||||||
|
(`${IMAGE_NAME}@sha256:...`), no por tag (`${IMAGE_NAME}:${VERSION}`).
|
||||||
|
|
||||||
|
Motivo: `:VERSION` y `:latest` son punteros mutables — nada impide que en el
|
||||||
|
futuro un tag se vuelva a mover a otro contenido. El digest identifica el
|
||||||
|
contenido exacto de la imagen que Trivy ya escaneó unos pasos antes en el
|
||||||
|
mismo job, así que la firma queda atada a ese contenido específico, no al
|
||||||
|
nombre que apunta a él en un momento dado.
|
||||||
|
|
||||||
|
El digest se captura parseando la salida de `docker push` (`sha256:[a-f0-9]{64}`)
|
||||||
|
inmediatamente después de subir la imagen, y se pasa como `steps.push.outputs.digest`
|
||||||
|
a los pasos de firma y verificación (smoke test) que corren a continuación en
|
||||||
|
el mismo job.
|
||||||
|
|
||||||
|
## Rekor / transparency log: deshabilitado (decisión, no pendiente)
|
||||||
|
|
||||||
|
`cosign sign` corre con `--use-signing-config=false --tlog-upload=false` en
|
||||||
|
los tres workflows — evaluado y confirmado el 2026-08-16, se mantiene así.
|
||||||
|
|
||||||
|
Razones:
|
||||||
|
- El registry (`gitea.cruzcloud.net`) es privado, de un lab. No hay valor de
|
||||||
|
auditoría en anunciar públicamente en Rekor qué se firmó y cuándo.
|
||||||
|
- Subir al transparency log público requiere una llamada de red saliente por
|
||||||
|
build hacia `rekor.sigstore.dev`, atravesando el túnel de Cloudflare. Este
|
||||||
|
túnel ya tuvo un incidente de SNI/orden con Cosign (ver memoria de
|
||||||
|
infraestructura), así que sumar otra dependencia de red saliente por cada
|
||||||
|
build agrega superficie de fallo sin beneficio real en este contexto.
|
||||||
|
- Impacto en tiempo de pipeline: evitado por completo al no hacer la llamada.
|
||||||
|
|
||||||
|
Si el registry deja de ser privado, o aparece un requisito de compliance
|
||||||
|
externo que exija prueba pública de firma, revisar esta decisión.
|
||||||
|
|
||||||
|
## Llaves públicas
|
||||||
|
|
||||||
|
Cada app commitea su llave pública junto al resto de sus manifiestos
|
||||||
|
(`workloads/<app>/cosign.pub`), para que la verificación (paso de smoke test
|
||||||
|
dentro del mismo workflow) sea local y no dependa de un servicio externo.
|
||||||
Reference in New Issue
Block a user