Signer, attester et promouvoir une image avec Cosign¶
Objectifs pédagogiques¶
À la fin de ce TP, vous devez être capables de :
- récupérer le digest d'une image au moment du build et le transmettre aux jobs suivants
- signer une image par son digest avec Cosign en mode keyless, à partir de l'identité OIDC de la pipeline
- générer un SBOM et l'attacher à l'image sous forme d'attestation signée
- vérifier localement une signature et une attestation en contraignant l'émetteur et l'identité
- promouvoir l'image signée vers un dépôt GitOps par Merge Request, avec un jeton à portée minimale
Prérequis¶
- Un projet GitLab.com contenant une application conteneurisée, par exemple l'API de securepay-api importée dans votre groupe
- Un
Dockerfilemulti-stage non-root et un job de build BuildKit rootless (voir le TP Conteneuriser une app Node.js) - Un second projet GitLab jouant le rôle de dépôt GitOps, par exemple
securepay-platform, contenant un chart Helm qui référence l'image par digest (charts/securepay-api/values.yaml) - Cosign installé sur votre poste, en version 2.0.1 ou plus récente (v3 recommandée), ainsi que
jq
Rekor est un journal public
En mode keyless avec l'infrastructure publique de Sigstore, le certificat de signature est publié dans le journal de transparence Rekor. Il contient le chemin de votre projet et la référence Git. Pour un projet confidentiel, utilisez la variante par paire de clés décrite en fin de TP ou une infrastructure Sigstore privée
Contexte¶
Votre pipeline construit, teste et scanne déjà une image. Mais rien ne garantit que l'image déployée en production est bien celle qui a passé les contrôles : un tag peut être réécrit, un registre compromis, un déploiement fait à la main
Vous allez fermer cette brèche en trois temps :
- Intégrité : l'image est désignée par son digest, immuable, du build jusqu'au déploiement
- Authenticité : l'image est signée par l'identité de la pipeline de
main, et uniquement elle - Traçabilité : la mise en production passe par une Merge Request sur le dépôt GitOps
flowchart LR
BUILD["build:image\n(digest)"] --> SBOM["sbom:image\n(scan + SBOM)"]
SBOM --> SIGN["sign:image\n(cosign sign + attest)"]
SIGN --> PROMOTE["promote\n(MR sur le dépôt GitOps)"]
PROMOTE --> MR["Revue + pipeline GitOps"] Atelier 1 : Récupérer le digest au moment du build¶
Travail demandé¶
- Ajoutez l'option
--metadata-file metadata.jsonà votre commandebuildctl-daemonless.sh build - Extrayez la valeur
containerimage.digestdu fichier et exposez-la aux jobs suivants grâce à un rapportdotenv - Faites échouer le job si le digest est vide
stages:
- build
- scan
- sign
- promote
build:image:
stage: build
image:
name: moby/buildkit:v0.34.0-rootless
entrypoint: [""]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > ~/.docker/config.json
script:
- buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA,push=true
--metadata-file metadata.json
- |
IMAGE_DIGEST=$(grep -o '"containerimage.digest": *"[^"]*"' metadata.json | cut -d'"' -f4)
test -n "$IMAGE_DIGEST"
echo "IMAGE_DIGEST=${IMAGE_DIGEST}" > build.env
echo "Image construite : ${CI_REGISTRY_IMAGE}@${IMAGE_DIGEST}"
artifacts:
reports:
dotenv: build.env
Ce que vous devez observer¶
- Le log du job affiche une référence de la forme
registry.gitlab.com/<groupe>/<projet>@sha256:... docker pullde cette référence fonctionne depuis votre poste et renvoie le même digest- Le digest reste identique si vous ajoutez ensuite un nouveau tag à la même image
Critère de réussite de l'atelier 1¶
La variable IMAGE_DIGEST est disponible dans les jobs qui déclarent needs: build:image avec artifacts: true
Atelier 2 : Scanner et générer le SBOM de l'image exacte¶
Travail demandé¶
- Créez un job
sbom:imagequi scanne l'image par son digest et échoue sur une vulnérabilitéCRITICAL - Générez le SBOM au format CycloneDX de la même image et conservez-le en artefact
sbom:image:
stage: scan
image:
name: aquasec/trivy:0.76.0
entrypoint: [""]
needs:
- job: build:image
artifacts: true
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
script:
- trivy image --exit-code 1 --severity CRITICAL --no-progress "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
- trivy image --format cyclonedx --output sbom.cdx.json "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
artifacts:
paths:
- sbom.cdx.json
Épinglez vos outils de sécurité
En mars 2026, les tags 0.69.4 et latest de l'image Trivy sur Docker Hub ont été écrasés par une image malveillante. Épinglez une version vérifiée, idéalement par digest (aquasec/trivy:<version>@sha256:<digest>), et appliquez la même règle à toutes les images d'outillage de la pipeline
Critère de réussite de l'atelier 2¶
Le fichier sbom.cdx.json est téléchargeable depuis le job et décrit les composants de l'image construite, pas ceux du dépôt source
Atelier 3 : Signer et attester l'image en mode keyless¶
Travail demandé¶
- Créez un job
sign:imagequi obtient un jeton OIDC pour l'audiencesigstoreviaid_tokens: - Signez l'image par son digest
- Attachez le SBOM sous forme d'attestation de type
cyclonedx - Laissez ce job s'exécuter sur toutes les branches : vous en aurez besoin à l'atelier 4 pour un test négatif
sign:image:
stage: sign
image: alpine:3.22
needs:
- job: build:image
artifacts: true
- job: sbom:image
artifacts: true
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
before_script:
- apk add --no-cache cosign
- cosign version
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- cosign sign --yes "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
- cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
Pourquoi le digest et non le tag
La documentation GitLab recommande de signer dans le job qui construit l'image, pour qu'elle ne puisse pas être altérée entre le build et la signature. Signer un digest transmis par artefact supprime ce risque : le digest désigne un contenu unique, qu'aucune réécriture de tag ne peut remplacer
Cosign v2 ou v3
cosign version affiche la version installée par le paquet Alpine. Les commandes de ce TP sont identiques en v2 et en v3. La v3 produit par défaut le format bundle de Sigstore et stocke les signatures comme artefacts référents OCI 1.1 : si votre registre ou votre contrôleur d'admission ne le supporte pas, consultez cosign sign --help pour revenir à l'ancien format
Ce que vous devez observer¶
- Le log du job mentionne l'obtention d'un certificat Fulcio et l'enregistrement dans le journal de transparence
- Dans le registre GitLab, de nouveaux artefacts apparaissent à côté de l'image (signature et attestation)
Critère de réussite de l'atelier 3¶
Le job sign:image réussit sur main sans qu'aucune clé ni aucun secret de signature n'ait été configuré dans le projet
Atelier 4 : Vérifier localement la signature et l'attestation¶
Travail demandé¶
- Authentifiez-vous sur le registre GitLab depuis votre poste (
docker login registry.gitlab.comoucosign login) -
Vérifiez la signature produite sur
mainen contraignant l'émetteur et l'identité :IMAGE=registry.gitlab.com/<groupe>/securepay-api DIGEST=sha256:<digest du job build:image> IDENTITY="https://gitlab.com/<groupe>/securepay-api//.gitlab-ci.yml@refs/heads/main" cosign verify \ --certificate-identity "$IDENTITY" \ --certificate-oidc-issuer "https://gitlab.com" \ "$IMAGE@$DIGEST" | jq . -
Vérifiez l'attestation SBOM et lisez son type de prédicat :
-
Test négatif n°1 : poussez une branche
feature/test-signature, laissez la pipeline signer l'image de cette branche, puis vérifiez-la avec l'identité demain - Test négatif n°2 : vérifiez l'image de
mainavec un émetteur ou un chemin de projet erroné
Ce que vous devez observer¶
- La vérification sur
mainréussit et affiche les informations du certificat (émetteur, identité, référence) - La vérification de l'image signée sur la branche de travail échoue avec un message du type
none of the expected identities matched - Une identité saisie avec un seul slash avant
.gitlab-ci.ymléchoue elle aussi
Lire l'attestation
verify-attestation affiche l'enveloppe signée (DSSE) : la déclaration in-toto est encodée en base64 dans le champ payload. Selon la version de Cosign, le format de sortie peut légèrement varier : adaptez le filtre jq si nécessaire
Questions de l'atelier¶
- Que prouverait une vérification réussie si vous omettiez
--certificate-identity? - Pourquoi la référence Git fait-elle partie de l'identité vérifiée ?
Critère de réussite de l'atelier 4¶
Vous savez démontrer en direct qu'une image signée par une autre branche, ou par un autre projet, est rejetée par la vérification
Atelier 5 : Promouvoir l'image par Merge Request¶
Contexte¶
La production est décrite dans le dépôt GitOps. Personne ne doit y pousser directement, pas même la CI : la pipeline applicative ouvre une Merge Request qui sera validée par la pipeline du dépôt GitOps puis relue
Travail demandé¶
- Dans le dépôt GitOps, protégez
main: push interdit à tous, merge par Merge Request uniquement, pipeline au vert obligatoire - Créez un jeton dédié à la promotion :
- portée limitée au seul dépôt GitOps
- rôle Developer et scope
write_repositoryuniquement - date d'expiration courte
- Dans le projet applicatif, enregistrez ce jeton dans la variable
GITOPS_PROMOTE_TOKEN, de visibilité Masked (ou Masked and hidden) et Protected - Ajoutez le job
promote, exécuté uniquement sur la branche par défaut, après la signature :
promote:
stage: promote
image: alpine:3.22
needs:
- job: build:image
artifacts: true
- job: sign:image
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
GITOPS_REPO: gitlab.com/<groupe>/securepay-platform.git
VALUES_FILE: charts/securepay-api/values.yaml
before_script:
- apk add --no-cache git
script:
- |
git clone "https://promote-bot:${GITOPS_PROMOTE_TOKEN}@${GITOPS_REPO}" gitops
cd gitops
git switch -c "promote/securepay-api-${CI_COMMIT_SHORT_SHA}"
sed -i "s|^\( *digest:\).*|\1 \"${IMAGE_DIGEST}\"|" "$VALUES_FILE"
# Rien à promouvoir si le digest est déjà en place
git diff --quiet && echo "Digest déjà promu" && exit 0
git -c user.name="securepay-ci" -c user.email="[email protected]" \
commit -am "chore(securepay-api): promotion ${IMAGE_DIGEST}"
git push origin HEAD \
-o merge_request.create \
-o merge_request.target=main \
-o merge_request.title="Promotion securepay-api ${CI_COMMIT_SHORT_SHA}" \
-o merge_request.description="Image ${CI_REGISTRY_IMAGE}@${IMAGE_DIGEST} signée par ${CI_PIPELINE_URL}" \
-o merge_request.remove_source_branch
Jetons d'accès projet sur GitLab.com
Selon l'offre de votre namespace GitLab.com, la création de jetons d'accès projet peut nécessiter un abonnement payant. À défaut, utilisez une deploy key SSH avec accès en écriture limitée au dépôt GitOps, ou le jeton d'un compte de service dédié, avec la même discipline : portée minimale, expiration, variable protégée
Adapter la mise à jour du digest
La commande sed suppose une seule clé digest: dans le fichier de valeurs. Si votre chart en contient plusieurs, utilisez un outil YAML comme yq et ciblez précisément .image.digest
Ce que vous devez observer¶
- Une Merge Request apparaît dans le dépôt GitOps avec un diff d'une seule ligne : l'ancien et le nouveau digest
- La description de la MR renvoie vers la pipeline qui a construit et signé l'image
- La pipeline du dépôt GitOps s'exécute sur la MR avant tout merge
- Le jeton n'apparaît jamais en clair dans les logs du job
Critère de réussite de l'atelier 5¶
Un commit sur main du projet applicatif aboutit, sans intervention manuelle, à une Merge Request de promotion qui référence un digest signé et vérifiable
Variante : signature par paire de clés¶
À utiliser si le mode keyless n'est pas disponible : instance GitLab auto-hébergée sans infrastructure Sigstore, réseau sans accès à Fulcio et Rekor, ou projet qui ne doit pas apparaître dans un journal public
-
Générez une paire de clés sur votre poste :
-
Dans le projet applicatif, créez :
COSIGN_PRIVATE_KEY: le contenu decosign.key, en variable Protected (une clé multiligne ne peut pas être masquée : restreignez strictement les rôles autorisés à lire les variables)COSIGN_PASSWORD: la phrase de passe, en variable Masked et Protected
-
Remplacez la signature keyless par :
-
Committez uniquement
cosign.pub(dans le dépôt GitOps, par exemple), puis vérifiez : -
Supprimez
cosign.keyde votre poste une fois la variable créée
Ce que la variante vous coûte
Avec une paire de clés, la rotation, la révocation et la distribution de la clé publique sont à votre charge. Un KMS (awskms://, gcpkms://, azurekms://, hashivault://) évite de manipuler la clé privée
Critères de réussite globaux¶
Le TP est réussi si vous pouvez démontrer :
- une image construite une seule fois et désignée par son digest jusqu'au dépôt GitOps
- une signature et une attestation SBOM vérifiables avec l'identité de la pipeline de
main - le rejet de la vérification pour une image signée depuis une autre branche
- une Merge Request de promotion ouverte automatiquement, avec un jeton à portée minimale
Questions de synthèse¶
- Quelle différence faites-vous entre une signature et une attestation ? Donnez un exemple de décision que seule une attestation permet de prendre
- Quel niveau SLSA de la piste Build votre pipeline atteint-elle ? Que manque-t-il pour viser le niveau suivant ?
- Pourquoi une signature vérifiée uniquement dans la pipeline ne protège-t-elle pas la production ?
- Quels sont les risques d'un jeton de promotion à portée trop large, et comment les limitez-vous ?
- Dans quelles situations choisiriez-vous une paire de clés plutôt que le mode keyless ?
Pour aller plus loin¶
- La vérification à l'admission avec Kyverno et
verifyImages, qui refuse toute image non signée par la pipeline : voir le deck 04 de ce cours, consacré à la sécurisation du cluster Kubernetes - Documentation GitLab : signature keyless avec Sigstore
- Documentation Sigstore
- Spécification SLSA