Cache CI : accélérer les builds sans livrer du code douteux
Les caches CI réduisent les temps de build, mais ouvrent aussi la voie au cache poisoning. Méthode concrète pour les isoler, les auditer et les purger.
Un cache CI bien configuré peut faire gagner beaucoup de temps sur des installations de dépendances, la compilation ou la génération d’images intermédiaires. Un cache mal isolé peut aussi faire entrer dans un build de confiance des fichiers produits dans un contexte qui ne l’est pas.
Le sujet n’est pas théorique : un cache est un espace de stockage réutilisé entre plusieurs exécutions. Dès que des branches, des pull requests, des forks, des runners partagés ou des identités distinctes y accèdent, la question devient simple : qui a pu écrire ce que mon pipeline relit ?
Le cache poisoning en CI/CD ne signifie pas forcément qu’un attaquant modifie directement votre dépôt. Il peut viser un cache de dépendances, un répertoire de build, une couche Docker ou un outil téléchargé par le pipeline. Si un job privilégié restaure ensuite cet état sans validation suffisante, la vitesse obtenue devient un risque d’intégrité.
L’objectif n’est donc pas de désactiver tous les caches. Ce serait coûteux, souvent inutile, et cela pousserait parfois les équipes à contourner la CI. L’enjeu est de mettre en place une politique de cache adaptée aux frontières de confiance réelles, sur GitHub Actions, GitLab CI/CD ou Jenkins.
Pourquoi le cache CI redevient un sujet de sécurité
Les pipelines modernes font bien plus que lancer des tests. Ils publient des paquets, construisent des images de conteneur, déploient des applications, signent des artefacts ou utilisent des identités cloud temporaires. Dans ce contexte, un job de CI dispose parfois de permissions importantes, même si ces permissions ne sont accordées que pendant quelques minutes.
Un cache intervient avant une partie de ces étapes. Il est restauré pour éviter de recalculer ou de retélécharger des éléments déjà connus. Cette optimisation modifie la chaîne de confiance : le job ne travaille plus seulement à partir du dépôt et de sources externes vérifiées, mais aussi à partir d’un état local créé lors d’une exécution antérieure.
Cette distinction est importante :
- un artefact de build devrait être identifié, conservé et promu selon une politique explicite ;
- un cache est une optimisation jetable, souvent restaurée automatiquement sur la base d’une clé ou d’un préfixe ;
- un workspace de runner persistant peut, lui aussi, devenir un cache implicite si son nettoyage est incomplet.
La confusion entre ces trois notions est une source classique de problèmes. Un cache ne doit pas servir à transporter le résultat officiel d’une chaîne de livraison. Pour cela, il faut privilégier des artefacts versionnés, un registre de paquets ou un registre d’images, avec les contrôles adaptés. L’article sur la signature des artefacts avec Sigstore couvre justement ce besoin d’intégrité à la livraison.
Les outils de CI documentent eux-mêmes des règles de portée. Par exemple, GitHub Actions associe un cache à une clé, une version de cache et une branche ; les caches de la branche par défaut peuvent être accessibles depuis d’autres branches selon les règles de restauration. GitLab CI/CD propose des clés de cache et des mécanismes de séparation entre branches protégées et non protégées. Ces comportements sont utiles pour la performance, mais ils doivent être compris avant d’ouvrir des workflows à des contributions externes.
Cache, artefact et dépendance : ne pas leur demander la même chose
Le bon premier réflexe est d’identifier exactement ce qui est mis en cache. Tous les contenus n’ont pas le même niveau de risque ni la même valeur.
Les contenus généralement adaptés au cache
- les archives téléchargées par un gestionnaire de paquets, comme le cache de téléchargement de npm, pip, Maven ou Gradle ;
- les dépendances reconstruites à partir d’un fichier de verrouillage ;
- les répertoires de compilation régénérables ;
- les couches de build réutilisables, lorsque leur provenance et leur isolation sont maîtrisées.
Même dans ces cas, l’outil doit pouvoir vérifier ce qu’il installe. Un fichier package-lock.json, npm-shrinkwrap.json, poetry.lock, requirements.txt avec hachages, pom.xml ou gradle.lockfile n’offre pas exactement les mêmes garanties, mais tous permettent de réduire l’incertitude par rapport à une résolution libre des versions.
Les contenus à exclure du cache
- les clés privées, jetons d’accès, fichiers .env, fichiers de configuration cloud ou identifiants de registre ;
- les certificats et informations d’authentification ;
- les artefacts prêts à être publiés ou déployés ;
- les répertoires contenant des secrets potentiellement créés par les outils ;
- les binaires téléchargés sans contrôle de version, de somme de contrôle ou de provenance.
Un cache accessible à une pull request issue d’un fork ne doit jamais contenir de secret. Cette règle reste vraie même si le pipeline ne les affiche pas dans les logs : restaurer une archive donne accès à ses fichiers au job qui l’extrait.
La même prudence s’applique aux caches de Docker BuildKit, aux répertoires node_modules et aux caches de compilation. Ils peuvent contenir des exécutables, des scripts de cycle de vie ou des fichiers générés. Les traiter comme de simples données inoffensives est une erreur. Le verrouillage des dépendances, les contrôles d’intégrité du gestionnaire de paquets et une clé de cache correctement construite sont complémentaires ; aucun de ces mécanismes ne remplace les autres.
Les scénarios concrets de cache poisoning dans un pipeline
Le cache poisoning apparaît lorsqu’un contexte peu fiable peut produire un cache qu’un contexte plus fiable restaurera. La forme exacte dépend de la plateforme, mais les mécanismes sont récurrents.
Une pull request alimente un cache restauré par la branche principale
Supposons une clé trop générale, telle que npm-linux, ou un préfixe de restauration qui accepte tout cache commençant par cette valeur. Un workflow exécuté sur une contribution non approuvée peut écrire un contenu associé à cette famille de clés. Plus tard, une exécution sur la branche principale restaure ce contenu parce qu’elle ne trouve pas de correspondance exacte ou parce que sa logique de repli est trop large.
Le danger augmente avec les clés de type « restore key ». Elles améliorent le taux de réutilisation, mais elles font passer le pipeline d’une correspondance précise à une recherche approximative. Une restauration approximative ne doit jamais traverser une frontière de confiance.
Un workflow privilégié exécute du code contrôlé par une contribution externe
Sur GitHub Actions, la combinaison d’événements, de permissions du GITHUB_TOKEN, de secrets et de code issu d’une pull request doit être examinée avec soin. L’événement pull_request_target s’exécute dans le contexte du dépôt cible ; il n’est pas conçu pour lancer sans précaution du code proposé par un fork. Si ce même workflow lit ou écrit des caches, il faut analyser l’ensemble de la chaîne, pas uniquement le fichier YAML.
Les recommandations de sécurité de GitHub sur les workflows expliquent notamment les risques liés à l’exécution de code non fiable et aux permissions des jetons. Une règle opérationnelle simple est de séparer les workflows de validation non privilégiés des workflows de publication ou de déploiement.
Un runner persistant conserve des fichiers entre deux jobs
Sur Jenkins ou sur des runners auto-hébergés, le risque ne vient pas toujours du mécanisme de cache déclaré dans le pipeline. Un dossier de travail non nettoyé, un volume Docker monté durablement, un répertoire Maven partagé ou un cache de package manager au niveau de la machine peuvent survivre à un job.
Un job non fiable et un job de release qui se succèdent sur la même machine ne devraient pas partager implicitement leur système de fichiers. Les runners éphémères ne résolvent pas tous les problèmes, mais ils réduisent fortement ce type de persistance non maîtrisée.
Une dépendance est réutilisée sans verrouillage suffisant
Le cache ne crée pas nécessairement la dépendance malveillante : il peut simplement la conserver et la diffuser plus longtemps. Si le pipeline installe des versions flottantes, télécharge des scripts à l’exécution ou accepte un cache dont la clé ne change pas après modification du lockfile, il devient difficile de savoir quelle entrée a été utilisée.
Un cache de dépendances doit être invalidé lorsque le descripteur de dépendances change. Sans cela, vous gagnez quelques secondes au prix de builds moins reproductibles et plus difficiles à diagnostiquer.
Cartographier les frontières de confiance avant d’écrire une clé
La clé de cache est une règle de partage. Avant de choisir son format, listez les contextes qui peuvent lire et écrire.
- branche principale et branches protégées ;
- branches de fonctionnalité internes ;
- pull requests provenant de forks ;
- environnements de test, de préproduction et de production ;
- runners hébergés et runners auto-hébergés ;
- architectures et systèmes d’exploitation différents ;
- jobs de test et jobs disposant de permissions de publication.
Ensuite, posez deux questions pour chaque cache : « qui peut écrire ? » et « qui peut restaurer ? ». Si les réponses ne correspondent pas au même niveau de confiance, il faut séparer les espaces de cache.
Un cache partagé est acceptable lorsque les producteurs et les consommateurs ont un niveau de confiance équivalent. Sinon, la performance doit céder la place à l’isolation.
Dans la pratique, une clé robuste incorpore au minimum la plateforme, l’architecture, la version de l’environnement d’exécution et l’empreinte des fichiers de dépendances. Une structure conceptuelle peut ressembler à ceci : environnement de confiance, système, architecture, version du runtime, empreinte du lockfile.
Pour un projet Node.js, l’empreinte de package-lock.json est plus pertinente que celle de l’ensemble du dépôt. Pour un projet Java, les fichiers qui décrivent effectivement la résolution des dépendances doivent guider l’invalidation. Pour Python, il faut distinguer les téléchargements mis en cache par pip des environnements virtuels complets : ces derniers peuvent contenir bien plus que des archives de paquets.
Ajoutez également un préfixe de version à votre politique de cache, par exemple une génération fonctionnelle. Lorsque vous modifiez la structure du cache, un changement de préfixe force une nouvelle famille de clés sans devoir deviner quels anciens contenus restent compatibles.
Isoler GitHub Actions, GitLab CI/CD et Jenkins sans tuer la vitesse
GitHub Actions : clés précises et prudence sur les restaurations de secours
Avec le mécanisme de cache de GitHub Actions, utilisez une clé exacte construite à partir du système, de la version du runtime et du fichier de verrouillage. Réservez les préfixes de restauration aux contextes qui partagent réellement la même frontière de confiance.
Évitez qu’un workflow déclenché par un fork puisse peupler une famille de caches destinée à la branche principale. Dans les workflows exécutant du code de contribution, limitez les permissions du jeton à ce qui est nécessaire et ne leur donnez pas accès à des étapes de publication. La documentation GitHub précise aussi le comportement particulier des caches associés aux pull requests : il faut le tester avec votre modèle de branches, plutôt que de supposer qu’un cache est privé parce que son nom le suggère.
Si vous utilisez des actions tierces, épinglez-les à un commit immuable plutôt qu’à une étiquette mobile lorsque votre niveau d’exigence le justifie. Cette pratique concerne la sécurité de la chaîne CI dans son ensemble, pas seulement le cache. L’outil OpenSSF Scorecard peut aider à identifier plusieurs signaux de maturité sur les dépôts GitHub.
GitLab CI/CD : exploiter la séparation des branches protégées
GitLab CI/CD permet de définir des clés de cache dans .gitlab-ci.yml et documente une option de séparation des caches entre branches protégées et non protégées. Vérifiez explicitement ce paramètre dans votre projet : il est particulièrement utile quand les branches de release ou la branche par défaut sont protégées, tandis que des contributions moins fiables doivent rester isolées.
Une clé fondée sur les fichiers de dépendances est préférable à une clé statique par projet. GitLab propose notamment une syntaxe permettant de dériver une clé de fichiers. Cela évite de restaurer les mêmes dépendances après une modification de lockfile, tout en conservant les gains de cache tant que la résolution reste identique.
Les mécanismes de fallback doivent être utilisés avec parcimonie. Un fallback depuis une branche de fonctionnalité vers une branche protégée peut être acceptable en lecture pour des données strictement non sensibles et vérifiables, mais l’écriture en retour ne doit pas transformer cette relation en canal de contamination.
Jenkins : traiter le système de fichiers comme une surface de sécurité
Jenkins n’impose pas un modèle unique de cache. Les équipes utilisent des workspaces, des volumes, des caches locaux de gestionnaires de paquets, des agents statiques ou éphémères, et parfois un stockage externe. Cette souplesse demande une discipline supplémentaire.
Privilégiez des agents jetables pour les builds non fiables et nettoyez le workspace entre des exécutions de niveaux de confiance différents. Isolez les répertoires de cache par projet, par branche protégée si nécessaire et par identité de build. Ne transformez pas un volume partagé entre tous les agents en cache universel sans contrôle d’accès ni durée de rétention.
La fonctionnalité stash de Jenkins est destinée au transfert de fichiers entre étapes d’un pipeline, pas à la conception d’un cache global persistant. Pour les dépendances, un dépôt de paquets tel que Nexus Repository ou JFrog Artifactory, correctement configuré, fournit souvent une frontière plus explicite qu’un répertoire partagé sur un agent.
Une politique de cache pragmatique : durée de vie, audit et purge
Un cache n’a pas vocation à vivre indéfiniment. Plus sa durée de vie est longue, plus il devient difficile de relier son contenu à son producteur, à un lockfile et à une configuration de pipeline précise. La bonne durée dépend de la fréquence des builds, du coût de reconstruction et de la sensibilité du contenu.
Au lieu de chercher une valeur universelle, définissez des règles par catégorie :
- caches de téléchargements de dépendances : durée modérée, clé liée au lockfile, contrôle d’intégrité actif ;
- caches de compilation : durée courte à modérée, clé liée au compilateur, à la plateforme et aux sources pertinentes ;
- couches de build d’images : isolation par dépôt et par niveau de confiance, avec attention particulière aux registres et aux identifiants ;
- workspaces de runners : nettoyage systématique ou destruction de l’agent après le job lorsque le contexte est non fiable.
L’audit doit permettre de répondre à des questions simples : quels caches existent, quand ont-ils été créés, quelle clé utilisent-ils, quel workflow les produit et qui peut les restaurer ? GitHub expose des informations et des opérations de gestion des caches via son interface, son CLI et son API. GitLab permet également de gérer les caches de runner au niveau du projet. Sur Jenkins, l’audit dépendra surtout du plugin, du stockage et de l’infrastructure retenus : c’est une raison supplémentaire de documenter l’architecture.
Préparez une procédure de purge avant d’en avoir besoin. Elle doit indiquer :
- qui peut supprimer les caches ;
- comment purger une clé, une famille de clés ou tout un projet ;
- comment invalider les caches après un incident sur une dépendance ou un runner ;
- comment confirmer qu’un pipeline reconstruit bien son état sans le cache ;
- comment éviter qu’un cache supprimé soit immédiatement recréé par un workflow non fiable.
Cette dernière étape est souvent oubliée. Purger un cache contaminé ne suffit pas si la règle qui autorisait sa création reste en place. La correction doit porter sur les permissions, la portée des clés, les conditions d’écriture et l’isolation des runners.
Tester le cache comme une partie du pipeline, pas comme un détail d’implémentation
Une politique de cache n’est utile que si elle est vérifiée dans des conditions proches du réel. Ajoutez des tests de fonctionnement simples à vos revues de pipeline.
- Modifiez un lockfile et vérifiez qu’un nouveau cache est créé ou que le cache précédent n’est pas utilisé comme état final.
- Déclenchez une pull request depuis un fork de test et observez quels caches elle peut restaurer ou écrire.
- Exécutez un build sans cache pour vous assurer que la chaîne reste reproductible.
- Contrôlez les logs : ils doivent montrer une restauration ou une absence de cache sans exposer de chemin contenant des secrets.
- Simulez la purge d’une famille de caches dans un environnement non critique.
Surveillez aussi les symptômes moins évidents : un build qui réussit seulement grâce à un cache ancien, des différences entre runner auto-hébergé et runner hébergé, ou une dépendance présente localement alors qu’elle n’est plus décrite dans les fichiers de projet. Ces écarts révèlent souvent un cache trop large ou un workspace insuffisamment nettoyé.
Le même raisonnement s’applique aux scripts. Un job idempotent et explicite se remet plus facilement d’une purge qu’un script dépendant d’un état local invisible. Pour aller plus loin sur ce point, consultez notre guide sur les jobs idempotents et la réduction des erreurs humaines.
Conclusion : viser un cache rapide, reconstructible et non privilégié
Le cache CI est un excellent levier d’efficacité lorsqu’il accélère des données régénérables et vérifiables. Il devient dangereux lorsqu’il traverse silencieusement une frontière entre code non fiable et pipeline privilégié.
La méthode la plus réaliste consiste à séparer les caches selon les niveaux de confiance, à lier les clés aux dépendances et à l’environnement réel, à limiter les restaurations approximatives, puis à prévoir audit et purge. Commencez par cartographier un pipeline sensible, en particulier celui qui publie ou déploie : quelques clés de cache trop génériques suffisent parfois à révéler un compromis de sécurité évitable.