Aller au contenu
CI/CD & Automatisation medium

Build vérifiable : provenance SLSA, SBOM et signature

18 min de lecture

logo github

Le pipeline CI garantit que le code est testé et durci. Reste à rendre l'image publiée vérifiable : prouver d'où elle vient, ce qu'elle contient, et qu'elle n'a pas été substituée dans le registre. Ce guide construit, pas à pas, le workflow release.yml du dépôt de référence : build et push par digest sur GHCR, provenance SLSA native, SBOM CycloneDX attesté et signature Cosign keyless. Il montre ensuite comment un tiers vérifie toute la chaîne, et pourquoi une version de Cosign mal alignée fait croire à tort qu'une release est cassée. La règle de fond : une image sans provenance ni signature est indistinguable d'une image compromise.

  • Construire et pousser une image sur le registre GitHub (GHCR) par digest
  • Générer une attestation de provenance SLSA avec l'action native GitHub
  • Attester un SBOM CycloneDX et signer l'image avec Cosign en mode keyless
  • Vérifier la provenance et la signature en tant que tiers, sans piège de version

Le pipeline CI durci est en place : c'est lui qui introduit le durcissement de base des workflows (épinglage par SHA, permissions: {}, harden-runner, checkout sans credentials). Ce guide ne le réexplique pas, il l'applique. Lecture conseillée en parallèle : SLSA, Sigstore et SBOM. Côté machine, la CLI gh est authentifiée et Docker est disponible pour les vérifications locales. Tout le code montré ici vient du dépôt public de référence github.com/stephrobert/secure-python-pipeline : vous pouvez le cloner, inspecter les workflows et rejouer les vérifications sur la vraie image publiée.

Le workflow se déclenche à la publication d'une release et enchaîne, dans un seul job, le build, les attestations et la signature. Le déclencheur release: [published] garantit qu'on ne signe que des versions taguées explicitement, jamais chaque push. La partie sensible est le bloc permissions: : il part de zéro au niveau workflow, puis n'ouvre que le strict nécessaire au niveau du job.

name: Release
on:
release:
types: [published]
# Aucune permission par défaut.
permissions: {}
jobs:
build-and-attest:
name: Build, push, attester et signer
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write # attacher la provenance a la release (Signed-Releases)
packages: write # push vers GHCR
id-token: write # OIDC keyless (provenance + cosign)
attestations: write # attestations natives GitHub
env:
IMAGE: ghcr.io/stephrobert/secure-python-pipeline

Chaque droit a une justification unique. packages: write autorise le push vers GHCR. id-token: write délivre le jeton OIDC dont Cosign et l'action de provenance ont besoin pour signer sans clé longue durée. attestations: write permet de publier les attestations natives. Enfin contents: write sert uniquement à attacher la provenance comme asset de la release, exigence du contrôle Scorecard traitée plus bas. On ne demande rien de plus.

Avant d'attester quoi que ce soit, il faut une image poussée et adressable par digest. Le digest sha256:... est l'empreinte immuable du contenu de l'image : c'est lui, pas le tag mutable, qui sert de sujet à toutes les attestations. On enchaîne le durcissement du runner, l'authentification au registre, la préparation de Buildx, le calcul des tags, puis le build.

steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Login GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Métadonnées (tags et labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- name: Build et push
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Deux détails portent tout le reste. Le step de connexion utilise le GITHUB_TOKEN comme mot de passe : aucun secret longue durée ne transite. Et le step Build et push porte un id: build, ce qui expose sa sortie steps.build.outputs.digest. Ce digest est la valeur qu'on passera, identique, à chaque étape d'attestation. docker/metadata-action génère au passage un tag semver (le numéro de version) et le tag latest, pour que l'image soit retrouvable des deux façons.

La provenance répond à la question « quel workflow, sur quel commit, a produit cette image ? ». Depuis fin 2024, GitHub fournit une action native qui génère une attestation de provenance SLSA signée, sans déployer de générateur tiers ni coder un layout in-toto à la main. On la place juste après le build et on lui donne le digest de l'image poussée.

- name: Attestation de provenance SLSA (native)
id: attest
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

L'option push-to-registry: true rattache l'attestation à l'image dans GHCR : elle voyage avec elle et reste vérifiable par n'importe qui. Le step porte un id: attest car il expose un bundle-path, le chemin local du bundle d'attestation, qu'on réutilisera pour la release. Techniquement, cette action donne SLSA niveau L2 directement, et L3 via un workflow réutilisable ; elle est plus simple et plus sûre qu'un générateur tiers, et c'est la pratique recommandée aujourd'hui.

Un SBOM (Software Bill of Materials, l'inventaire exhaustif des composants de l'image) répond à « qu'y a-t-il dedans ? ». On le génère avec Syft au format CycloneDX JSON, puis on l'atteste en le liant au même digest. La pratique moderne est d'attacher le SBOM à l'image dans le registre, pas de le laisser traîner en fichier détaché que personne ne relie à rien.

- name: Générer le SBOM (Syft, CycloneDX)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
# Le SBOM est attesté au registre (attest-sbom) : pas besoin de
# l'attacher à la release, ce qui exigerait contents: write.
upload-release-assets: false
- name: Attester le SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cdx.json
push-to-registry: true

La signature keyless (sans clé longue durée) s'appuie sur l'identité OIDC du workflow. Cosign demande un certificat court à l'autorité Fulcio, signe le digest, puis enregistre l'opération dans le log de transparence public Rekor. Il n'y a aucune clé privée à stocker ni à faire tourner : l'identité du workflow fait foi, et Rekor rend la signature auditable par tous.

- name: Installer Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Signer l'image (keyless Sigstore)
env:
IMAGE: ${{ env.IMAGE }}
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes "${IMAGE}@${DIGEST}"

Deux points de vigilance. Le digest ne passe jamais directement dans le run: en interpolation ${{ }} : il transite par un bloc env:, et la commande utilise ${DIGEST}. C'est la parade contre l'injection de template, appliquée systématiquement dès qu'une donnée dynamique entre dans un run:. L'autre point est le drapeau --yes, qui saute la confirmation interactive (impossible en CI). La version de l'installeur, elle, cache un piège traité en fin de guide.

Un dernier step échappe souvent à l'attention. Le contrôle Signed-Releases d'OpenSSF Scorecard ne regarde pas l'attestation OCI rattachée à l'image : il cherche un fichier *.intoto.jsonl attaché comme asset de la release. On copie donc le bundle produit par le step attest sous ce nom, et on l'attache à la release avec gh.

- name: Attacher la provenance à la release (Scorecard Signed-Releases)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
cp "$BUNDLE" provenance.intoto.jsonl
gh release upload "$TAG" provenance.intoto.jsonl --clobber

C'est précisément ce step qui débloque Signed-Releases 10 dans Scorecard ; le détail du calcul (moyenne sur les cinq dernières releases, plancher par division entière) est traité dans le guide Scoring et durcissement. Ici, retenez que l'attestation OCI et l'asset de release sont deux artefacts distincts : l'un sert la vérification tierce, l'autre le scoring.

Une attestation qui ne se vérifie pas ne vaut rien. Sur un dépôt public, avec Rekor ouvert, n'importe qui peut contrôler la provenance et la signature sans accès particulier au dépôt. Deux commandes suffisent : gh attestation verify pour la provenance native, et cosign verify pour la signature keyless.

Fenêtre de terminal
# Provenance native GitHub (attestation OCI rattachée à l'image)
gh attestation verify oci://ghcr.io/stephrobert/secure-python-pipeline:1.0.0 \
--owner stephrobert

La sortie confirme le digest chargé et la validation des attestations trouvées :

Loaded digest sha256:… for oci://ghcr.io/stephrobert/secure-python-pipeline:1.0.0
Loaded 2 attestations from GitHub API
✓ Verification succeeded!
sha256:… was attested by:
REPO PREDICATE_TYPE WORKFLOW
stephrobert/secure-python-pipeline https://slsa.dev/provenance/v1 .github/workflows/release.yml@refs/tags/v1.0.0

La signature Cosign se vérifie ensuite. Les deux options de certificat sont obligatoires : sans elles, cosign verify accepterait n'importe quelle identité, ce qui viderait la vérification de son sens.

Fenêtre de terminal
# Signature Cosign keyless : identité du workflow + émetteur OIDC obligatoires
cosign verify ghcr.io/stephrobert/secure-python-pipeline:1.0.0 \
--certificate-identity-regexp "https://github.com/stephrobert/secure-python-pipeline/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"

Une vérification réussie liste les contrôles passés et rend le payload de signature :

Verification for ghcr.io/stephrobert/secure-python-pipeline:1.0.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates

Sur l'image v1.0.0 du dépôt, ces contrôles renvoient trois attestations rattachées au même digest : la provenance SLSA v1, la signature Cosign, et le SBOM CycloneDX. Le détail par commande est approfondi dans le guide Vérifier les attestations.

Vérifier à la main est utile pour comprendre ; en pratique, on outille la vérification pour qu'un mainteneur puisse la rejouer sans mémoriser les options. Le workflow verify-slsa.yml fait exactement cela. Il se déclenche manuellement (workflow_dispatch) avec le tag en entrée, ce qui évite la course avec le push d'image d'une release fraîche, et n'exige que des permissions en lecture.

name: Verify SLSA
# Vérifie la provenance et la signature de l'image publiée. Déclenchement manuel
# (après une release) pour éviter toute course avec le push de l'image.
on:
workflow_dispatch:
inputs:
tag:
description: "Tag de l'image à vérifier (ex : 1.0.0 ou latest)"
required: true
default: "latest"
permissions: {}
jobs:
verify:
name: Vérifier provenance et signature
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: read
env:
IMAGE: ghcr.io/stephrobert/secure-python-pipeline
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Installer Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Vérifier l'attestation de provenance (native GitHub)
env:
IMAGE: ${{ env.IMAGE }}
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh attestation verify "oci://${IMAGE}:${TAG}" --owner stephrobert
- name: Vérifier la signature Cosign (keyless)
env:
IMAGE: ${{ env.IMAGE }}
TAG: ${{ inputs.tag }}
run: |
cosign verify "${IMAGE}:${TAG}" \
--certificate-identity-regexp "https://github.com/stephrobert/secure-python-pipeline/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"

Ce workflow installe le même Cosign que le pipeline de release, ce qui écarte d'emblée le piège de version décrit ci-dessous. Le tag et l'image passent par env:, jamais en interpolation directe dans le run:, cohérent avec la règle d'injection appliquée partout dans la série.

Ce piège fait perdre des heures parce qu'il produit un faux négatif crédible. L'installeur sigstore/cosign-installer@v4 pose Cosign 3.x, dont le format de signature et de bundle diffère de celui de la 2.x. Si votre poste a encore un Cosign 2.x installé, un cosign verify local renvoie un no signatures found alors que l'image est parfaitement signée. Le problème n'est pas la release : c'est l'outil local qui ne sait pas lire le nouveau format.

La parade tient en une ligne de discipline : installer Cosign par le même moyen qu'en CI (l'installeur Sigstore, épinglé par SHA), et confirmer la version au moindre doute. C'est aussi pourquoi verify-slsa.yml réutilise l'installeur plutôt qu'un binaire pré-provisionné : le workflow de vérification et le workflow de release restent alignés par construction.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Le workflow de release part de permissions: {} et n'ouvre que quatre droits justifiés : packages, id-token, attestations, et contents: write pour l'asset de release.
  • Le digest sha256:... exposé par le step build est le sujet unique de toutes les attestations : provenance, SBOM et signature pointent la même empreinte.
  • L'action native attest-build-provenance produit une provenance SLSA signée (L2 direct, L3 via workflow réutilisable), sans générateur tiers.
  • Le SBOM CycloneDX est attesté au registre ; on coupe son attachement redondant à la release avec upload-release-assets: false.
  • La signature Cosign keyless s'ancre sur l'identité OIDC du workflow, via Fulcio et Rekor, sans clé longue durée.
  • Signed-Releases de Scorecard exige un *.intoto.jsonl attaché à la release, distinct de l'attestation OCI.
  • Un tiers vérifie tout avec gh attestation verify et cosign verify, ce dernier toujours avec --certificate-identity-regexp et --certificate-oidc-issuer.
  • Un no signatures found local vient souvent d'un Cosign 2.x face à une image signée en 3.x : aligner la version avant de conclure.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn