Blog / Sécurité

Cache CI : vos secrets sont-ils lisibles par une PR ?

Une fuite de secrets via le cache de Miri sur GitHub Actions rappelle une règle simple de pipeline. Voici comment auditer vos jobs PHP ou Rust en 30 minutes.

Par Romain Barberi 7 min de lecture 5 sources

Le 21 septembre, l’équipe de sécurité de Rust a publié un avis : la commande cargo miri enregistre toutes les variables d’environnement dans le répertoire target/. Si ce répertoire est mis en cache dans GitHub Actions et que le cache est lisible depuis les pull requests, les secrets passés au job peuvent ressortir dans n’importe quelle PR. Le correctif est annoncé pour la nightly du 22 septembre, l’avis précisant qu’il peut ne pas y être encore disponible. Mais je pense que le vrai sujet n’est pas Miri : c’est une règle de conception de pipeline que la plupart des petites équipes ne s’appliquent pas, et qui vaut autant pour un projet Symfony avec un cache Composer que pour un build Rust.

Ce que dit l’avis, et ce qu’il ne dit pas

Selon l’avis signé Manish Goregaokar pour le Rust Security Response Team, Miri doit conserver certaines variables d’environnement d’un appel à l’autre pour rejouer correctement les builds. Il les écrivait donc toutes dans target/. Le correctif réduit la liste aux variables CARGO_* (hors CARGO_*_TOKEN) et à OUT_DIR.

Vous êtes concerné si vous cumulez trois choses : cargo miri en CI, des secrets exposés en variables d’environnement aux étapes qui le lancent, et un cache de target/ accessible aux pull requests. L’avis ne mentionne pas de CVE, et précise qu’un dépôt a été confirmé vulnérable, sept autres appelant à la prudence. Ce n’est donc pas une catastrophe à grande échelle. Les mesures immédiates recommandées sont de désactiver le cache sur les jobs concernés, de restreindre les secrets aux seules étapes qui en ont besoin, de vider les caches existants et de faire tourner les secrets qui ont pu fuiter.

Je retiens la nature du défaut : aucun exploit sophistiqué. Un outil écrit trop de choses sur disque, et un cache est relu par un contexte moins fiable que celui qui l’a écrit.

Pourquoi le cache est un canal de fuite

La documentation GitHub est explicite. Un run de workflow peut restaurer un cache créé sur sa branche ou sur la branche par défaut, et, pour une pull request, sur la branche de base. La documentation ajoute : « Don’t store sensitive information in a cache. Anyone who can open a pull request against your repository can read the contents of caches in the base branch. » Elle précise aussi que le contenu d’un cache n’est ni signé ni vérifié.

Le raisonnement à garder est donc le suivant : tout ce qui est écrit dans un chemin mis en cache doit être considéré comme lisible par quiconque peut ouvrir une PR. Le problème n’est pas de stocker un secret volontairement (personne ne le fait), mais qu’un outil l’écrive à votre place. Un projet public a d’ailleurs réagi à l’avis en coupant l’enregistrement du cache Rust sur ses builds de release, qui portent des clés de signature, comme le décrit cette pull request : son auteur y explique que Cargo sérialise la valeur des variables déclarées par cargo:rerun-if-env-changed dans target/. Je n’ai pas vérifié ce point dans la documentation de Cargo, prenez-le comme un témoignage de mainteneur.

Le même schéma dans un projet PHP

Sur les pipelines PHP que j’ai rencontrés, la configuration typique met en cache le dossier de cache de Composer (~/.cache/composer sur les runners Ubuntu) ou le dossier vendor/, quelquefois le dossier du projet entier pour gagner du temps. Le cache Composer contient des archives de paquets, ce qui est sans risque. Le reste dépend de ce que le job a écrit avant.

Un exemple concret côté Symfony : la commande composer dump-env prod, recommandée pour la production, compile les fichiers .env* en un .env.local.php. Les variables réellement présentes dans l’environnement n’y sont pas copiées, elles restent lues à l’exécution. En revanche, si la CI écrit des secrets dans un .env.prod.local avant le dump, ils se retrouvent en clair dans le fichier généré. Et si ce fichier atterrit dans un chemin mis en cache, vous venez de recréer le défaut de Miri avec un autre outil. Le même raisonnement s’applique à tout fichier de configuration généré, à un docker buildx qui met en cache des couches, ou à un dossier var/cache réchauffé avec des paramètres sensibles.

La règle : séparer les jobs qui écrivent des caches de ceux qui portent des secrets

GitHub a d’ailleurs commencé à durcir la plateforme de son côté. Depuis le 26 juin 2026, les événements qui peuvent être déclenchés sans droit d’écriture sur le dépôt (pull_request_target, issue_comment, les cascades workflow_run issues d’une PR de fork) reçoivent un jeton de cache en lecture seule quand ils s’exécutent sur la branche par défaut. Les événements push, schedule ou workflow_dispatch gardent la lecture et l’écriture, et pull_request aussi. Le 10 septembre, GitHub a ajouté un réglage cache-mode qui fixe l’accès au cache au niveau du workflow ou du job : read, write, write-only ou none, le réglage du job l’emportant sur celui du workflow. Pour un job de déploiement, none est la valeur naturelle ; la syntaxe exacte est dans la documentation des workflows.

Cela protège contre l’empoisonnement de cache par un tiers, pas contre l’écriture involontaire d’un secret par un outil de confiance. La discipline reste à vous. Voici la structure que je recommande : un job de construction qui écrit le cache sans aucun secret, un job de déploiement qui n’écrit aucun cache.

name: ci
on: [push, pull_request]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    # aucun secret ici : ce job lit et écrit le cache
    steps:
      - uses: actions/checkout@v6
      - id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
      - uses: actions/cache@v5
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: composer-${{ hashFiles('composer.lock') }}
      - run: composer install --no-interaction
      - run: vendor/bin/phpunit

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v6
      - run: ./bin/deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Les numéros de version des actions ci-dessus sont là pour la lisibilité : épinglez celles que vous utilisez réellement, idéalement par empreinte de commit. Ce qui compte est la séparation : le secret n’existe que dans une étape du job deploy, qui ne restaure ni n’enregistre aucun cache.

Audit d’un pipeline en 30 minutes

La liste qui suit tient en une demi-heure sur un dépôt de taille moyenne. C’est celle que je déroule en premier sur une base que je découvre.

  1. Listez tous les usages de cache : grep -rn "actions/cache\|cache:" .github/workflows/, sans oublier les options de cache des actions setup-*.
  2. Pour chaque cache, notez le chemin et demandez-vous quels outils ont écrit dedans avant la sauvegarde.
  3. Listez tous les secrets : grep -rn "secrets\." .github/workflows/, et dites pour chacun quel job et quelle étape le voit.
  4. Repérez les secrets déclarés au niveau du workflow ou du job (env:) plutôt que de l’étape. Descendez-les à l’étape.
  5. Repérez les jobs qui cumulent cache en écriture et secret. C’est le cas à corriger en priorité.
  6. Pour les projets Rust, vérifiez si cargo miri tourne quelque part, et si target/ est mis en cache.
  7. Si un doute existe sur un secret passé dans un job qui écrivait un cache : videz le cache, puis faites tourner le secret. Le vider ne suffit pas si quelqu’un a pu le lire entre-temps.

Les limites

Cette règle a un coût. Séparer les jobs peut allonger un pipeline, et un job de déploiement sans cache repart de zéro sur ses dépendances. Sur un projet où le déploiement est court, c’est négligeable. Sur un build qui a besoin d’un secret pour récupérer des dépendances privées (un jeton de registre Composer privé, par exemple), la séparation demande un peu d’ingénierie : le jeton est nécessaire pendant l’installation, donc pendant l’écriture du cache. Dans ce cas, vérifiez que le jeton n’est pas écrit dans un chemin mis en cache (un fichier auth.json dans le dossier de Composer, par exemple) et préférez un jeton à portée minimale, en lecture seule.

Ensuite, l’avis Miri concerne un cas précis, et il est corrigé. Si vous ne lancez pas Miri, l’article ne prétend pas que votre pipeline fuit. Il dit qu’un pipeline dont vous n’avez jamais listé les secrets et les caches est un pipeline dont vous ne savez pas s’il fuit. Enfin, je n’ai pas reproduit la fuite pour cet article : je m’appuie sur l’avis de l’équipe Rust et sur la documentation de GitHub.

Concrètement, par où commencer

  1. Dès cette semaine, exécutez les deux grep de la section précédente et dressez un tableau job, cache, secrets.
  2. Corrigez d’abord le cas le plus grave : un job qui écrit un cache et voit un secret de déploiement ou de signature.
  3. Si vous utilisez Miri, mettez à jour la nightly et vérifiez la date de vos caches existants ; en cas de doute, videz-les et faites tourner les secrets concernés.
  4. Ajoutez au dépôt une note de trois lignes qui énonce la règle, pour qu’elle survive au prochain changement de mainteneur.
  5. Relisez la question de fond avec votre équipe : qui, aujourd’hui, peut ouvrir une PR sur vos dépôts, et qu’est-ce que votre CI lui permet de lire ?

Sur la plupart des audits de CI, ce qui m’a surpris n’est pas la sophistication d’une faille, c’est la quantité de secrets visibles par des jobs qui n’en avaient pas besoin.

Sources

À lire aussi

Et votre socle, il en est où ?

Audit d’architecture, montée de version PHP ou Symfony, fiabilisation d’un existant : nous travaillons au forfait, avec un périmètre et un prix fixés avant de commencer.