Codecov 2021 : secret dans une couche Docker et fuite des CI
Section intitulée « Codecov 2021 : secret dans une couche Docker et fuite des CI »Le 15 avril 2021, Codecov annonce que son script Bash Uploader, exécuté dans les pipelines d’intégration continue de ses clients, était modifié depuis le 31 janvier. Selon l’éditeur, l’attaquant a obtenu le droit de modifier ce script grâce à un identifiant extrait d’une image Docker mal construite. Le script altéré envoyait toutes les variables d’environnement des pipelines vers un serveur tiers. Il n’y a pas de CVE (Common Vulnerabilities and Exposures) : c’est une compromission de la chaîne d’approvisionnement, réparée par une rotation massive de secrets.
Imagine un serrurier qui pose ta nouvelle porte et oublie un double de la clé sous le paillasson de son atelier. Un cambrioleur le trouve, modifie discrètement les serrures que le serrurier installe chez tous ses clients, et récupère au passage les clés de chaque maison. Cette page raconte comment c’est arrivé à des programmes, et comment ne pas laisser traîner le double.
→ Lire l’explication complète de la marmotte
Chaîne de compromission du Bash Uploader
Section intitulée « Chaîne de compromission du Bash Uploader »Le Bash Uploader servait à téléverser les rapports de couverture de tests depuis une CI (Continuous Integration) vers le service Codecov. Le mode d’installation recommandé tenait en une ligne : bash <(curl -s https://codecov.io/bash). Aucune version figée, aucune vérification d’empreinte : chaque exécution de pipeline téléchargeait la version du jour et l’exécutait avec tous les droits du job. Vingt ans de métier apprennent que « toujours la dernière version » est une politique de mise à jour jusqu’au jour où elle devient une politique de distribution, au service de quelqu’un d’autre.
D’après la communication de l’éditeur, l’enchaînement est le suivant.
Ce schéma se lit comme une chaîne où chaque maillon hérite des droits du précédent : une erreur de construction d’image donne un identifiant, l’identifiant donne l’écriture sur un script, le script donne l’exécution dans des milliers de CI, et l’exécution donne tous les secrets de ces CI. Aucun maillon ne demandait une vulnérabilité logicielle.
La modification publiée dans les analyses tenait en une ligne ajoutée : un appel curl qui postait le résultat de env et de git remote -v vers une adresse externe, suivi de || true pour que l’échec de l’envoi ne fasse jamais échouer le job. Pas d’exploit, pas de charge chiffrée, pas de persistance. Une ligne de shell qu’une revue aurait repérée en dix secondes, si quelqu’un avait relu un script qui change sans prévenir. Personne ne relit un script qui change sans prévenir ; c’est précisément ce que « sans prévenir » veut dire.
La sortie de env dans une CI contient en général : jetons d’accès aux dépôts, clés de fournisseurs cloud, identifiants de registres de conteneurs, jetons de publication de paquets, parfois des clés de signature. Et git remote -v révèle les URL de dépôts, avec un jeton embarqué quand le clone a été fait en https://x-access-token:...@. HashiCorp, Twilio, Rapid7 et Monday.com ont déclaré avoir été touchés ; HashiCorp a notamment indiqué devoir renouveler la clé GPG (GNU Privacy Guard) qui signait ses publications. Reuters a rapporté l’ouverture d’une enquête fédérale américaine. Le mécanisme d’exfiltration ressemble de près à ce que décrit la page sur la pollution des variables d’environnement : l’environnement d’un processus est un fourre-tout que tout code exécuté peut lire en entier.
Secrets de construction : ce que les couches Docker retiennent
Section intitulée « Secrets de construction : ce que les couches Docker retiennent »Le point de départ est une erreur banale de Dockerfile, et c’est le seul enseignement de l’affaire que la plupart des équipes peuvent corriger dès aujourd’hui. Une image OCI (Open Container Initiative) est une pile de couches immuables plus un fichier de configuration. Tout ce qui y entre reste lisible par quiconque peut tirer l’image, et une image publique se tire sans authentification.
Trois instructions laissent un secret dans l’image, chacune à sa manière :
ARG: la valeur passée par--build-argest enregistrée dans l’historique de construction dès qu’une instructionRUNl’utilise.docker history --no-truncl’affiche en clair.ARGn’est pas un mécanisme de secret, c’est un paramètre de build ; la documentation Docker le dit et le lint de BuildKit le signale (SecretsUsedInArgOrEnv).ENV: la variable est écrite dans la configuration de l’image et réapparaît dans chaque conteneur lancé.docker inspectla montre sans même extraire une couche.COPY/ADD: le fichier entre dans une couche. UnRUN rmultérieur crée une nouvelle couche qui masque le fichier, sans l’effacer de la couche précédente. Le fichier reste dans l’archive de l’image, à untar -xprès.
Le multi-stage ne sauve qu’à moitié. Un secret copié dans l’étape builder n’atteint pas l’image finale, mais il vit dans les couches de cette étape. Si la CI pousse le cache avec --cache-to type=registry,mode=max, toutes les couches de toutes les étapes partent dans le registre, secret compris. Même chose si quelqu’un construit avec --target builder et pousse le résultat « pour déboguer ». Le multi-stage est une optimisation de taille, pas un cloisonnement de confidentialité.
La seule réponse correcte au niveau du Dockerfile est RUN --mount=type=secret de BuildKit : le secret est monté en tmpfs (par défaut sous /run/secrets/<id>) pour la durée d’une seule instruction RUN, n’entre dans aucune couche, n’apparaît ni dans l’historique ni dans la configuration. La position de la page est nette : tout secret de construction passe par un montage de secret, ou ne passe pas. Le compromis est mince (une syntaxe plus verbeuse, une dépendance à BuildKit) face à un gain qui se mesure en incidents évités. Le guide de durcissement de Dockerfile de la section guides détaille le reste des règles de construction.
Prérequis de plateforme : BuildKit, activé par défaut depuis Docker Engine 23.0 et dans docker buildx ; la directive # syntax=docker/dockerfile:1 en tête de fichier pour disposer d’un frontend récent. Podman et Buildah acceptent aussi --secret et RUN --mount=type=secret. Kaniko et les constructeurs plus anciens n’ont pas toujours l’équivalent : vérifier avant de migrer une chaîne.
Exemple : détecter un secret dans une image puis le monter proprement
Section intitulée « Exemple : détecter un secret dans une image puis le monter proprement »Le premier bloc montre comment on lit ce qu’une image publiée retient. Il s’exécute contre n’importe quelle image locale ; mon-image:1.0 est à remplacer par l’image auditée.
# 1. Historique de construction : les valeurs d'ARG utilisées par RUN apparaissent icidocker history --no-trunc --format '{{.CreatedBy}}' mon-image:1.0
# 2. Configuration : les variables ENV persistent dans chaque conteneurdocker inspect --format '{{json .Config.Env}}' mon-image:1.0
# 3. Contenu de toutes les couches, y compris les fichiers "supprimés" plus tardmkdir -p /tmp/audit && docker save mon-image:1.0 | tar -x -C /tmp/audit# Chaque couche est une archive tar : chercher des motifs de jetonsfind /tmp/audit -type f -exec sh -c 'tar -tf "$1" 2>/dev/null | sed "s|^|$1: |"' _ {} \; \ | grep -Ei '\.npmrc|\.netrc|credentials|\.pem$|id_rsa'Le second bloc est la version corrigée d’un build qui a besoin d’un jeton de registre de paquets.
# syntax=docker/dockerfile:1FROM node:20-bookworm-slim AS builderWORKDIR /appCOPY package.json package-lock.json ./# Le fichier .npmrc est monté en tmpfs le temps de cette instruction seulement :# il n'entre dans aucune couche, ni dans le cache exporté, ni dans l'historique.RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \ npm ci --omit=devCOPY . .
FROM gcr.io/distroless/nodejs20-debian12COPY --from=builder /app /appCMD ["/app/server.js"]# Le secret vient d'un fichier du runner, jamais d'un --build-argdocker buildx build --secret id=npmrc,src="$HOME/.npmrc" -t mon-image:1.0 .required=true fait échouer le build si le secret manque, au lieu de produire une image construite sans authentification (et donc avec des paquets peut-être résolus ailleurs). Après construction, rejouer le premier bloc : aucune trace de .npmrc ne doit subsister.
Trois pièges restent possibles avec ce montage. Si la commande recopie le secret sur disque (un outil qui écrit le jeton dans un fichier de configuration du projet), la copie entre dans la couche. Si la commande affiche le secret, il finit dans les journaux de build, que les CI conservent souvent des mois. Enfin, le contenu du secret n’entre pas dans la clé de cache de BuildKit : changer le jeton ne reconstruit pas la couche, ce qui est voulu, mais surprend quand on cherche à prouver qu’un build a bien utilisé le nouveau secret.
Surface d’attaque de la CI consommatrice
Section intitulée « Surface d’attaque de la CI consommatrice »Corriger le Dockerfile de l’éditeur empêche la première étape. Les clients, eux, n’ont pas la main sur les Dockerfile de leurs fournisseurs ; ils ont la main sur ce que leur CI exécute et sur ce qu’elle laisse voir. Chaque attaque connue de cette famille a sa contre-mesure.
| Attaque | Contre-mesure côté pipeline | Limite |
|---|---|---|
| Script tiers modifié à la source | Figer une version et vérifier son empreinte SHA-256 (Secure Hash Algorithm) ou sa signature avant exécution | Inutile si l’empreinte est publiée au même endroit que le script |
| Action ou image tierce réétiquetée | Référencer par empreinte (@sha256:..., commit complet) et non par étiquette | Les mises à jour deviennent manuelles |
| Lecture de tout l’environnement du job | Injecter chaque secret dans la seule étape qui en a besoin | Un outil exécuté dans cette étape voit quand même ce secret |
| Jeton statique longue durée exfiltré | Fédération OIDC (OpenID Connect) vers le cloud, jetons de quelques minutes liés au dépôt et à la branche | Le jeton reste exploitable pendant sa durée de vie |
| Envoi vers un serveur arbitraire | Filtrage des sorties réseau du runner par liste d’autorisation | Contournable par un service autorisé détourné |
La vérification d’empreinte mérite qu’on s’y arrête, parce qu’elle a détecté l’incident et qu’elle est souvent mal faite. Elle n’a fonctionné que parce que le hash de référence vivait sur GitHub, hors du stockage compromis. Une empreinte téléchargée depuis le même compartiment que le script prouve seulement que l’attaquant sait aussi écrire un fichier .sha256. L’éditeur a ensuite publié des sommes signées par GPG puis remplacé le script par un binaire signé : c’est la bonne direction, à condition que la clé publique soit épinglée dans la CI et non retéléchargée à chaque run depuis la même origine.
Le compromis central de toute l’affaire se situe ici. Exécuter la dernière version à chaque run, c’est zéro maintenance et une confiance totale dans l’intégrité continue d’un tiers. Figer et vérifier, c’est accepter de traiter chaque mise à jour d’outil comme un changement relu. La page tranche pour le second : un outil de CI tourne avec les secrets de production, son changement de version doit passer par la même revue qu’un changement de code. Condition de validité : disposer d’un mécanisme de mise à jour assisté (Renovate, Dependabot) qui propose les nouvelles empreintes, sinon les versions figées pourrissent et deviennent à leur tour une dette. Le même raisonnement vaut pour les providers d’infrastructure as code et les images référencées par étiquette mutable, traités dans la section cloud.
Le filtrage des sorties réseau du runner est la contre-mesure la plus sous-estimée, parce qu’elle ne dépend d’aucune confiance dans le code exécuté. Sur cette topologie, on regarde ce qui sort du runner : seules les destinations nommées passent par le proxy, tout le reste tombe.
L’appel curl du script altéré visait une adresse IP brute en HTTP : il serait tombé sur un refus journalisé, ce qui transforme une fuite silencieuse de deux mois en alerte du jour même. Le filtrage ne garantit pas l’absence de fuite (un attaquant patient exfiltre vers un service autorisé, un dépôt Git par exemple), mais il élève le coût et produit une trace. Les principes de segmentation correspondants sont détaillés dans la section réseaux.
Ce que ces contre-mesures ne garantissent pas
Section intitulée « Ce que ces contre-mesures ne garantissent pas »RUN --mount=type=secret protège l’image, pas le processus de build : un RUN compromis (dépendance malveillante, script post-installation) lit le secret monté pendant son exécution, exactement comme le Bash Uploader lisait env. Le montage réduit la persistance, pas l’exposition à l’exécution.
L’épinglage par empreinte garantit qu’on exécute ce qu’on a relu, pas que ce qu’on a relu est sain. Une version déjà piégée au moment de l’épinglage passe sans bruit. La relecture du différentiel à chaque montée de version reste le seul contrôle de contenu.
La fédération OIDC supprime les jetons longue durée, mais un attaquant présent dans le job obtient un jeton frais et l’utilise immédiatement. Le gain réel tient au rayon de l’incident : le jeton meurt en minutes et son périmètre suit les conditions de confiance (dépôt, branche, environnement). Si la règle de confiance accepte n’importe quelle branche, le gain disparaît.
Aucune de ces mesures ne remplace un inventaire des secrets exposés à la CI. Sans lui, la rotation après incident devient une chasse au trésor. Les cadres NIST SP 800-190 (sécurité des conteneurs) et NIST SP 800-218 (SSDF, Secure Software Development Framework) couvrent ces deux volets, construction d’image et protection de l’environnement de build, sans prétendre qu’un seul contrôle suffit.
Rotation massive des secrets et conditions d’exploitation
Section intitulée « Rotation massive des secrets et conditions d’exploitation »Sans CVE ni correctif à appliquer, la réponse à un incident de ce type se résume à révoquer et remplacer tout ce que l’environnement compromis a pu voir. La règle de périmètre est stricte : tout secret présent dans l’environnement d’un job ayant exécuté le script pendant la fenêtre d’exposition est réputé compromis, qu’il ait été utilisé ou non par ce job. Raisonner « le job de couverture n’avait besoin que du jeton Codecov » oublie que la CI injecte souvent des secrets au niveau de l’organisation dans tous les jobs.
L’ordre compte, et il se lit sur ce schéma.
Il se lit de haut en bas : on délimite, on remplace, on révoque, puis on cherche des usages frauduleux. Révoquer avant de remplacer casse la production ; remplacer sans révoquer laisse l’attaquant en place. La recherche d’usages passés vient en dernier mais n’est pas optionnelle : un jeton exfiltré depuis deux mois a eu le temps de servir, et les jetons Git ont servi à cloner des dépôts privés, ce qui ajoute à la liste tous les secrets codés en dur dans ces dépôts. Le cas d’une clé de signature est le plus coûteux, parce que la révocation se propage mal : les utilisateurs qui ont épinglé l’ancienne clé publique continuent de lui faire confiance jusqu’à ce qu’ils la changent.
Pour que cette rotation tienne en heures et non en semaines, quelques conditions doivent exister avant l’incident : un coffre de secrets d’où la CI tire les valeurs au lieu de les stocker en variables de projet ; une rotation automatisée et testée pour chaque type de secret (la page sur la gestion de secrets distribuée en décrit l’architecture) ; une conservation des journaux de CI et des journaux d’accès des émetteurs couvrant au moins la fenêtre de détection plausible, ici supérieure à deux mois ; une supervision des appels réseau sortants des runners. Un secret qu’on n’a jamais renouvelé est un secret dont on ne sait pas s’il peut l’être, et le jour de l’incident est le pire moment pour l’apprendre. Le jeton de compte de service Kubernetes souffre du même défaut d’héritage silencieux, voir l’attaque par jeton de service account.
Côté éditeur, la leçon de supervision est double : alerter sur toute écriture dans le stockage d’artefacts publiés (un script de distribution n’a aucune raison d’être réécrit hors du pipeline de publication), et analyser chaque image avant publication avec un détecteur de secrets sur l’ensemble des couches, pas seulement sur le système de fichiers final. Les autres cas de la section failles montrent la même constante : la détection arrive rarement par l’outil prévu à cet effet.
- NIST SP 800-190, NIST.
- NIST SP 800-218, NIST.
🐝 Ce qui arrive si on s’en passe
Section intitulée « 🐝 Ce qui arrive si on s’en passe »Sans montage de secret à la construction, tout identifiant passé par ARG, ENV ou COPY voyage avec chaque copie de l’image et du cache exporté, et toute personne capable de tirer l’image peut l’extraire hors ligne, sans laisser de trace chez l’éditeur. Sans épinglage ni vérification indépendante côté CI, la modification d’un outil tiers s’exécute dès le run suivant avec tous les secrets du job, sans différentiel à relire. Sans filtrage des sorties, l’envoi vers un serveur inconnu ne produit aucun journal, et la fenêtre d’exposition se mesure au hasard d’une détection externe. Sans inventaire ni rotation outillée, le périmètre de compromission ne peut pas être délimité, ce qui oblige à tout renouveler à la main.
🦫 Explication de la marmotte 🍫
Section intitulée « 🦫 Explication de la marmotte 🍫 »Quand des programmeurs modifient leur code, des machines automatiques le testent et le préparent. Pendant ce travail, ces machines reçoivent des mots de passe secrets pour accéder à d’autres services. En 2021, beaucoup d’entreprises faisaient télécharger à ces machines un petit programme de l’entreprise Codecov, à chaque fois, sans vérifier qu’il n’avait pas changé. Codecov fabriquait aussi des « images », des paquets prêts à lancer qui contiennent un programme et tout ce qu’il lui faut. Par erreur, un mot de passe permettant de modifier le petit programme est resté caché dans une de ces images. Une image est faite de plusieurs étages empilés, et effacer un fichier dans un étage du haut ne l’efface pas de l’étage du bas. Un pirate a fouillé les étages, trouvé le mot de passe, puis ajouté une ligne au petit programme. Cette ligne envoyait chez lui tous les secrets des machines qui l’exécutaient. Pendant deux mois, personne n’a rien vu. Un client a fini par comparer une empreinte, sorte de code calculé à partir du fichier, avec celle publiée ailleurs : elles ne correspondaient plus. Toutes les victimes ont dû changer leurs mots de passe. Pour éviter cela, il faut prêter les secrets à la fabrication de l’image seulement le temps d’une étape, sans jamais les écrire dedans, vérifier chaque programme téléchargé, et empêcher les machines d’envoyer des données n’importe où.
À lire aussi
- Écrire un Dockerfile durci avec condensé, multi-étapes et non rootGuide pas à pas pour durcir un Dockerfile naïf avec image de base épinglée par condensé, construction multi-étapes, utilisateur non root, secrets de construction BuildKit et contrôle en intégration continue.
- Shellshock CVE-2014-6271 : exécution de code via l'environnement BashComment Shellshock (CVE-2014-6271) a transformé une variable d'environnement en exécution de code dans Bash, ses vecteurs CGI, DHCP et SSH, et ce qu'il faut en retenir pour l'architecture.
- WannaCry et MS17-010 : fenêtre d'exposition et correctifsWannaCry, EternalBlue et MS17-010 montrent comment les deux mois entre un correctif publié et un correctif déployé deviennent la fenêtre d'exposition d'un parc Windows.
- DigiNotar 2011 : compromission d'une autorité de certificationRécit et analyse de la compromission de l'autorité de certification DigiNotar en 2011, de l'interception de Gmail en Iran à la naissance de Certificate Transparency.
Pages voisines
La citent
- CICD-SEC-6 : hygiène des identifiants insuffisante
- CICD-SEC-8 : usage non gouverné de services tiers
- CICD-SEC-9 : validation d'intégrité des artefacts défaillante
Codecov 2021 : secret dans une couche Docker et fuite des CI
Elle cite
