Le problème

Prenons une boutique en ligne organisée en monorepo. Les services Symfony order, stock, shipping et billing côtoient des fronts Vue.js, et moon pilote les tâches. À chaque commit, la CI construit une image par service, la pousse dans un registre, et chaque environnement la déploie par digest.

Ce pipeline exécute beaucoup de code que l'équipe n'a pas écrit : actions tierces, images de base, scanner de vulnérabilités, paquets Composer et npm. Ce code tourne avec les droits du pipeline : jeton du registre, identité de signature, parfois accès à la recette. C'est la chaîne d'approvisionnement du logiciel.

Les symptômes se ressemblent d'un dépôt à l'autre :

  • uses: vendor/scan-action@v1 : le tag v1 peut désigner demain un autre commit ;
  • FROM php:8.4-fpm-alpine : deux builds du même commit ne partent pas forcément de la même image ;
  • curl -sSfL https://... | sh installe la dernière version d'un outil, sans vérification ;
  • personne ne sait dire quelles versions de bibliothèques tournent dans l'image order en production.

Ce risque s'est concrétisé. Le 19 mars 2026, un attaquant muni d'identifiants compromis a publié une version piégée de Trivy, le scanner d'Aqua Security (CVE-2026-33634). Il a aussi redirigé 76 des 77 tags de aquasecurity/trivy-action, et tous ceux de aquasecurity/setup-trivy, vers un code qui volait les secrets.

Selon l'avis de sécurité, les workflows qui référençaient ces actions par tag pendant la fenêtre d'exposition ont exécuté ce code. Ceux qui les épinglaient sur le SHA d'un commit sain n'ont pas été touchés, pas plus que les images de Trivy référencées par digest. L'outil chargé de la sécurité était devenu la porte d'entrée.

Les options

Suivre les tags et les dernières versions

  • Ce qu'elle règle : rien à maintenir, les correctifs arrivent seuls.
  • Ce qu'elle coûte : le code exécuté change sans relecture. Un tag déplacé ou une version piégée entre dans le pipeline au build suivant.

Épingler des numéros de version

  • Ce qu'elle règle : chaque mise à jour devient une décision visible dans le dépôt.
  • Ce qu'elle coûte : un tag Git ou un tag d'image reste modifiable par qui contrôle le dépôt de l'éditeur. La version affichée ne garantit pas le contenu.

Épingler des empreintes et automatiser leur mise à jour

  • Ce qu'elle règle : un SHA de commit, un digest d'image ou une somme de contrôle désignent un contenu précis. Un tag réécrit chez l'éditeur ne change plus rien chez vous.
  • Ce qu'elle coûte : des références illisibles sans commentaire, et un flux régulier de pull requests de mise à jour à relire.

Tout reconstruire en vase clos

  • Ce qu'elle règle : outils compilés depuis les sources, dépendances servies par des miroirs internes, aucun téléchargement public pendant le build.
  • Ce qu'elle coûte : une exploitation lourde. Il faut tenir les miroirs, recompiler les outils et suivre soi-même les avis de sécurité.

Notre recommandation

Épingler par empreinte tout ce que la CI exécute, faire passer les dépendances par un proxy, et livrer chaque image signée avec son SBOM.

L'épingle fixe ce qui s'exécute. Un robot propose les mises à jour, la revue de code les accepte : chaque changement de version devient un diff relu.

Ce que la CI exécute Épinglé par Ce que l'épingle empêche
Actions (uses:) SHA de commit complet un tag déplacé vers un autre code
Images de base et de test digest sha256: une image republiée sous le même tag
Outils en ligne de commande version et somme de contrôle un binaire remplacé chez l'éditeur
Paquets Composer et npm lockfile, installé via un proxy une version imprévue, un téléchargement non tracé

Le SBOM (Software Bill of Materials) est l'inventaire des composants d'une image, dans un format standard. Il répond à la question « cette bibliothèque vulnérable tourne-t-elle en production ? » sans reconstruire l'image. La signature, elle, permet au déploiement de refuser une image que la CI n'a pas produite.

Tout ce que la CI exécute est épinglé ; l'image sort signée, avec son SBOM Entrées épinglées Pipeline Livraison Actions tierces uses: actions/checkout@3d3c42e… empêche : un tag déplacé Images de base et de test FROM php:8.4-fpm-alpine@sha256:… empêche : une image republiée Outils : scan, SBOM, signature version et somme de contrôle SHA-256 empêche : un binaire remplacé Dépendances Composer et npm lockfile, installé via un proxy empêche : une version imprévue CI une image par service et par commit audit des dépendances build de l'image SBOM CycloneDX signature du digest Registre shop/order @sha256:… signature attestation SBOM Déploiement par digest vérifie la signature et l'attestation À éviter : des références qui bougent @v1 · :latest · curl … | sh · composer update

CycloneDX ou SPDX

Deux formats de SBOM coexistent. CycloneDX, projet de l'OWASP normalisé par Ecma International (ECMA-424), en est à la version 1.7. SPDX, projet de la Linux Foundation, est normalisé à l'ISO dans sa version 2.2.1 (ISO/IEC 5962:2021) ; sa version courante est la 3.0.1.

Syft et Trivy produisent les deux formats. Le choix compte moins que la constance : un seul format, produit par le même outil à chaque build, pour comparer deux versions d'une image.

Les compromis assumés

Un flux continu de mises à jour. Chaque épingle devient une pull request quand l'éditeur publie. Regroupez-les par écosystème et tenez une cadence, sinon la pile grossit et plus personne ne les lit. Une épingle jamais mise à jour finit par figer une version vulnérable.

Le SHA ne couvre que la référence directe. Une action composite peut appeler d'autres actions par tag. Dans l'incident Trivy, une trivy-action épinglée sur un commit antérieur au 9 avril 2025 récupérait encore setup-trivy par tag, et restait exposée.

La documentation de GitHub ne précise pas si la règle d'épinglage couvre les actions appelées par une action composite : vérifiez-le. La règle ne voit pas non plus un outil que l'action télécharge elle-même : nous lisons donc le action.yml des actions retenues. GitHub prépare un fichier de verrouillage pour ces dépendances transitives, en préversion technique au moment où nous écrivons.

Le proxy devient un point critique. S'il tombe, aucun build ne passe : surveillez-le et sauvegardez-le comme la CI. Il trace les téléchargements et permet de bloquer une version, mais il ne remplace ni l'audit ni la relecture des mises à jour.

La signature sans clé publie l'identité du workflow. En mode keyless, Sigstore inscrit chaque signature dans son journal de transparence public, Rekor, avec un certificat qui nomme le dépôt et le workflow. Pour un dépôt privé ou une forge auto-hébergée en zone isolée, signez plutôt avec une clé gérée dans un KMS (cosign sign --key). Avec cosign 3, --tlog-upload=false est refusé : passez à --signing-config une configuration sans journal, créée par cosign signing-config create.

Le signal qui doit faire réexaminer ces choix : des audits contournés, des mises à jour qui s'empilent, une vérification de signature désactivée « temporairement ». Ce sont les signes d'un dispositif trop bruyant pour être tenu.

Mise en œuvre

Les exemples utilisent GitHub Actions. Le principe vaut ailleurs : GitLab CI accepte un SHA de commit pour ses composants et un digest pour ses images. Azure Pipelines référence ses tâches par version : vérifier les outils par somme de contrôle reste à votre charge.

1. Faire l'inventaire

Listez ce que la CI télécharge et exécute :

grep -rn "uses:" .github
grep -rn "^FROM" --include=Dockerfile services fronts
grep -rn -e "curl" -e "wget" .github/workflows tools

Chaque référence à un tag, à latest ou à une URL sans somme de contrôle est à épingler.

2. Épingler les actions par SHA

Chaque uses: désigne le SHA complet d'un commit, suivi de la version en commentaire : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. Vérifiez que ce SHA vient du dépôt de l'action, et non d'un fork. Le workflow complet figure à l'étape 6.

Activez ensuite la règle « Require actions to be pinned to a full-length commit SHA », au niveau de l'organisation ou du dépôt. Un workflow qui référence une action par tag échoue alors au lancement. Déclarez aussi permissions: contents: read en tête de workflow : chaque job n'élargit le GITHUB_TOKEN que pour ses besoins.

3. Épingler les images par digest

# services/order/Dockerfile (extrait)
FROM composer:2.10@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac AS composer

FROM php:8.4-fpm-alpine@sha256:78cd8de9970a9cd6ff4d98860a94eb5bf37dd2d5776b4785562dbfad01c29d5a
COPY --from=composer /usr/bin/composer /usr/bin/composer

Les images des services de test, comme PostgreSQL dans un job, s'épinglent de la même façon. Dependabot ne les met pas à jour dans un workflow, Renovate si.

Dependabot tient à jour les SHA, les digests des Dockerfile et les lockfiles. Il met à jour le commentaire de version avec le SHA, et regroupe les mises à jour par écosystème :

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ['*']
  - package-ecosystem: docker
    directories: ['/services/*', '/fronts/*']
    schedule:
      interval: weekly

Les blocs composer et npm suivent le même modèle.

Dependabot attend par défaut trois jours avant de proposer une nouvelle version (option cooldown), sauf pour les mises à jour de sécurité. Ce délai réduit le risque d'adopter une version piégée, retirée peu après sa publication. Avec Renovate, le preset helpers:pinGitHubActionDigests épingle les actions de la même façon.

4. Installer les outils avec leur somme de contrôle

#!/usr/bin/env sh
# tools/install-syft.sh : version et somme de contrôle versionnées dans le dépôt
set -eu
SYFT_VERSION=1.52.0
SYFT_SHA256=caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d

curl -sSfLo syft.tar.gz \
  "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"
echo "${SYFT_SHA256}  syft.tar.gz" | sha256sum --check
tar -xzf syft.tar.gz syft

La somme vient du fichier syft_1.52.0_checksums.txt de la release. Ce fichier est signé avec Sigstore : vérifiez sa signature (cosign verify-blob) quand vous relevez la somme, pas à chaque build. Pour les outils installés par proto, .prototools fixe déjà la version ; son fichier de verrouillage, qui ajoute les sommes de contrôle, reste marqué instable.

5. Proxy, lockfiles et audit des dépendances

Un proxy de dépendances est le seul point de passage entre la CI et les registres publics. Nexus Repository Pro ou Cloud et Artifactory gèrent Composer et npm, Verdaccio se limite à npm, Private Packagist sert de miroir Composer. Chaque téléchargement y laisse une trace, et une version suspecte se bloque à un seul endroit.

# services/order : Composer passe par le proxy, plus par packagist.org
composer config repositories.proxy composer https://proxy.example.com/composer/
composer config repo.packagist.org false

# fronts/shop : npm passe par le proxy
npm config set registry https://proxy.example.com/npm/ --location=project

La CI installe depuis les lockfiles, avec composer install et npm ci, jamais composer update ni npm install. Le package-lock.json enregistre l'empreinte (integrity) de chaque archive.

Puis chaque projet déclare une tâche moon audit, que moon ci lance avec les autres vérifications :

# services/order/moon.yml (extrait)
tasks:
  audit:
    command: 'composer audit --locked'
    inputs:
      - 'composer.lock'
    options:
      # La base d'avis change sans que le lockfile change :
      # pas de cache, et un lancement à chaque build.
      cache: false
      runInCI: 'always'

Avec Composer 2.10, composer audit sort avec le code 1 dès qu'un paquet enfreint une politique : avis de sécurité, paquet abandonné ou signalé comme malveillant. Côté front, npm audit échoue dès la première vulnérabilité, --audit-level fixe un seuil, et npm audit signatures vérifie les signatures du registre. Les deux outils interrogent les dépôts configurés : vérifiez que votre proxy relaie les avis et les clés de signature.

6. Produire le SBOM, signer et attester l'image

Le workflow construit l'image de chaque service, puis produit son SBOM, la signe et attache le SBOM sous forme d'attestation signée. La matrice est fixe pour rester lisible ; en pratique, elle reprend les projets affectés calculés par moon dans le workflow de CI.

# .github/workflows/images.yml
name: images
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  image:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        service: [order, stock, shipping, billing]
    permissions:
      contents: read
      id-token: write # signature sans clé : jeton OIDC pour Sigstore
    env:
      IMAGE: registry.example.com/shop/${{ matrix.service }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}
      - id: build
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          file: services/${{ matrix.service }}/Dockerfile
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - run: sh tools/install-syft.sh
      - name: SBOM, signature et attestation du digest
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          ./syft scan "registry:${IMAGE}@${DIGEST}" -o cyclonedx-json=sbom.cdx.json
          cosign sign --yes "${IMAGE}@${DIGEST}"
          cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "${IMAGE}@${DIGEST}"

Nous signons le digest, jamais le tag, comme le demande cosign : un tag peut changer de cible entre le build et la signature. L'action cosign-installer vérifie l'intégrité du binaire qu'elle installe. Trivy produit un document équivalent avec trivy image --format cyclonedx --output sbom.cdx.json "${IMAGE}@${DIGEST}".

7. Vérifier au déploiement, puis rescanner

Une signature que personne ne vérifie n'apporte rien. Avant d'appliquer un nouveau digest, l'agent de déploiement de l'environnement cible contrôle la signature et l'attestation :

# Dans l'environnement cible, avant le déploiement du digest
cosign verify-attestation --type cyclonedx \
  --certificate-identity "https://github.com/ORG/REPO/.github/workflows/images.yml@refs/heads/main" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  "registry.example.com/shop/order@${DIGEST}"

cosign verify, avec les mêmes options, contrôle la signature de l'image. En zone isolée, fournissez à cosign la racine de confiance de Sigstore (--trusted-root). Sur Kubernetes, un contrôleur d'admission peut imposer cette règle à tout le cluster.

Le SBOM sert encore après la livraison. Une tâche planifiée relit avec trivy sbom le SBOM extrait des attestations de l'étape 7 : une CVE publiée ce matin remonte sans reconstruire l'image.

Checklist

  • Chaque uses: pointe vers un SHA de commit complet, avec la version en commentaire.
  • La règle d'épinglage par SHA est activée, et le GITHUB_TOKEN est en lecture seule par défaut.
  • Chaque FROM et chaque image de service de test portent un digest.
  • Chaque outil téléchargé est vérifié par somme de contrôle.
  • Les actions composites retenues ont été lues, dépendances comprises.
  • Dependabot ou Renovate couvre actions, images, Composer et npm, avec une cadence tenue.
  • Composer et npm passent par un proxy ; la CI installe depuis les lockfiles.
  • composer audit et npm audit tournent à chaque build, sans cache.
  • Chaque image est signée et porte une attestation SBOM, sur son digest.
  • Le déploiement refuse une image non signée ou sans attestation.
  • Les SBOM des images en production sont rescannés régulièrement.

Sources