🛡️ Ce que tu reçois : un script de scan en lecture seule, la liste des packages empoisonnés, et la procédure de remédiation dans le bon ordre.
Le 4 août 2026, un ver a empoisonné des packages npm que la moitié de l'écosystème JavaScript utilise sans le savoir. Ce guide te donne un script de 70 lignes pour vérifier ta machine en 2 minutes, et surtout l'ordre exact des opérations si tu es touché, parce que là-dessus, l'intuition mène droit dans le piège.
Ce qui s'est passé
Le compte GitHub du mainteneur de keyv a été compromis. L'attaquant a poussé un voleur d'identifiants directement sur la branche main, puis a publié des versions sur toute la famille de packages.
À partir de là, le malware s'est propagé tout seul : il volait les tokens npm des machines infectées et s'en servait pour republier d'autres packages. SafeDep a compté 2 234 versions empoisonnées sur 444 noms de packages, réparties sur 12 organisations. Le tout en une matinée.
| Package | Version empoisonnée | Pourquoi tu l'as sûrement |
|---|---|---|
keyv | 6.0.0 | ~127 M téléchargements/semaine |
flat-cache | 6.1.24 | Tiré par ESLint |
file-entry-cache | 11.1.6 | Tiré par ESLint |
cacheable | 2.5.1 | Dépendance de got, cache-manager |
cacheable-request | 13.0.20 | Dépendance HTTP courante |
cache-manager | 7.2.10 | Cache applicatif |
⚠️ Le point important : tu n'as rien eu à installer de louche. Si tu as ESLint dans ton projet, tu asflat-cacheetfile-entry-cacheen dépendance transitive. Tu ne les as jamais choisis, ils sont là quand même.
Comment ça t'infecte
Le package empoisonné contient un hook preinstall. Il s'exécute avant ton propre code, pendant le npm install, sans rien te demander.
À partir de là :
- Il télécharge le runtime Bun 1.3.13 depuis les releases officielles GitHub, donc rien d'anormal côté réseau
- Il lance un fichier compilé de 727 680 octets
- Il ramasse tokens GitHub, tokens npm, clés cloud, tokens Vault, kubeconfig, identifiants de bases de données et clés privées
- Il republie des packages avec les tokens npm qu'il vient de voler
Sur GitHub Actions, il lit en plus la mémoire du runner pour récupérer les secrets qui n'ont jamais touché le disque.
Le deuxième chemin, celui que personne ne regarde
Le dépôt keyv contenait aussi un hook SessionStart dans .claude/settings.json et une tâche runOn: folderOpen dans .vscode/tasks.json.
Traduction : cloner le dépôt et l'ouvrir dans VS Code ou Claude Code suffit à déclencher le payload, sans jamais lancer npm install. Les deux éditeurs demandent confirmation via leur système de workspace de confiance, mais qui lit vraiment cette popup sur un repo populaire ?
⚠️ Et la signature ? Les versions empoisonnées portaient une provenance SLSA et OIDC parfaitement valide. Elles sont passées par le vrai workflow GitHub Actions du projet. La signature prouve que le build a eu lieu là où il devait, elle ne dit rien sur ce qui est entré dedans. Vérifier la provenance n'aurait rien changé ici.
Le script de scan
Il fait cinq contrôles, ne modifie rien, ne révoque rien. Lis-le avant de le lancer : vu le sujet, c'est le minimum.
#!/usr/bin/env bash
# Scan ChainDrop / Shai-Hulud : compromission npm du 4 août 2026
# Lecture seule : ce script ne supprime rien et ne révoque rien.
# Usage : bash scan-npm.sh ~/dev (ou sans argument pour tout scanner)
DIR="${1:-$HOME}"
echo "Scan de $DIR, $(date)"
echo
# --- 1. Artefacts du malware sur disque -------------------------------------
echo "[1/5] Fichiers déposés par le malware..."
find "$DIR" -type f \( -name 'Math_Symbol.js' -o -name 'math_init.js' -o -name 'setup.mjs' \) 2>/dev/null |
grep -v '/regenerate-unicode-properties/' | # faux positif connu, voir plus bas
while read -r f; do
sz=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null)
if [ "$sz" = "727680" ] || [ "$sz" = "11017" ]; then
echo " /!\\ MALWARE CONFIRMÉ ($sz o) : $f"
else
echo " /?\\ à inspecter : $f ($sz o)"
fi
done
# --- 2. Hooks preinstall -----------------------------------------------------
echo "[2/5] Hooks preinstall malveillants..."
grep -rl --include=package.json -E '"preinstall"[^"]*"[^"]*setup\.mjs' "$DIR" 2>/dev/null |
sed 's/^/ \/!\\ hook trouvé : /'
# --- 3. Hooks éditeur (Claude Code / VS Code) --------------------------------
echo "[3/5] Hooks Claude Code / VS Code..."
find "$DIR" -type f \( -path '*/.claude/settings.json' -o -path '*/.vscode/tasks.json' \) 2>/dev/null |
while read -r f; do
grep -qE 'setup\.mjs|math_init' "$f" 2>/dev/null && echo " /!\\ hook malveillant : $f"
done
# --- 4. Versions empoisonnées dans les lockfiles -----------------------------
echo "[4/5] Versions empoisonnées dans les lockfiles..."
find "$DIR" -name node_modules -prune -o -type f \
\( -name 'package-lock.json' -o -name 'yarn.lock' -o -name 'pnpm-lock.yaml' \) -print 2>/dev/null |
node -e '
const fs=require("fs");
const BAD=new Set(["keyv@6.0.0","cacheable@2.5.1","cacheable-request@13.0.20",
"cache-manager@7.2.10","flat-cache@6.1.24","file-entry-cache@11.1.6"]);
for(const f of fs.readFileSync(0,"utf8").split("\n").filter(Boolean)){
let t; try{ t=fs.readFileSync(f,"utf8"); }catch(e){ continue; }
const found=new Set();
const check=(n,v)=>{ if(BAD.has(n+"@"+v)) found.add(n+"@"+v); };
if(f.endsWith("package-lock.json")){
let j; try{ j=JSON.parse(t); }catch(e){ continue; }
for(const [p,i] of Object.entries(j.packages||{}))
check(i.name || p.split("node_modules/").pop(), i.version);
(function w(d){ for(const [n,x] of Object.entries(d||{})){ check(n,x.version); w(x.dependencies); } })(j.dependencies);
} else if(f.endsWith("yarn.lock")){
for(const b of t.split(/\n(?=\S)/)){
const v=b.match(/\n\s+version:?\s+"?([^"\s]+)/); if(!v) continue;
const sp=b.split("\n")[0].replace(/:$/,"").split(",")[0].trim().replace(/^"|"$/g,"");
const a=sp.lastIndexOf("@"); check(a>0?sp.slice(0,a):sp, v[1]);
}
} else {
for(const m of t.matchAll(/^\s*\/?((?:@[^/\s]+\/)?[^/@\s:]+)[@/]([0-9][^:(\s]*)[:(]/gm)) check(m[1],m[2]);
}
for(const x of found) console.log(" /!\\ "+x+" dans "+f);
}
' 2>/dev/null
# --- 5. Posture npm ----------------------------------------------------------
echo "[5/5] Version de npm..."
V=$(npm --version 2>/dev/null)
case "${V%%.*}" in
''|*[!0-9]*) echo " npm introuvable" ;;
*) [ "${V%%.*}" -ge 12 ] && echo " OK, npm $V bloque les scripts non approuvés" \
|| echo " /!\\ npm $V : pense à activer l'approbation des scripts (voir plus bas)" ;;
esac
echo
echo "Terminé. Toute ligne /!\\ = à traiter."
echo "Si tu en as : NE RÉVOQUE PAS tes tokens tout de suite."
echo "Supprime d'abord les fichiers, ensuite seulement fais tourner tes secrets."
Le lancer
- Créer le fichier : ouvre TextEdit ou VS Code, colle le script, enregistre-le sous
scan-npm.shdans ton dossier Téléchargements. - Ouvrir le terminal :
Cmd + Espace, tapeTerminal, Entrée. - Lancer :
cd ~/Downloadspuis la commande ci-dessous.
cd ~/Downloads
bash scan-npm.sh ~/dev
Remplace ~/dev par le dossier qui contient tes projets. Sans argument, il scanne tout ton dossier perso, plus long, plus complet.
💡 Si tu galères avec les chemins : tape bash (avec l'espace), puis glisse-dépose le fichier depuis le Finder dans la fenêtre du terminal. Le chemin s'écrit tout seul.
Sur Windows
Le script tourne dans Git Bash (livré avec Git pour Windows) ou dans WSL. Clic droit dans ton dossier de projets → « Git Bash Here », puis la même commande. Si tu n'as ni l'un ni l'autre, le plus rapide est de vérifier à la main :
npm ls keyv flat-cache file-entry-cache cacheable
Lire le résultat
| Ce que tu vois | Ce que ça veut dire |
|---|---|
Aucune ligne /!\ | Rien trouvé. Lis quand même la section suivante. |
/?\ à inspecter | Un fichier porte un nom d'artefact mais pas la bonne taille. Dans l'immense majorité des cas c'est légitime, ouvre-le, si c'est du code lisible tu peux passer. |
/!\ MALWARE CONFIRMÉ | Le payload est sur ton disque. Passe à la remédiation. |
/!\ hook | Un chemin d'exécution est armé sur ta machine. |
/!\ nom@version dans ... | Une version empoisonnée est dans ton lockfile. |
Deux faux positifs à connaître
Math_Symbol.js dans regenerate-unicode-properties : ce package est tiré par Babel, donc il est dans presque tous les projets JavaScript. Il contient un vrai fichier General_Category/Math_Symbol.js qui fait ~1 Ko et qui n'a rien à voir avec l'attaque. L'auteur du malware a choisi ce nom exprès, pour que son payload passe inaperçu dans un find. C'est pour ça que le script se fie à la taille exacte (727 680 octets) et pas au nom.
Un hook SessionStart dans .claude/settings.json : c'est un usage parfaitement normal, la plupart des setups Claude Code en ont un. Seule une référence à setup.mjs ou math_init dans ce fichier est un signe de compromission.
⚠️ Un scan vert ne prouve rien. Le script ne voit pas unnpm installpassé dont tu as supprimé lesnode_modulesdepuis. Si tu as installé quoi que ce soit le 4 août 2026 entre 9h et 14h UTC sur un projet contenant ESLint oukeyv, fais tourner tes secrets même si le rapport est propre.
Si tu es touché : l'ordre compte
Le malware installe un watcher de révocation. Il surveille tes tokens, et se déclenche au moment où tu en révoques un.
Donc le réflexe normal, « je suis hacké, je révoque tout tout de suite », est exactement le mauvais. Tu déclenches un handler local laissé par l'attaquant, sur une machine encore infectée.
- Débranche la machine du réseau et coupe tes runners CI
- Nettoie d'abord : supprime
setup.mjs,Math_Symbol.js,math_init.js, les hooks.claude/settings.jsonet.vscode/tasks.json, et tous lesnode_modulesconcernés - Ensuite seulement, fais tourner les secrets : tokens npm, PAT GitHub et clés SSH, clés cloud (AWS, GCP, Azure), tokens Vault, kubeconfig, identifiants de bases, clés privées, et tous les secrets de tes GitHub Actions
- Purge les caches :
npm cache clean --force, supprimenode_modules, réinstalle depuis un lockfile assaini - Vérifie ton compte GitHub : cherche un dépôt public décrit
Shai-Hulud: Here We Go Again. S'il existe, ton token a servi à l'exfiltration.
⚠️ Mettre à jour ne suffit pas. Après le nettoyage côté npm, le tag latest pointait encore vers une version empoisonnée sur beaucoup de packages. Compare toujours les versions résolues dans ton lockfile, jamais les tags.
Éviter le prochain
Celui-ci n'est pas le premier et ne sera pas le dernier. Trois réglages qui coûtent une minute :
# 1. npm 12 bloque les scripts de lifecycle non approuvés par défaut
npm install -g npm@latest
# 2. Ou, sur une version plus ancienne, désactive-les à la main
npm config set ignore-scripts true
# 3. En CI, toujours npm ci (respecte le lockfile) plutôt que npm install
npm ci --ignore-scripts
💡ignore-scripts truecasse quelques packages qui compilent du natif (sharp,bcrypt,node-gyp). Quand ça arrive, réactive-les ponctuellement sur ce projet précis plutôt que globalement.
Et le réflexe qui vaut tous les outils : dans VS Code comme dans Claude Code, ne fais pas confiance à un workspace que tu viens de cloner. La popup « voulez-vous faire confiance aux auteurs de ce dossier » est là pour ça.
Récap
| Étape | Commande |
|---|---|
| Scanner | bash scan-npm.sh ~/dev |
| Vérifier un package précis | npm ls keyv flat-cache file-entry-cache |
| Purger le cache | npm cache clean --force |
| Se protéger | npm install -g npm@latest |
| Réinstaller propre | rm -rf node_modules && npm ci |
Ressources
- 🔬 Analyse technique SafeDep : safedep.io
- 📰 The Hacker News : le ver npm lié à keyv
- 🛡️ Socket : analyse des namespaces keyv et cacheable
- 📖 npm 12, approbation des scripts : docs.npmjs.com