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
- 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).
- 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.
- Signature — Cosign signe le digest de l’image avec la clé privée éphémère correspondant au certificat Fulcio.
- 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.
- 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--yesconfirme le mode non interactif (pas d’invite pour le consentement keyless). L’absence de flag--keysignifie 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 toujourshttps://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 :
- Signature et vérification d’images container avec Sigstore et Cosign — un guide complet couvrant l’architecture de Cosign, les modèles de vérification avancés et l’intégration avec différents registres et systèmes CI.
- Provenance des artefacts et attestations : de SLSA à in-toto — comprendre l’écosystème de provenance plus large, y compris les niveaux SLSA, les layouts in-toto, et comment les attestations s’intègrent dans une stratégie complète de sécurité de la chaîne d’approvisionnement.