
Scanner pour l'attaque de la chaîne d'approvisionnement keyv/cacheable : détecte les paquets npm compromis, vérifie les hashs des payloads et recherche les implants de persistance en modes repo et hôte.
English | Português
Scanner autonome pour l'incident de chaîne d'approvisionnement keyv/cacheable (« Shai-Hulud: Here We Go Again », 4 août 2026) — plus de 440 paquets npm compromis par un ver auto-propagateur qui vole des identifiants cloud/CI et installe une persistance avec un interrupteur homme mort.
Détecte, en quelques minutes et sans rien installer :
package-lock.json, npm-shrinkwrap.json, yarn.lock (v1 et Berry), pnpm-lock.yaml et bun.lock — y compris les dépendances transitives, avec la chaîne complète (p. ex. eslint → file-entry-cache → flat-cache → [email protected]) ;node_modules (nom + empreinte SHA-256 des artefacts connus) ;SUSPECT, jamais confirmées sans empreinte ;.claude/settings.json et .vscode/tasks.json, artefacts temporaires (bun-dl-*) ;keyv/cacheable a été compromis ; l'attaquant a publié de nouvelles versions avec un hook "preinstall": "node setup.mjs" — du code qui s'exécute avant l'installation du paquet, avec les privilèges de celui qui a lancé npm install.setup.mjs télécharge le runtime Bun depuis GitHub et y exécute la charge utile — une évasion face aux outils qui ne surveillent que les processus node.Math_Symbol.js (~728 Ko, obfusqué) vole des identifiants : métadonnées d'instance AWS, clés AWS/GCP/Azure, jetons Vault, comptes de service Kubernetes, secrets GitHub Actions, jetons npm, plus un balayage regex générique à la recherche de clés privées et de jetons bearer sur le disque.npm install/npm ci avec les scripts de cycle de vie activés depuis 2026-08-04 09:35 UTC. Avec --ignore-scripts, le hook ne s'exécutait pas..claude/settings.json (SessionStart) et .vscode/tasks.json (folderOpen) qui exécutent le chargeur à l'ouverture du dossier cloné — pas de npm install, rien d'installé. Cela inclut les personnes ayant cloné le dépôt pour enquêter sur l'incident et les agents de codage IA ayant ouvert le répertoire — l'un des premiers cas publics de hooks d'agents IA (.claude/) utilisés comme vecteur de chaîne d'approvisionnement.L'implant installe un « watcher » (gh-token-monitor) maintenu en vie par un LaunchAgent (macOS) ou un service utilisateur systemd + loginctl enable-linger (Linux). Toutes les 60 secondes, il valide le jeton GitHub volé auprès de l'API. Tant que le jeton fonctionne, rien ne se passe. Lorsque la réponse passe en 4xx — c'est-à-dire au moment où vous révoquez le jeton — il exécute, via eval, le contenu de ~/.config/gh-token-monitor/handler : une commande arbitraire définie à distance par l'attaquant. Les analyses publiques ne savent pas ce qu'elle contient — cela pourrait être une destruction de données, une ré-implantation, un rançongiciel, ou rien. Le risque n'est pas évaluable ; c'est pourquoi l'ordre de réponse est absolu.
Trois propriétés qui changent la réponse :
host.[email protected] a été publié avec une attestation SLSA valide. La provenance atteste l'intégrité du build, pas celle de la source : le workflow légitime a compilé un code déjà trojanisé.package.json (le hook preinstall) et dans deux nouveaux fichiers ajoutés au paquet (setup.mjs, Math_Symbol.js).eslint → file-entry-cache → flat-cache → keyv. C'est pourquoi le scanner affiche la chaîne dans chaque résultat.scan.mjs possède les propriétés suivantes — importantes pour quiconque répond à un incident de chaîne d'approvisionnement :
npm install. Auditez l'intégralité de scan.mjs en 15 minutes avant de l'exécuter.--update (téléchargement d'un nouveau manifeste d'IOC), explicite et facultative.docker run --network=none ou une machine isolée : il suffit de copier scan.mjs + iocs.json.Prérequis : Node.js ≥ 18 (toute machine avec npm l'a déjà). Téléchargez les deux fichiers — scan.mjs + iocs.json — et c'est tout : aucune installation.
Attention : si vous avez cloné ce dépôt complet, le dossier
fixtures/contient des IOC inertes utilisés dans les tests (vrais noms et versions, contenu factice — aucun malware). Le scanner l'ignore automatiquement et vous en avertit dans la sortie ; ses résultats n'apparaissent que si vous le scannez exprès.
Il existe deux modes d'exécution qui répondent à des questions différentes, et c'est ce qui détermine où l'exécuter :
repo lit les lockfiles et node_modules — et les lockfiles vivent dans git, il peut donc être centralisé : une seule personne scanne tous les dépôts de l'entreprise.host recherche l'implant (watcher, LaunchAgent/systemd, hooks IDE), qui vit sur la machine où le code s'est exécuté — cela ne se trouve pas dans git et ne peut pas être centralisé.node scan.mjs repo /folder/with/all/the/repos --json=result.json --html=report.html
Répond en quelques minutes à la question « quels projets sont exposés », sans impliquer personne. Accepte plusieurs chemins ; parcourt les sous-répertoires (monorepos et workspaces inclus).
Pour chaque projet avec un résultat, identifiez qui l'a touché depuis 2026-08-04 09:35 UTC (git log, journaux CI). Ces personnes exécutent, sur leur machine :
node scan.mjs # current directory + host, in ~30 seconds
Dans le périmètre : toute personne qui (a) a exécuté npm install/npm ci dans la fenêtre ; ou (b) a simplement cloné et ouvert le dossier dans VS Code ou un agent IA — le vecteur B ne nécessite aucune installation.
Comme le coût est d'environ 30 secondes et que l'entonnoir peut fuir (un clone égaré, un projet personnel), le message interne le plus sûr est : chaque développeur exécute node scan.mjs une fois et envoie le --json/--html à l'AppSec. L'envoi est manuel par conception — le scanner n'a pas de télémétrie (zéro trafic sortant).
Priorité absolue : c'est là que vivent les identifiants les plus précieux. Ici, le scanner a deux rôles différents — un pour le passé, un pour l'avenir :
Triage du passé — ne scannez PAS le runner pour décider. La question « ce runner a-t-il été touché ? » n'est pas résolue par un scan : si un job a installé une version affectée sans --ignore-scripts depuis le 08-04, les identifiants étaient déjà volés à ce moment-là, et l'hôte du runner conserve rarement des preuves (les runners éphémères détruisent le conteneur à la fin du job ; le watcher s'efface de lui-même en ~24 h). Ce qui y répond, ce sont les lockfiles de l'étape 1 et les journaux CI. Si la réponse est « oui, il l'a installé » : reconstruisez le runner et faites pivoter ses secrets — les runners sont éphémères, il n'y a aucune raison de les nettoyer.
Prévention pour la suite — OUI, exécutez-le dans le pipeline. Ajoutez le scanner comme étape de build, en mode repo, après le checkout et avant npm install. Il n'examine pas l'hôte du runner — il examine le code qui va être installé, et le code de sortie fait échouer le build avant que le preinstall malveillant n'ait une chance de s'exécuter :
# example (GitHub Actions / GitLab CI — adapt):
- run: node scan.mjs repo . --json # exit 0 clean · 1 findings · 2 COMPROMISED
- run: npm ci --ignore-scripts # only runs if the previous step passed
node scan.mjs # scan the current directory + the host
node scan.mjs repo /path/a /path/b
node scan.mjs host # persistence/implants on the machine only
node scan.mjs repo . --json=result.json --html=report.html
node scan.mjs --update # update iocs.json (the only network operation)
| Niveau | Signification | Action |
|---|---|---|
COMPROMISED | Version malveillante installée dans node_modules, charge utile confirmée par empreinte, ou implant de persistance trouvé | Traiter l'hôte comme compromis ; suivre l'ordre de réponse — nettoyer l'implant avant de faire pivoter les identifiants |
EXPOSED | Version malveillante épinglée dans un lockfile, aucune preuve d'exécution | Épingler une version sûre, supprimer node_modules, réinstaller avec --ignore-scripts |
AT_RISK | Plage (^/~) dans package.json qui admet une version malveillante | Épingler une version exacte ou la bloquer au proxy de registre |
SUSPECT | Heuristique (nom de fichier du ver avec une empreinte non concordante, script de cycle de vie suspect) | Inspecter manuellement — peut être une nouvelle variante ou un faux positif |
INFO | Vecteur présent mais aucun IOC (p. ex. une tâche folderOpen générique) | Examiner |
--html produit un rapport autonome avec un horodatage, le nom d'hôte, la version du manifeste d'IOC et le SHA-256 du scanner lui-même — utilisable comme pièce jointe à une notification d'incident et comme piste d'audit.
Ne révoquez et ne faites pivoter aucun identifiant pour l'instant — c'est le déclencheur du piège. La séquence :
1. ISOLEZ — coupez la machine du réseau. C'est sûr : sans réponse HTTP, il n'y a pas de 4xx, le piège ne se déclenche pas et l'exfiltration s'arrête. Ne coupez pas l'alimentation (la mémoire volatile est une preuve).
2. PRÉSERVEZ — avant de supprimer quoi que ce soit (le watcher s'autodétruit en ~24 h) :
mkdir -p /tmp/evidence && cp -r ~/.config/gh-token-monitor /tmp/evidence/ 2>/dev/null
cp /tmp/gh-token-monitor.*.log /tmp/evidence/ 2>/dev/null
shasum -a 256 /tmp/evidence/* 2>/dev/null
Le fichier handler est la commande de l'attaquant qui serait exécutée — ne l'exécutez pas, ne la collez pas dans un shell ; traitez-le comme un texte inerte. Le fichier started_at délimite la fenêtre d'exposition (l'auditeur et le régulateur vous le demanderont).
3. ÉRADIQUEZ — tuez d'abord le processus du watcher, puis :
# macOS
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
rm -f ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
# Linux
systemctl --user disable --now gh-token-monitor.service
loginctl disable-linger "$USER"
rm -f ~/.config/systemd/user/gh-token-monitor.service
# both
rm -rf ~/.config/gh-token-monitor ~/.local/bin/gh-token-monitor.sh /tmp/bun-dl-*
Supprimez également les hooks malveillants de .claude/settings.json et .vscode/tasks.json, les fichiers setup.mjs/Math_Symbol.js/math_init.js, et videz les caches (~/.npm/_cacache, pnpm, yarn). Réexécutez node scan.mjs host jusqu'à ce qu'il revienne propre.
4. FAITES PIVOTER — seulement une fois chaque hôte affecté nettoyé et vérifié (un seul watcher vivant suffit à déclencher le piège). Révoquez d'abord le jeton npm (cela empêche le ver de se propager) ; puis GitHub (PAT, clés de déploiement), AWS/GCP/Azure, Vault, Kubernetes, les secrets CI — et tout secret présent sur le disque, car il y a eu un balayage regex.
5. AUDITEZ — le ver agit en votre nom : recherchez les dépôts décrits Shai-Hulud: Here We Go Again dans vos organisations, les versions npm publiées de manière inattendue depuis le 08-04 (dépréciez-les et notifiez les consommateurs), et l'utilisation des identifiants dans CloudTrail/Journaux d'audit dans la fenêtre started_at.
Ensuite : supprimez node_modules, réinstallez à partir d'un lockfile propre avec --ignore-scripts. Runners CI et hôtes avec exécution confirmée : reconstruisez de zéro, toujours — du code arbitraire s'est exécuté, et la liste des artefacts connus ne garantit pas l'exhaustivité.
Institutions réglementées (BR) : une compromission confirmée avec accès à des identifiants peut déclencher des obligations de signalement (Res. CMN 4.893/2021, Res. BCB 85/2021 ; LGPD art. 48 si des données personnelles sont impliquées). Documentez la chronologie en UTC —
started_at, détection, confinement, éradication, rotation — et confirmez les délais avec le service juridique/conformité.
L'incident est actif et la liste s'allonge. Pour récupérer le dernier manifeste :
node scan.mjs --update # the only operation that touches the network
--update récupère iocs.json depuis ce dépôt (securest8/npm-incident-response), jamais depuis un tiers — Securest8 est la passerelle de curation. Vous obtenez ce qui a été publié en dernier ici.
La liste des paquets provient du flux public Wiz ; les empreintes, domaines C2, IOC de persistance et versions sûres sont statiques et organisés dans tools/gen-iocs.mjs. Un instantané du CSV Wiz se trouve dans tools/keyv-packages.csv pour la reproductibilité et les exécutions hors ligne.
node tools/gen-iocs.mjs # fetch the latest Wiz CSV, regenerate iocs.json + refresh the snapshot
node tools/gen-iocs.mjs --offline # regenerate from the committed snapshot, no network
node tools/gen-iocs.mjs --allow-shrink # allow a package count lower than the snapshot (guarded by default)
Le générateur est idempotent : il conserve le manifest_version existant et ne réécrit pas le fichier lorsque rien de substantiel n'a changé, et il refuse d'écrire un manifeste vide ou réduit (protection contre un flux amont tronqué ou modifié).
Automatisation : .github/workflows/update-iocs.yml exécute le générateur quotidiennement (et à la demande) et ne fait de commit vers main que lorsque les IOC changent réellement — ainsi le --update des utilisateurs suit le flux Wiz en environ une journée, avec tout l'historique auditable dans les commits. Basculez-le en étape de pull request (noté dans le workflow) une fois l'incident calmé et si vous voulez une fusion manuelle à la place.
node test/run-tests.mjs # 15 assertions against fixtures/demo-repo
fixtures/demo-repo est un dépôt de test avec des IOC inertes (vrais noms et versions, contenu factice — aucun malware). Lors du scan du dépôt du scanner lui-même, le dossier fixtures/ est ignoré automatiquement (avec un avertissement dans la sortie) ; les tests le scannent en passant explicitement le chemin.
Un outil dédié à un incident unique, conçu pour un triage rapide pendant la fenêtre active de l'attaque — pas un remplaçant de Socket, Snyk ou équivalent. Recherche et IOC : Socket.dev, Wiz Research (CSV public), Kodem Security.
Maintenu par Securest8. Licence MIT.