Lab : Signature et Vérification d’Images Container avec Cosign dans GitHub Actions

Présentation

Chaque image container produite par votre pipeline CI/CD devrait être signée cryptographiquement avant d’atteindre un quelconque environnement. Les images non signées constituent un angle mort — vous n’avez aucune preuve qu’elles proviennent de votre pipeline, aucune garantie qu’elles n’ont pas été altérées en transit, et aucun mécanisme de politique pour bloquer les déploiements non autorisés.

Dans ce lab pratique, vous allez :

  • Signer une image container localement à l’aide d’une paire de clés Cosign.
  • Configurer la signature keyless dans GitHub Actions en utilisant l’infrastructure Fulcio et Rekor de Sigstore.
  • Vérifier les signatures localement avec des contrôles de certificats basés sur l’identité.
  • Appliquer la vérification des signatures au moment de l’admission dans Kubernetes à l’aide de Kyverno.
  • Attacher et vérifier une attestation SBOM avec Cosign et Syft.

À la fin de ce lab, vous disposerez d’un workflow GitHub Actions complet qui construit, pousse, signe et atteste chaque image — ainsi qu’une politique Kubernetes qui rejette tout ce qui n’est pas signé.

Prérequis

Avant de commencer, assurez-vous d’avoir les éléments suivants :

  • Compte GitHub avec les permissions nécessaires pour créer des dépôts et activer GitHub Actions.
  • Compte de registre de conteneurs — ce lab utilise GitHub Container Registry (GHCR), mais Docker Hub fonctionne également.
  • Docker installé et fonctionnel localement.
  • CLI Cosign installé localement :
# macOS (Homebrew)
brew install cosign

# Ou installation depuis les sources avec Go
go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Vérifier l'installation
cosign version
  • kubectl et Helm installés (pour l’exercice Kyverno).
  • Syft installé (pour l’exercice SBOM) :
brew install syft

Vous aurez également besoin d’une application simple à conteneuriser. Voici une application Go minimale et son Dockerfile que nous utiliserons tout au long du lab.

main.go

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from a signed container!")
	})
	http.ListenAndServe(":8080", nil)
}

Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go build -o server main.go

FROM alpine:3.19
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Configuration de l’environnement

Commencez par créer un dépôt de test et pousser le code de l’application.

Étape 1 — Créer le dépôt

# Créer un nouveau répertoire et initialiser un dépôt Git
mkdir cosign-lab && cd cosign-lab
git init

# Créer l'application Go et le Dockerfile à partir des prérequis ci-dessus
# Puis pousser vers GitHub
git add .
git commit -m "Initial commit: simple Go app"
gh repo create cosign-lab --public --source=. --push

Étape 2 — Créer le workflow sans signature

Avant d’ajouter la signature, créez un workflow de base qui ne fait que construire et pousser l’image. Cela vous donne un point de comparaison pour la suite.

Créez .github/workflows/build.yml :

name: Build and Push (Unsigned)

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Committez et poussez ce workflow. Créez un tag pour le déclencher :

git add .
git commit -m "Add unsigned build workflow"
git push origin main
git tag v0.1.0
git push origin v0.1.0

Une fois le workflow terminé, votre image se trouve dans GHCR — mais elle ne porte aucune signature cryptographique. Quiconque dispose d’un accès en écriture au registre pourrait la remplacer, et rien en aval ne le remarquerait.

Exercice 1 : Signature locale avec une paire de clés

Avant de passer à la signature keyless en CI, il est utile de comprendre les fondamentaux en signant une image localement avec une paire de clés explicite.

Étape 1 — Générer une paire de clés Cosign

cosign generate-key-pair

Cela crée deux fichiers dans votre répertoire courant :

  • cosign.key — la clé privée (chiffrée avec une phrase secrète de votre choix).
  • cosign.pub — la clé publique que vous distribuez aux vérificateurs.

Étape 2 — Construire, pousser et signer l’image

# Construire l'image
docker build -t ghcr.io/<your-username>/cosign-lab:v1 .

# Pousser vers GHCR
docker push ghcr.io/<your-username>/cosign-lab:v1

# Signer l'image avec votre clé privée
cosign sign --key cosign.key ghcr.io/<your-username>/cosign-lab:v1

Remplacez <your-username> par votre nom d’utilisateur GitHub. Cosign vous demandera la phrase secrète définie lors de la génération de la clé.

Étape 3 — Vérifier la signature

cosign verify --key cosign.pub ghcr.io/<your-username>/cosign-lab:v1

Vous devriez obtenir une sortie similaire à :

Verification for ghcr.io/<your-username>/cosign-lab:v1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:abc123..."},"type":"cosign container image signature"},"optional":null}]

Où est stockée la signature ?

Cosign stocke les signatures sous forme d’artefacts OCI dans le même registre, à côté de l’image. Pour une image taguée sha256:abc123, Cosign pousse la signature vers un tag dérivé de ce digest — sha256-abc123.sig. Cela signifie :

  • Aucune infrastructure de stockage de signatures séparée n’est nécessaire.
  • Les signatures accompagnent l’image lorsque vous dupliquez ou répliquez des registres.
  • Les contrôles d’accès au registre s’appliquent aux signatures de la même manière qu’aux images.

La signature par paire de clés fonctionne, mais elle introduit une charge de gestion des clés : vous devez protéger la clé privée, la faire tourner périodiquement et distribuer la clé publique à chaque vérificateur. Dans l’exercice suivant, nous éliminons entièrement cette charge avec la signature keyless.

Exercice 2 : Signature keyless dans GitHub Actions

La signature keyless supprime la nécessité de générer, stocker ou faire tourner des clés de signature. Elle repose à la place sur des certificats à durée de vie courte émis par Fulcio et enregistrés dans le journal de transparence Rekor.

Comment fonctionne la signature keyless

  1. Jeton OIDC — GitHub Actions émet un jeton d’identité OIDC qui prouve l’identité du workflow (dépôt, fichier de workflow, référence, et plus encore).
  2. Certificat Fulcio — Cosign envoie ce jeton OIDC à Fulcio, qui émet un certificat de signature X.509 à durée de vie courte lié à l’identité du workflow.
  3. Signature — Cosign signe le digest de l’image avec la clé privée éphémère correspondant au certificat Fulcio.
  4. Journal de transparence Rekor — La signature et le certificat sont enregistrés dans Rekor afin que quiconque puisse auditer quand et par qui une image a été signée.
  5. Destruction de la clé — La clé privée éphémère est immédiatement détruite. La vérification utilise le certificat et l’entrée Rekor, et non une clé publique à longue durée de vie.

Étape 1 — Créer le workflow de signature

Créez .github/workflows/sign.yml :

name: Build, Push, and Sign

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write   # Required for keyless signing via OIDC

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Détails importants de ce workflow

  • id-token: write — cette permission autorise le runner à demander un jeton OIDC à GitHub, que Fulcio utilise pour émettre le certificat de signature.
  • packages: write — nécessaire pour pousser l’image et sa signature vers GHCR.
  • cosign sign --yes — le flag --yes confirme le mode non interactif (pas d’invite pour le consentement keyless). L’absence de flag --key signifie que Cosign utilise automatiquement la signature keyless.
  • Nous signons par digest (@sha256:...) plutôt que par tag pour garantir que nous signons exactement l’image que nous venons de construire.

Étape 2 — Pousser et déclencher le workflow

git add .github/workflows/sign.yml
git commit -m "Add keyless signing workflow"
git push origin main
git tag v1.0.0
git push origin v1.0.0

Étape 3 — Examiner les logs Actions

Dans l’étape « Sign the image », vous verrez une sortie similaire à :

Generating ephemeral keys...
Retrieving signed certificate...

        The sigstore community wants to hear from you! Connect with us at
        https://links.sigstore.dev/slack-invite

Successfully verified SCT...
tlog entry created with index: 45678901
Pushing signature to: ghcr.io/<your-username>/cosign-lab:sha256-a1b2c3d4.sig

L’image est désormais signée avec un certificat qui la lie cryptographiquement à l’identité de votre workflow GitHub Actions. La signature et le certificat sont enregistrés de manière permanente dans le journal de transparence Rekor.

Exercice 3 : Vérification locale des signatures

La vérification d’une image signée en mode keyless nécessite deux informations : l’identité du certificat (qui a signé) et l’émetteur OIDC (qui a attesté cette identité).

Étape 1 — Vérifier l’image signée

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Sortie en cas de succès :

Verification for ghcr.io/<your-username>/cosign-lab:v1.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
  - The signatures were verified against the specified public key
  - The signature was verified against a valid Fulcio certificate

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:a1b2c3d4..."},"type":"cosign container image signature"},"optional":{...}}]

Étape 2 — Vérifier avec une identité incorrecte (échec attendu)

cosign verify \
  --certificate-identity "https://github.com/attacker/malicious-repo/.github/workflows/build.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Sortie :

Error: no matching signatures:
none of the expected identities matched what was in the certificate

Cela confirme que les signatures sont liées à l’identité. Même si quelqu’un parvient à pousser une signature, elle ne passera pas la vérification à moins d’avoir été signée par le workflow exact que vous spécifiez.

Étape 3 — Vérifier une image non signée (échec attendu)

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v0.1.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v0.1.0

Sortie :

Error: no matching signatures
no signatures found for image

L’image v0.1.0 a été construite avec le workflow sans signature de la section Configuration de l’environnement, donc aucune signature n’existe.

Comprendre les champs du certificat

Lorsque vous vérifiez une signature keyless, Cosign vérifie plusieurs champs intégrés dans le certificat Fulcio :

  • Émetteur (certificate-oidc-issuer) — le fournisseur OIDC qui a authentifié le signataire. Pour GitHub Actions, c’est toujours https://token.actions.githubusercontent.com.
  • Sujet / Identité (certificate-identity) — la référence complète du workflow incluant le dépôt, le chemin du fichier de workflow et la référence Git. Cela lie la signature à un workflow spécifique à un commit ou tag spécifique.
  • Extensions GitHub Workflow — le certificat contient également des extensions OID personnalisées pour le dépôt, le SHA du workflow, l’événement déclencheur et l’environnement du runner. Celles-ci permettent des politiques de vérification granulaires.

Vous pouvez également utiliser la correspondance par expression régulière pour des politiques plus flexibles :

cosign verify \
  --certificate-identity-regexp "https://github.com/<your-username>/cosign-lab/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

C’est utile lorsque vous souhaitez accepter les signatures de n’importe quel workflow d’un dépôt, ou de n’importe quel tag.

Exercice 4 : Vérification dans Kubernetes avec Kyverno

La vérification locale est utile pour le débogage, mais les clusters de production ont besoin d’une application automatisée. Kyverno est un contrôleur d’admission Kubernetes qui peut vérifier les signatures Cosign à chaque requête d’admission de pod.

Étape 1 — Installer Kyverno

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace

Attendez que les pods Kyverno soient prêts :

kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=kyverno -n kyverno --timeout=120s

Étape 2 — Créer la politique de vérification d’images

Enregistrez le contenu suivant sous le nom require-signed-images.yml :

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-cosign-signature
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/<your-username>/cosign-lab:*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"

Appliquez la politique :

kubectl apply -f require-signed-images.yml

Étape 3 — Tester avec une image signée (devrait réussir)

kubectl run signed-app \
  --image=ghcr.io/<your-username>/cosign-lab:v1.0.0 \
  --restart=Never

Sortie attendue :

pod/signed-app created

Étape 4 — Tester avec une image non signée (devrait échouer)

kubectl run unsigned-app \
  --image=ghcr.io/<your-username>/cosign-lab:v0.1.0 \
  --restart=Never

Sortie attendue :

Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/default/unsigned-app was blocked due to the following policies:

require-cosign-signature:
  verify-cosign-signature: |
    image verification failed for ghcr.io/<your-username>/cosign-lab:v0.1.0:
    no matching signatures found

C’est exactement la boucle d’application que vous souhaitez : seules les images signées par votre workflow GitHub Actions de confiance sont autorisées dans le cluster.

Exercice 5 : Attacher un SBOM

Une signature prouve qui a construit l’image. Une attestation SBOM prouve ce qu’elle contient. La combinaison des deux vous donne une chaîne de confiance complète : identité, intégrité et transparence du contenu.

Étape 1 — Générer le SBOM avec Syft

syft ghcr.io/<your-username>/cosign-lab:v1.0.0 -o spdx-json > sbom.spdx.json

Cela analyse les couches de l’image et produit un document JSON au format SPDX listant chaque paquet, bibliothèque et dépendance à l’intérieur de l’image.

Étape 2 — Attacher le SBOM en tant qu’attestation

cosign attest \
  --predicate sbom.spdx.json \
  --type spdxjson \
  --yes \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Comme pour la signature keyless, cela utilise l’identité basée sur OIDC lorsque c’est exécuté dans GitHub Actions ou demande une authentification interactive en local. L’attestation est stockée en tant qu’artefact OCI à côté de l’image.

Étape 3 — Vérifier l’attestation

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

La vérification réussie confirme que le SBOM a été généré et attaché par votre workflow de confiance, et qu’il n’a pas été altéré depuis.

Le pipeline de signature complet

Voici le workflow final qui combine tout : construction, push, signature, génération d’un SBOM et attestation. Enregistrez-le sous .github/workflows/sign-and-attest.yml :

name: Build, Sign, and Attest

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-sign-attest:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Generate SBOM
        run: |
          syft ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
            -o spdx-json > sbom.spdx.json

      - name: Attest SBOM
        run: |
          cosign attest --yes \
            --predicate sbom.spdx.json \
            --type spdxjson \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify signature
        run: |
          cosign verify \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify SBOM attestation
        run: |
          cosign verify-attestation \
            --type spdxjson \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Ce workflow vous offre une chaîne de confiance complète pour chaque release taguée : l’image est signée, son contenu est documenté dans un SBOM, et le SBOM est attesté cryptographiquement — le tout sans gérer une seule clé à longue durée de vie.

Nettoyage

Lorsque vous avez terminé le lab, nettoyez les ressources que vous avez créées.

Supprimer les images de test de GHCR

Naviguez vers https://github.com/<your-username>?tab=packages et supprimez le paquet cosign-lab, ou utilisez le CLI GitHub :

# Lister les versions du paquet
gh api user/packages/container/cosign-lab/versions | jq '.[].id'

# Supprimer chaque version
gh api --method DELETE user/packages/container/cosign-lab/versions/<version-id>

Supprimer Kyverno

kubectl delete clusterpolicy require-cosign-signature
helm uninstall kyverno -n kyverno
kubectl delete namespace kyverno

Supprimer le dépôt de test

gh repo delete <your-username>/cosign-lab --yes

Supprimer les fichiers locaux

cd .. && rm -rf cosign-lab
rm -f cosign.key cosign.pub

Points clés à retenir

  • La signature keyless élimine la gestion des clés. En utilisant les jetons d’identité OIDC de GitHub Actions et les certificats Fulcio à durée de vie courte, vous évitez la charge opérationnelle de génération, stockage, rotation et distribution des clés de signature.
  • Les signatures sont liées à l’identité, pas à une clé. La vérification contrôle qui a signé l’image (quel workflow, dans quel dépôt, à quelle référence) plutôt que quelle clé a été utilisée. Cela rend les politiques plus intuitives et auditables.
  • Le journal de transparence Rekor fournit une piste d’audit inviolable. Chaque signature est enregistrée publiquement, de sorte que vous pouvez prouver quand une image a été signée et détecter toute tentative d’antidater ou de supprimer des signatures.
  • Les contrôleurs d’admission appliquent les politiques de signature au moment du déploiement. Kyverno (ou des alternatives comme Connaisseur ou Sigstore Policy Controller) garantit que les images non signées ou incorrectement signées ne s’exécutent jamais dans votre cluster.
  • Les attestations SBOM étendent la chaîne de confiance. La signature prouve qui a construit l’image ; l’attachement d’un SBOM signé prouve ce qu’elle contient. Ensemble, ils fournissent une provenance complète de la source à l’exécution.
  • Signez par digest, pas par tag. Les tags sont mutables — quelqu’un peut déplacer un tag vers une image différente. Les digests sont des adresses de contenu immuables, donc signer par digest garantit que vous avez signé exactement l’image que vous avez construite.

Prochaines étapes

Continuez à développer vos connaissances en sécurité de la chaîne d’approvisionnement avec ces guides connexes :