Générer de la provenance SLSA ne suffit pas. Sans vérification systématique, ces attestations restent des documents ignorés. Le vrai gain de sécurité vient de la mise en application : bloquer automatiquement tout artefact qui ne respecte pas vos exigences de provenance.
Ce guide montre comment implémenter une chaîne de vérification complète : d'abord en CI (avant publication), puis au déploiement (admission Kubernetes), avec une gestion rigoureuse des exceptions.
Quand vérifier la provenance ?
Section intitulée « Quand vérifier la provenance ? »La vérification de provenance s'applique à deux points de contrôle complémentaires :
┌─────────────────────────────────────────────────────────────────────────┐│ Pipeline CI/CD ││ ┌──────────┐ ┌──────────┐ ┌────────────────┐ ┌────────────┐ ││ │ Build │───>│ Generate │───>│ Push image + │───>│ Verify │ ││ │ artifact │ │provenance│ │ attestation │ │ before tag │ ││ └──────────┘ └──────────┘ └────────────────┘ │ "release" │ ││ └────────────┘ │└────────────────────────────────────────────┬────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────────────┐│ Kubernetes cluster ││ ┌──────────────────────────────────────────────────────────────────┐ ││ │ Admission Controller (Kyverno / Gatekeeper) │ ││ │ ┌─────────────┐ │ ││ │ │ Pod create │──> Verify provenance ──> PASS ──> Allow deploy │ ││ │ │ request │ └── FAIL ──> Block + log │ ││ │ └─────────────┘ │ ││ └──────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘| Point de contrôle | Objectif | Outils |
|---|---|---|
| CI pipeline | Empêcher la publication d'artefacts sans provenance valide | slsa-verifier, cosign |
| Admission Kubernetes | Bloquer le déploiement d'images non conformes | Kyverno, Gatekeeper, Connaisseur |
Vérification en pipeline CI
Section intitulée « Vérification en pipeline CI »Le pipeline est le premier endroit où refuser un artefact, parce qu'il connaît le contexte de build : dépôt source, tag, identité du builder. Deux outils couvrent ce besoin, slsa-verifier pour les provenances produites par les générateurs SLSA, cosign pour les attestations signées via Sigstore. Dans les deux cas, la vérification doit précéder la promotion de l'image vers un tag de release : sinon rien n'empêche de publier d'abord et de constater ensuite.
Avec slsa-verifier
Section intitulée « Avec slsa-verifier »L'outil slsa-verifier du SLSA Framework vérifie cryptographiquement qu'une provenance provient bien d'un builder SLSA connu.
name: Verify and Release
on: push: tags: ['v*']
permissions: {}
jobs: verify-provenance: runs-on: ubuntu-latest permissions: contents: read packages: write # requis uniquement par le `crane tag` final
steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false
- name: Install slsa-verifier uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6 # v2.7.1
- name: Verify container image provenance env: IMAGE: ghcr.io/${{ github.repository }}:${{ github.ref_name }} run: | slsa-verifier verify-image "$IMAGE" \ --source-uri "github.com/${{ github.repository }}" \ --source-tag "${{ github.ref_name }}" \ --builder-id "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0"
- name: Promote to release tag if: success() run: | # Tag l'image comme release uniquement après vérification crane tag "$IMAGE" "release-${{ github.ref_name }}"verify-provenance: stage: verify image: alpine:3.20 before_script: - apk add --no-cache curl jq # Empreinte SHA256 de l'asset slsa-verifier-linux-amd64 de la release v2.7.1 - SLSA_VERIFIER_SHA256=946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 - curl -sSfL https://github.com/slsa-framework/slsa-verifier/releases/download/v2.7.1/slsa-verifier-linux-amd64 -o slsa-verifier - echo "${SLSA_VERIFIER_SHA256} slsa-verifier" | sha256sum -c - - install -m 0755 slsa-verifier /usr/local/bin/slsa-verifier script: - | slsa-verifier verify-image "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" \ --source-uri "github.com/$CI_PROJECT_PATH" \ --builder-id "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0" rules: - if: $CI_COMMIT_TAGAvec cosign verify-attestation
Section intitulée « Avec cosign verify-attestation »Pour les attestations signées avec Sigstore (keyless), utilisez cosign :
# Vérifier qu'une image possède une attestation SLSA signée par GitHub Actionscosign verify-attestation \ ghcr.io/mon-org/mon-app:v1.0.0 \ --type slsaprovenance \ --certificate-identity-regexp "^https://github.com/mon-org/mon-app/.github/workflows/build.yml@.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
# Extraire et inspecter le contenu de la provenancecosign verify-attestation \ ghcr.io/mon-org/mon-app:v1.0.0 \ --type slsaprovenance \ --certificate-identity-regexp "^https://github.com/mon-org/mon-app/.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ | jq -r '.payload' | base64 -d | jq '.predicate'Vérification au déploiement Kubernetes
Section intitulée « Vérification au déploiement Kubernetes »Le contrôle en CI ne couvre que les images passées par le pipeline officiel. Un kubectl apply manuel, un opérateur qui déploie une image tierce ou un pipeline parallèle le contournent sans effort. Le contrôleur d'admission ferme cette porte : il intercepte chaque requête de création de Pod avant que l'objet ne soit persisté dans etcd, et refuse celles dont l'image n'a pas d'attestation valide. Ce contrôle s'applique donc quelle que soit la manière dont le Pod a été demandé.
Kyverno ImageValidatingPolicy
Section intitulée « Kyverno ImageValidatingPolicy »Kyverno permet de définir des policies déclaratives pour vérifier les images à l'admission :
apiVersion: policies.kyverno.io/v1kind: ImageValidatingPolicymetadata: name: verify-slsa-provenance annotations: policies.kyverno.io/title: "Verify SLSA Provenance" policies.kyverno.io/category: "Supply Chain Security" policies.kyverno.io/severity: high policies.kyverno.io/description: >- Vérifie que les images de conteneurs possèdent une attestation SLSA valide signée par un workflow GitHub Actions autorisé.spec: validationActions: [Deny] matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["pods"] matchImageReferences: - glob: "ghcr.io/mon-org/*" attestors: - name: slsa-keyless cosign: keyless: identities: - subjectRegExp: "^https://github.com/mon-org/.*/\\.github/workflows/" issuer: "https://token.actions.githubusercontent.com" validations: - expression: "true" message: "Image must have a valid SLSA provenance attestation."Déploiement progressif de la policy
Section intitulée « Déploiement progressif de la policy »Passer directement en blocage sur un cluster existant revient à casser les déploiements de toutes les équipes qui n'ont pas encore de provenance. Les quatre étapes ci-dessous font varier deux réglages seulement : validationActions, qui décide entre observer et refuser, et matchConditions, qui restreint le périmètre aux namespaces choisis.
-
Mode Audit : Observez sans bloquer
spec:validationActions: [Audit]Collectez les violations dans les PolicyReports pendant 1-2 semaines.
-
Analysez les violations
Fenêtre de terminal kubectl get policyreport -A -o json | jq '.items[].results[] | select(.result == "fail")' -
Mode Deny sur staging : appliquez une
matchConditionpour cibler les namespacesspec:validationActions: [Deny]matchConditions:- name: staging-onlyexpression: "object.metadata.namespace == 'staging'" -
Extension progressive à production
spec:matchConditions:- name: staging-and-prodexpression: "object.metadata.namespace in ['staging', 'production']"
Gestion des exceptions (break-glass)
Section intitulée « Gestion des exceptions (break-glass) »Même la meilleure policy nécessite un mécanisme d'exception pour les urgences. L'objectif : permettre le contournement sans le normaliser.
Stratégie break-glass avec Kyverno
Section intitulée « Stratégie break-glass avec Kyverno »Une PolicyException désactive une règle nommée pour un ensemble de ressources précis. Le point à retenir : Kyverno ne fait pas expirer une exception tout seul. L'annotation expiry du manifeste ci-dessous est purement informative, c'est à vous de supprimer l'objet ou de programmer sa suppression. Les champs names et namespaces sont ce qui limite réellement la portée.
apiVersion: kyverno.io/v1kind: PolicyExceptionmetadata: name: emergency-deploy-incident-12345 namespace: kyverno labels: exception-type: break-glass created-by: alice@example.com incident-id: "12345" annotations: expiry: "2026-02-10T12:00:00Z" # Expire dans 24h reason: "Hotfix critique CVE-2026-XXXX, provenance sera ajoutée post-incident" approved-by: "security-team@example.com"spec: exceptions: - policyName: verify-slsa-provenance ruleNames: - verify-slsa-keyless match: any: - resources: kinds: - Pod namespaces: - production names: - "hotfix-*" # Limite aux pods hotfixWorkflow d'exception automatisé
Section intitulée « Workflow d'exception automatisé »Créer l'exception à la main en pleine astreinte produit systématiquement un objet trop large et non tracé. Le workflow ci-dessous impose au contraire un formulaire : durée choisie dans une liste fermée, identifiant d'incident obligatoire, et une environment protégée qui exige une approbation avant exécution. Il notifie enfin le canal sécurité, ce qui rend l'exception visible au moment où elle est créée et non lors de l'audit trimestriel.
name: Break-Glass Request
on: workflow_dispatch: inputs: namespace: description: 'Target namespace' required: true type: choice options: [staging, production] pod_pattern: description: 'Pod name pattern (e.g., hotfix-*)' required: true duration_hours: description: 'Exception duration (hours)' required: true default: '4' type: choice options: ['1', '4', '12', '24'] reason: description: 'Reason for exception' required: true incident_id: description: 'Incident/ticket ID' required: true
permissions: {}
jobs: create-exception: runs-on: ubuntu-latest environment: production-breakglass # Requires approval permissions: contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false
- name: Calculate expiry id: expiry run: | EXPIRY=$(date -u -d "+${{ inputs.duration_hours }} hours" +%Y-%m-%dT%H:%M:%SZ) echo "expiry=$EXPIRY" >> $GITHUB_OUTPUT
- name: Create PolicyException run: | cat <<EOF | kubectl apply -f - apiVersion: kyverno.io/v1 kind: PolicyException metadata: name: breakglass-${{ inputs.incident_id }} namespace: kyverno labels: exception-type: break-glass created-by: ${{ github.actor }} incident-id: "${{ inputs.incident_id }}" annotations: expiry: "${{ steps.expiry.outputs.expiry }}" reason: "${{ inputs.reason }}" workflow-run: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" spec: exceptions: - policyName: verify-slsa-provenance ruleNames: ["verify-slsa-keyless"] match: any: - resources: kinds: [Pod] namespaces: ["${{ inputs.namespace }}"] names: ["${{ inputs.pod_pattern }}"] EOF
- name: Notify Security Channel uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0 with: channel-id: 'security-alerts' slack-message: | :warning: *BREAK-GLASS EXCEPTION CREATED* • Namespace: `${{ inputs.namespace }}` • Pattern: `${{ inputs.pod_pattern }}` • Duration: ${{ inputs.duration_hours }}h • Reason: ${{ inputs.reason }} • Incident: ${{ inputs.incident_id }} • Created by: ${{ github.actor }} • Expires: ${{ steps.expiry.outputs.expiry }}Enregistrement et audit des décisions
Section intitulée « Enregistrement et audit des décisions »Chaque décision de vérification doit être tracée pour la conformité et le debugging.
Collecte des PolicyReports
Section intitulée « Collecte des PolicyReports »Les PolicyReport vivent dans le cluster et suivent le cycle de vie des ressources qu'ils décrivent : un Pod supprimé emporte son rapport. Pour garder une trace exploitable en audit, il faut les exporter vers un stockage externe. Le CronJob ci-dessous les pousse chaque heure vers un SIEM ; adaptez l'URL et l'authentification à votre collecteur.
apiVersion: batch/v1kind: CronJobmetadata: name: export-policy-reports namespace: kyvernospec: schedule: "0 * * * *" # Toutes les heures jobTemplate: spec: template: spec: containers: - name: export image: bitnamilegacy/kubectl:1.33.4 command: - /bin/sh - -c - | kubectl get policyreport -A -o json | \ curl -X POST -H "Content-Type: application/json" \ -d @- https://siem.example.com/api/kyverno-reports restartPolicy: OnFailureMétriques de vérification
Section intitulée « Métriques de vérification »Exposez des métriques Prometheus pour monitorer la santé de vos policies :
apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: kyverno-metrics namespace: monitoringspec: selector: matchLabels: app.kubernetes.io/name: kyverno endpoints: - port: metrics path: /metrics interval: 30sMétriques clés à surveiller :
| Métrique | Description | Alerte si |
|---|---|---|
kyverno_policy_results_total{result="fail"} | Violations bloquées | Spike soudain |
kyverno_policy_execution_duration_seconds | Latence de vérification | > 5s |
kyverno_admission_review_duration_seconds | Temps de réponse admission | > 10s |
Dashboard de décisions
Section intitulée « Dashboard de décisions »Trois requêtes suffisent à un tableau de bord utile : le taux de refus par policy, la répartition pass/fail par namespace, et le nombre d'exceptions break-glass actives. Cette dernière est la plus parlante en revue : si le compteur ne redescend jamais à zéro, les exceptions sont devenues permanentes et la policy ne protège plus rien.
# Taux de violations par policysum by (policy) (rate(kyverno_policy_results_total{result="fail"}[1h]))
# Ratio pass/fail par namespacesum by (namespace, result) (rate(kyverno_policy_results_total[24h]))
# Exceptions break-glass activescount(kyverno_policy_exception_total{type="break-glass"})Matrice de décision par environnement
Section intitulée « Matrice de décision par environnement »Une même policy ne se règle pas de la même façon selon l'environnement : le coût d'un blocage injustifié est nul en dev et élevé en production. Cette matrice donne un point de départ raisonnable, à lire ligne par ligne comme un contrat entre l'équipe plateforme et les équipes applicatives. La colonne Notification compte autant que la colonne Mode : un refus silencieux se transforme en ticket incident dans l'heure.
| Environnement | Mode | Exceptions | Notification |
|---|---|---|---|
| dev | Audit | Toutes autorisées | Log uniquement |
| staging | Enforce | Via PR reviewée | Slack #deploy |
| production | Enforce strict | Break-glass + approbation SecOps | PagerDuty + SIEM |
Dépannage courant
Section intitulée « Dépannage courant »Trois symptômes couvrent la quasi-totalité des appels après une mise en blocage. Le premier vient de l'artefact, le deuxième du réseau, le troisième de l'installation de Kyverno. Commencez toujours par déterminer lequel des trois avant de toucher à la policy elle-même.
Image rejected: attestation not found
Section intitulée « Image rejected: attestation not found »L'attestation n'est pas dans le manifeste de l'image mais dans un artefact rattaché ; ces deux commandes montrent ce que le registry expose réellement pour le tag visé.
# Vérifiez que l'attestation existecosign tree ghcr.io/mon-org/mon-app:v1.0.0
# Listez les attestations attachéescrane manifest ghcr.io/mon-org/mon-app:v1.0.0 | jq '.manifests[] | select(.annotations["org.opencontainers.image.ref.name"] | contains("att"))'Causes fréquentes :
- Le pipeline de build n'a pas généré de provenance
- L'attestation est sur un tag différent (
:v1.0.0vs:sha-abc123) - Le registry ne supporte pas les referrers OCI
Verification timeout
Section intitulée « Verification timeout »La vérification interroge le registry et le log de transparence pendant l'admission, donc sur le chemin critique de création du Pod. Le timeout du webhook vaut 10 secondes par défaut, ce qui est court dès que le cluster sort par un proxy.
# Augmenter le timeout dans la policyspec: webhookTimeoutSeconds: 60 # Défaut: 10Causes fréquentes :
- Rekor (transparency log) temporairement lent
- Réseau cluster → Internet filtré
- Registry externe lent
Policy not enforced
Section intitulée « Policy not enforced »Une policy qui laisse tout passer signale presque toujours que le webhook n'est plus enregistré ou que le contrôleur d'admission ne répond pas. Ces trois commandes vérifient successivement les Pods de Kyverno, l'enregistrement du webhook auprès de l'API server, puis les journaux du contrôleur.
# Vérifier que Kyverno est opérationnelkubectl get pods -n kyverno
# Vérifier les webhookskubectl get validatingwebhookconfigurations | grep kyverno
# Logs du controllerkubectl logs -n kyverno -l app.kubernetes.io/component=admission-controller --tail=100À retenir
Section intitulée « À retenir »Double vérification
Vérifiez en CI (avant publication) et à l'admission (avant déploiement). Une seule vérification peut être contournée.
Policy as Code
Les policies Kyverno sont versionnées en Git. Changements = PR + review.
Break-glass encadré
Exceptions possibles mais : durée limitée, scope minimal, traçabilité complète.
Observabilité
Chaque décision (pass/fail/exception) doit être loggée et monitorable.