Le 4 août 2026, le compte GitHub du mainteneur de keyv et de l’écosystème cacheable — deux briques totalisant plus de 150 millions de téléchargements hebdomadaires cumulés — a été compromis. Dans les heures qui ont suivi, une variante du malware Shai-Hulud a été publiée sur npm. Le décompte des versions de paquets affectées a atteint 2 236 en une journée, effet mécanique de l’auto-propagation : chaque développeur ou runner CI installant un paquet infecté devient à son tour un vecteur de publication.
Ce n’est pas un incident isolé. Les analyses de Chainguard et de Semgrep convergent : il s’agit de la troisième vague du même outillage en 2026, après PyTorch Lightning sur PyPI en avril et l’écosystème @antv en mai. Le mode opératoire est réutilisé d’un registre à l’autre avec des adaptations minimes.
Note méthodologique. Les horaires ci-dessous sont donnés en écarts relatifs plutôt qu’en horodatages absolus : la reconstruction publique varie encore selon les sources. Utilisez vos propres journaux CI comme référence de temps.
Chronologie du 4 août 2026
- T0 — Prise de contrôle du compte mainteneur. Les indicateurs publics pointent vers un vol de session/jeton plutôt qu’un contournement de la 2FA : les premières actions sont des appels API GitHub, pas des connexions interactives.
- T0 + ~20 min — Un commit est poussé directement sur
mainde plusieurs dépôts. Le diff est volontairement anodin : ajout ou modification d’un scriptpreinstalldanspackage.json, plus un fichier utilitaire d’apparence légitime à la racine. - T0 + ~35 min — Un workflow GitHub Actions modifié (ou déjà présent et déclenché par le push) publie sur npm en réutilisant les identifiants du dépôt. Les premières versions patch sortent, numérotées de façon à être capturées par les plages
^et~existantes. - T0 + 1 h à T0 + 6 h — Phase d’auto-propagation. Chaque installation sur un poste ou un runner disposant d’un jeton npm de publication entraîne la republication de nouveaux paquets. La courbe du nombre de versions affectées est exponentielle sur cette fenêtre.
- T0 + ~8 h — Détections publiques, retraits de versions par npm et invalidation des jetons du mainteneur. La propagation s’arrête, mais les paquets déjà présents dans les caches CI et les registres proxy internes, eux, ne disparaissent pas.
Le point saillant est la fenêtre : plusieurs heures pendant lesquelles un npm install parfaitement banal suffisait à être touché. Un pipeline nocturne planifié à 3 h du matin n’a besoin d’aucune erreur humaine pour tomber dedans.
Comment fonctionne le payload
La famille Shai-Hulud repose sur un enchaînement stable depuis 2025, dont cette variante ne s’écarte guère.
1. Le hook d’installation. Le package.json publié contient un preinstall (ou postinstall) qui invoque un script de bootstrap — les variantes précédentes utilisaient des noms comme setup_bun.js, celle-ci un setup.mjs. Ce hook s’exécute avant même que votre code ne tourne, y compris dans un npm ci de pipeline.
2. Le téléchargement d’un runtime tiers. Le bootstrap ne contient presque pas de logique malveillante. Sa mission est d’installer un runtime JavaScript alternatif (Bun dans les vagues précédentes) depuis une source légitime, puis d’exécuter le vrai payload avec ce runtime. L’intérêt est double : contourner les protections qui n’instrumentent que node, et faire passer le téléchargement pour une opération d’outillage banale.
3. La collecte. Le payload balaie les variables d’environnement, les fichiers de configuration (~/.npmrc, ~/.aws/credentials, ~/.docker/config.json, ~/.config/gh/hosts.yml), et exécute un scanner de secrets de type TruffleHog sur l’arborescence. Sur un runner CI, cela signifie : jeton npm de publication, GITHUB_TOKEN du job, jeton d’accès cloud obtenu par OIDC, éventuels secrets applicatifs injectés dans l’environnement.
4. L’exfiltration mimétique. C’est la partie la plus retorse. Plutôt qu’un appel vers une infrastructure attaquant facilement bloquée, les variantes Shai-Hulud publient les données dans des dépôts GitHub publics créés sous le compte de la victime, avec un contenu encodé en base64 imbriqué. Le trafic sortant se résume donc à des appels vers api.github.com et registry.npmjs.org — exactement ce qu’un runner de build fait toute la journée. Aucune règle egress naïve ne le verra.
5. La propagation. Avec le jeton npm récupéré, le payload énumère les paquets publiables par la victime, y réinjecte le hook et publie une nouvelle version. Le ver boucle.
Filiation : ce que nous apprennent avril et mai
La vague PyPI d’avril (PyTorch Lightning) et la vague @antv de mai partagent avec celle-ci trois invariants :
- Compromission du compte mainteneur, pas du code. Aucune vulnérabilité applicative n’est exploitée. L’attaque vise l’identité qui a le droit de publier.
- Abus du mécanisme de publication automatisée. Le CI du projet est utilisé comme rampe de lancement, ce qui donne aux artefacts malveillants la même apparence que les artefacts légitimes.
- Exfiltration par des services de confiance. GitHub, npm, parfois des services d’objets cloud publics. Le canal est indissociable du trafic normal.
Ce qui change d’une vague à l’autre : le langage, le nom des fichiers, le runtime téléchargé. Autrement dit, détecter par indicateur de compromission (IoC) vous protège de la vague précédente, pas de la suivante. Les contrôles doivent porter sur le comportement (exécution de scripts à l’installation, création de dépôts, publication non planifiée) et sur la structure (qui peut publier, avec quel jeton, depuis quel contexte).
Suis-je touché ? Commandes d’audit
Commencez par la question exacte : une version malveillante a-t-elle pu être résolue par un de mes environnements entre T0 et T0 + 8 h ?
# 1. Le paquet est-il présent, et à quelle version résolue ?
npm ls keyv cacheable --all 2>/dev/null
# Avec pnpm / yarn
pnpm why keyv
yarn why keyv
Le lockfile est la source de vérité pour ce qui a réellement été installé, mais le package.json dit ce qui aurait pu l’être :
# Plages réellement acceptées par vos manifestes (attention aux ^ et ~)
grep -rn --include=package.json -E '"(keyv|@keyv/[a-z-]+|cacheable[a-z-]*)"' . \
| grep -vE 'node_modules'
# Versions résolues dans le lockfile npm (v2/v3)
jq -r '.packages | to_entries[]
| select(.key | test("node_modules/(keyv|@keyv/|cacheable)"))
| "\(.key)\t\(.value.version)\t\(.value.resolved)"' package-lock.json
Comparez ensuite aux dates de publication réelles, plutôt qu’à une liste de versions qui sera périmée demain :
npm view keyv time --json | jq 'to_entries
| map(select(.value >= "2026-08-04T00:00:00Z" and .value < "2026-08-05T12:00:00Z"))'
Vérifiez le contenu effectif des tarballs présents dans votre cache ou votre registre miroir :
npm pack keyv@<version_suspecte> --silent | xargs tar -tzf
npm pack keyv@<version_suspecte> --silent | xargs -I{} tar -xzOf {} package/package.json \
| jq '.scripts'
Et l’arborescence installée, y compris dans les caches CI restaurés :
grep -rlE 'setup\.mjs|setup_bun|bun_environment|trufflehog' node_modules/ 2>/dev/null
grep -rn '"\(pre\|post\)\?install"' node_modules/*/package.json node_modules/@*/*/package.json 2>/dev/null
Côté CI, cherchez les traces du bootstrap et de l’exfiltration dans les journaux de la fenêtre concernée :
# Exemple GitHub Actions : jobs de la période, puis recherche dans les logs
gh run list --created 2026-08-04 --limit 200 --json databaseId,name,createdAt
gh run view <id> --log | grep -iE 'bun\.sh|bun-v|preinstall|trufflehog|api\.github\.com/user/repos'
Enfin, le signal le plus spécifique de cette famille : des dépôts publics inattendus créés sous vos comptes ou votre organisation.
gh api -X GET /orgs/<org>/repos --paginate -f sort=created -f direction=desc \
| jq -r '.[] | select(.created_at >= "2026-08-04") | "\(.created_at)\t\(.visibility)\t\(.full_name)"'
Réponse à incident : les 24 premières heures
Si un seul de ces contrôles remonte positif sur un environnement disposant de secrets, considérez tous les secrets accessibles depuis cet environnement comme compromis.
- Jetons npm.
npm token listpuisnpm token revoke <id>pour tout jeton classique. Profitez-en pour supprimer définitivement les jetons de publication à longue durée de vie plutôt que de les recréer. - GitHub. Révoquez les PAT du mainteneur et de tout compte de service, régénérez les clés de déploiement, et invalidez les jetons d’installation des GitHub Apps. Vérifiez les webhooks et les clés SSH ajoutés dans la fenêtre.
- OIDC et cloud. Un jeton OIDC est éphémère, mais les identifiants qu’il a permis d’obtenir ne le sont pas forcément. Passez en revue les rôles assumés pendant la fenêtre, révoquez les sessions actives (
aws iam .../équivalents), et resserrez les conditions de confiance (sublimité à un dépôt et une branche précise, pasrepo:org/*). - Caches et artefacts. Purgez les caches d’actions, le cache npm des runners, et surtout les caches de proxy de registre (Artifactory, Nexus, Verdaccio) : un paquet retiré de npm reste servi par votre miroir.
- Runners. Détruisez et reprovisionnez les runners auto-hébergés utilisés sur la période. Un runner persistant est un poste de travail partagé, pas un environnement jetable.
- Republication. Si vos propres paquets ont été republiés, publiez une version corrigée et marquez les versions malveillantes
deprecated— ne comptez pas sur l’unpublish, contraint par les règles de npm.
Durcissement durable
Publication de confiance (trusted publishing). npm supporte l’authentification OIDC depuis GitHub Actions et GitLab CI : plus aucun jeton de publication à stocker, donc plus rien à voler dans l’environnement. C’est la mesure au meilleur rapport effort/impact.
Provenance. Publiez avec npm publish --provenance et vérifiez côté consommateur :
npm audit signatures
Gel du temps de résolution. Un délai de quarantaine sur les versions fraîchement publiées neutralise la fenêtre de quelques heures qui fait tout l’intérêt de ces attaques :
# .npmrc (pnpm >= 10.16)
minimum-release-age=1440 # en minutes : 24 h
# .yarnrc.yml (Yarn 4.10+)
npmMinimalAgeGate: 1440
Désactivation des scripts d’installation dans les contextes qui n’en ont pas besoin — la majorité des builds applicatifs :
# CI
npm ci --ignore-scripts
# ou globalement sur le runner
export NPM_CONFIG_IGNORE_SCRIPTS=true
Pour les rares dépendances qui exigent réellement un postinstall (bindings natifs), pnpm permet une liste blanche explicite via onlyBuiltDependencies.
Registre miroir interne en mode pull-through avec rétention et possibilité de blocage immédiat d’une version : c’est le seul endroit où vous pouvez couper la diffusion sans attendre npm.
Jetons à portée minimale, permissions: déclarées explicitement dans chaque workflow, séparation stricte entre les jobs qui exécutent du code tiers et ceux qui détiennent des secrets de publication.
Protection de branche sur main avec revue obligatoire — y compris pour les administrateurs. Dans cet incident, un push direct sur main par un compte compromis a suffi.
Ce qui n’a pas marché
La provenance et les attestations SLSA ont été présentées, à juste titre, comme la réponse structurelle aux attaques sur la supply chain. Cet incident en montre la limite exacte : la provenance atteste de l’origine d’un artefact, pas de l’intention de son auteur. Les paquets malveillants ont été construits par le vrai workflow, dans le vrai dépôt, sous le vrai compte. Une attestation valide aurait été émise — et vérifiée avec succès par les consommateurs.
La provenance élimine la falsification en aval (paquet injecté sans passer par le CI du projet). Elle ne dit rien du cas où l’identité elle-même bascule. Contre ce scénario, seules trois familles de contrôles jouent : le délai (quarantaine des versions récentes), la compartimentation (aucun secret réutilisable dans un contexte exécutant du code tiers), et la détection comportementale côté consommateur.
Checklist pour l’équipe plateforme
- Inventaire des dépendances
keyv/cacheableet des versions résolues sur tous les lockfiles, y compris les branches non mergées - Recherche
setup.mjs/ téléchargement de runtime / TruffleHog dans les logs CI du 4 août - Recherche de dépôts publics créés récemment sous les comptes de l’organisation
- Rotation intégrale des jetons npm ; migration vers trusted publishing
- Rotation des PAT, clés de déploiement, jetons d’App GitHub
- Resserrement des conditions de confiance OIDC (
subpar dépôt et par branche) - Purge des caches CI et du cache du registre proxy interne
- Reprovisionnement des runners auto-hébergés
-
--ignore-scriptspar défaut + liste blanche explicite -
minimumReleaseAge/npmMinimalAgeGateà 24 h minimum - Revue obligatoire sur
main, appliquée aussi aux administrateurs -
npm audit signaturesintégré à la CI — en sachant ce qu’il ne couvre pas
Le coût réel de cette vague ne se mesure pas au nombre de versions retirées, mais au nombre d’identifiants qui circulaient en clair dans des environnements exécutant du code tiers. Un runner qui détient à la fois un jeton de publication npm et un rôle cloud n’est pas un environnement de build : c’est une clé maîtresse avec une interface npm install. Tant que cette configuration reste la norme, la quatrième vague trouvera le même terrain que les trois premières — et il faudra deux semaines de moins pour l’écrire.
Comments (0)
Please log in to leave a comment.
Log InNo comments yet. Be the first to comment!