Aller au contenu

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 Dockerfile multi-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é

  1. Ajoutez l'option --metadata-file metadata.json à votre commande buildctl-daemonless.sh build
  2. Extrayez la valeur containerimage.digest du fichier et exposez-la aux jobs suivants grâce à un rapport dotenv
  3. 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 pull de 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é

  1. Créez un job sbom:image qui scanne l'image par son digest et échoue sur une vulnérabilité CRITICAL
  2. 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é

  1. Créez un job sign:image qui obtient un jeton OIDC pour l'audience sigstore via id_tokens:
  2. Signez l'image par son digest
  3. Attachez le SBOM sous forme d'attestation de type cyclonedx
  4. 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é

  1. Authentifiez-vous sur le registre GitLab depuis votre poste (docker login registry.gitlab.com ou cosign login)
  2. Vérifiez la signature produite sur main en 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 .
    
  3. Vérifiez l'attestation SBOM et lisez son type de prédicat :

    cosign verify-attestation --type cyclonedx \
      --certificate-identity "$IDENTITY" \
      --certificate-oidc-issuer "https://gitlab.com" \
      "$IMAGE@$DIGEST" > attestation.json
    
    jq -r '.payload' attestation.json | base64 -d | jq '.predicateType'
    
  4. 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é de main

  5. Test négatif n°2 : vérifiez l'image de main avec un émetteur ou un chemin de projet erroné

Ce que vous devez observer

  • La vérification sur main ré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é

  1. Dans le dépôt GitOps, protégez main : push interdit à tous, merge par Merge Request uniquement, pipeline au vert obligatoire
  2. Créez un jeton dédié à la promotion :
    • portée limitée au seul dépôt GitOps
    • rôle Developer et scope write_repository uniquement
    • date d'expiration courte
  3. Dans le projet applicatif, enregistrez ce jeton dans la variable GITOPS_PROMOTE_TOKEN, de visibilité Masked (ou Masked and hidden) et Protected
  4. 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

  1. Générez une paire de clés sur votre poste :

    cosign generate-key-pair
    
  2. Dans le projet applicatif, créez :

    • COSIGN_PRIVATE_KEY : le contenu de cosign.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
  3. Remplacez la signature keyless par :

    cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
    cosign attest --yes --key env://COSIGN_PRIVATE_KEY --type cyclonedx \
      --predicate sbom.cdx.json "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
    
  4. Committez uniquement cosign.pub (dans le dépôt GitOps, par exemple), puis vérifiez :

    cosign verify --key cosign.pub "$IMAGE@$DIGEST"
    
  5. Supprimez cosign.key de 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