11 mai 2026, 19:26 UTC. Socket détecte les premiers packages compromis dans le namespace @tanstack. En 12 heures, la campagne « Mini Shai-Hulud » de TeamPCP touche 200+ artefacts npm et PyPI : TanStack, Mistral AI, UiPath, OpenSearch, Guardrails AI, Squawk, et des dizaines de packages secondaires. @tanstack/react-router a 12 millions de téléchargements hebdomadaires.
MUAD'DIB a scanné 206 de ces packages entre le 11 et le 12 mai. Résultat : 93 alertés, 113 passés sous le seuil. @tanstack/react-router a scoré 2/100. Le payload était un fichier de 2.3 MB contenant 78 accès dynamiques à process.env, 30 188 variables _0x, un canal d'exfiltration vers le réseau Session, et une persistance dans .claude/settings.json.
Les règles se sont déclenchées. Le scoring les a écrasées. Cet article est l'autopsie de ce gap.
L'attaque
L'analyse technique détaillée provient de Socket, StepSecurity et Snyk. Ce qui suit est un résumé du point de vue détection.
TeamPCP a exploité une chaîne de trois vulnérabilités dans le workflow GitHub Actions de TanStack :
- Pwn Request : un workflow
pull_request_targetcheckout le code du fork attaquant tout en conservant l'accès au cache du repo base - Cache poisoning : le store pnpm est empoisonné via le fork, le workflow légitime de release charge les dépendances corrompues
- Extraction mémoire : le payload lit
/proc/{pid}/memdu Runner.Worker pour extraire le token OIDC en clair (les secrets GitHub Actions sont masqués dans les logs mais pas chiffrés en RAM)
Le token OIDC est échangé contre un droit de publication npm via la fédération trusted-publisher. Les packages publiés portent des attestations SLSA Build Level 3 valides. C'est le premier cas documenté de packages malveillants avec provenance Sigstore authentique.
Le payload : router_init.js
Un fichier unique de 2.3 MB, une seule ligne, trois couches d'obfuscation imbriquées :
| Couche | Technique | Objectif |
|---|---|---|
| 1 | String-array rotation (obfuscator.io) | Variables _0x253b, 30 188 identifiants hex |
| 2 | beautify(), PBKDF2 cipher | 148 noms d'env vars chiffrés, invisibles à grep |
| 3 | AES-256-GCM + Bun.gunzipSync | Payload secondaire décompressé au runtime Bun |
Capacités du payload :
- Credential sweep : GitHub Actions (OIDC token, secrets via API), AWS (IMDSv2, Secrets Manager, SSM), Vault (svc.cluster.local:8200), Kubernetes (serviceaccount token)
- Exfiltration : réseau Session P2P (
filev2.getsession.org) avec protobuf signalservice embarqué. Trafic indiscernable d'une app de messagerie chiffrée au niveau réseau. Second canal : dead-drop GitHub GraphQL, commits signésclaude@users.noreply.github.com - Persistance : écriture dans
.claude/settings.json(hooks Claude Code) et.vscode/tasks.json(auto-run VS Code). Survit ànpm uninstallet aux reboots - Worm npm : acquisition OIDC → publication automatique sous l'identité du mainteneur compromis, avec attestations Sigstore valides
- Dead man's switch : le token npm porte le message
IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner. Un servicesystemd/launchctl(com.user.gh-token-monitor) surveille le token et déclenche une destruction du home directory si révoqué
Le choix de Bun comme runtime est tactique : il bypass les hooks --require utilisés par les outils de monitoring Node.js (dd-trace, opentelemetry, newrelic). Et le && exit 1 sur le script prepare fait « échouer » l'optional dependency proprement, sans log suspect, pendant que le child process détaché continue en background.
Ce que MUAD'DIB a détecté
Sur le tarball réel de @tanstack/react-router@1.169.8, 17 règles se sont déclenchées :
| Règle | Type | Sévérité originale | Sévérité finale | Raison du downgrade |
|---|---|---|---|---|
| MUADDIB-AST-018 | env_charcode_reconstruction (x78) | HIGH | LOW | unreachable |
| MUADDIB-AST-002 | env_access (x78 dynamic) | MEDIUM | LOW | unreachable |
| MUADDIB-AST-002 | env_access (ACTIONS_ID_TOKEN_REQUEST_TOKEN) | HIGH | LOW | unreachable + count > 4 |
| MUADDIB-AST-002 | env_access (AWS_SECRET_ACCESS_KEY) | HIGH | LOW | unreachable + count > 4 |
| MUADDIB-AST-002 | env_access (VAULT_TOKEN, VAULT_AUTH_TOKEN) | HIGH | LOW | unreachable + count > 4 |
| MUADDIB-AST-041 | credential_regex_harvest | HIGH | LOW | unreachable |
| MUADDIB-OBF-001 | obfuscation_detected (score 80) | LOW | LOW | isLargeJs (> 100KB) |
| MUADDIB-FLOW-001 | suspicious_dataflow (creds → fetch x9) | CRITICAL | LOW | unreachable |
| MUADDIB-ENTROPY-003 | js_obfuscation_pattern (30 188 _0x vars) | HIGH | LOW | unreachable + count > 1 |
| MUADDIB-ENTROPY-001 | high_entropy_string (x3) | MEDIUM | LOW | unreachable |
Score final : 2/100. Seuil d'alerte pour un package établi : 35. Pas d'alerte envoyée.
Trois causes racines
Le score de 2 n'est pas un bug unique. C'est la composition de trois mécanismes de FP reduction qui fonctionnent correctement sur les packages bénins mais que l'attaquant exploite, consciemment ou non.
1. Reachability : le fichier injecté est invisible au module-graph
router_init.js n'est importé par aucun fichier du package. Le module-graph le marque unreachable. Toutes les détections non exemptées passent à LOW (scoring.js:1106). C'est le mécanisme qui a le plus d'impact : il transforme 2 CRITICAL + 6 HIGH en 17 LOW.
Le fichier s'exécute en réalité via optionalDependencies → github:tanstack/router#79ac49ee → prepare: "bun run tanstack_runner.js". C'est un vecteur d'exécution que le module-graph ne trace pas : la dépendance git exécute un hook lifecycle dans un sous-package, pas un require() depuis le code principal.
2. isLargeJs : un fichier de 2.3 MB est forcément un bundle
L'obfuscation scanner (obfuscation.js:69) traite tout fichier .js > 100 KB comme du « bundled output » et émet les findings à LOW :
// P6: Any JS file > 100KB is overwhelmingly bundled output
const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeJs;
L'hypothèse est statistiquement correcte :99%+ des fichiers JS > 100 KB sont du webpack, rollup ou esbuild. Mais un payload javascript-obfuscator de 2.3 MB n'est pas un bundle, et le scanner ne fait pas la différence.
3. MT-1 : pas de lifecycle = pas de score > 35
Le plafond MT-1 (scoring.js:1401) limite le score à 35 pour les packages sans lifecycle script. @tanstack/react-router n'a ni preinstall, ni install, ni postinstall. L'exécution passe par l'optionalDependencies git, pas par un script du package principal.
Le bypass MT-1 vérifie la présence de HIGH_CONFIDENCE_MALICE_TYPES. env_charcode_reconstruction n'y était pas.
Cinq correctifs
| Fichier | Changement | Impact |
|---|---|---|
| scoring.js | env_charcode_reconstruction ajouté à REACHABILITY_EXEMPT_TYPES |
fromCharCode + process.env[computed] est structurellement unique au malware. Le finding reste HIGH même sur un fichier unreachable. |
| scoring.js | Les findings co-localisés avec un type exempt sont eux aussi exemptés du downgrade reachability | Si un fichier contient env_charcode_reconstruction (preuve de malice), les obfuscation_detected CRITICAL et suspicious_dataflow CRITICAL du même fichier ne sont plus écrasés. |
| classify.js | env_charcode_reconstruction ajouté à HIGH_CONFIDENCE_MALICE_TYPES |
Bypass du plafond MT-1 (35). Le score n'est plus artificiellement cappé pour les packages sans lifecycle direct. |
| obfuscation.js | isLargeJs vérifie l'absence de markers _0x dans les premiers 8 KB |
webpack/rollup/esbuild ne produisent jamais de variables _0x. Un fichier > 100 KB avec des _0x est du javascript-obfuscator, pas du bundle output. |
| package.js | PKG-014 étendu pour matcher github:, gitlab:, bitbucket: |
La regex /^git[+:]/ ne matchait pas github:tanstack/router#commit. Le format shorthand exécute des hooks prepare à l'installation. |
| bundle-detect.js | detached_credential_exfil, ai_config_injection, ide_task_persistence ajoutés à VETO_TYPES |
Ces patterns ne sont jamais produits par un bundler. Un fichier dist/ qui spawn un detached process + lit des credentials + écrit dans .claude/ n'est pas du code bundlé. |
Résultat : avant / après
Scan de @tanstack/react-router@1.169.8 (tarball réel de la campagne) :
| Avant | Après | |
|---|---|---|
| Score | 2/100 LOW | 100/100 CRITICAL |
| Findings CRITICAL | 0 | 2 (obfuscation_detected, suspicious_dataflow) |
| Findings HIGH | 0 | 8 (env_charcode, env_access x5, credential_regex, js_obf, git_dep_rce) |
| Alerte envoyée | Non | Oui |
| Tests passants | 878 | 878 (0 régression) |
Échantillon de 18 packages re-scannés sur les tarballs archivés (tous les namespaces de la campagne : @tanstack, @mistralai, @squawk, @beproduct, @draftlab, @supersurkhet, @tolka, @ml-toolkit-ts, git-git-git, nextmove-mcp, cmux-agent-mcp, safe-action, dirigible-ai) :
| Package | Score avant | Score après |
|---|---|---|
| @tanstack/react-router@1.169.8 | 2 | 100 |
| @tanstack/react-start@1.167.71 | 2 | 100 |
| @tanstack/solid-router@1.169.8 | 2 | 100 |
| @tanstack/history@1.161.12 | 2 | 100 |
| @mistralai/mistralai-azure@1.7.3 | 17 | 100 |
| @squawk/mcp@0.9.2 | 23 | 100 |
| git-git-git@1.0.8 | 9 | 100 |
| nextmove-mcp@0.1.3 | 23 | 100 |
| safe-action@0.8.4 | 15 | 100 |
| @dirigible-ai/sdk@0.6.3 | 17 | 100 |
| @beproduct/nestjs-auth@0.1.19 | 25 | 100 |
| @draftlab/auth@0.24.2 | 17 | 100 |
Re-scan complet des 206 tarballs archivés :
| Avant | Après | |
|---|---|---|
| Score == 100 | 0 | 165 (80%) |
| Score ≥ 35 | 0 | 168 (81%) |
| Score ≥ 20 | 93 (45%) | 170 (82%) |
| Score < 20 | 113 (55%) | 36 (18%) |
Les 36 restants sous le seuil sont des packages adjacents dans l'archive qui n'ont pas été compromis : 14 @tanstack/preact-* sans payload (versions légitimes publiées pendant la même fenêtre), des @uipath/packager-* dont les tarballs n'ont pas le router_init.js, et quelques mobx-tanstack-query-api qui sont des packages tiers non ciblés par TeamPCP.
Ce que MUAD'DIB ne détecte toujours pas
- PyPI (mises à jour de packages existants) : le monitor PyPI consomme le flux RSS
pypi.org/rss/packages.xml, qui ne liste que les nouveaux packages, pas les nouvelles versions de packages existants.mistralai@2.4.6etguardrails-ai@0.10.1sont des mises à jour de packages établis. Ils ne sont jamais apparus dans le feed, jamais téléchargés, jamais scannés. Le feed PyPI a bien tourné pendant la fenêtre de compromission (18 détections PyPI entre le 11 et le 12 mai), mais il ne capte structurellement pas ce type de publication. - IMDS :l'adresse
169.254.169.254(AWS metadata) n'a pas de règle dédiée. Le dataflow la capture indirectement s'il co-existe avec un env_access, mais l'IP elle-même n'est pas flaggée. - Session P2P :
filev2.getsession.orgn'est pas dans les IOC strings. Le trafic vers ce domaine est indiscernable de l'usage légitime de l'app Session au niveau réseau. - Provenance Sigstore :MUAD'DIB ne vérifie pas les attestations SLSA. Même si elle l'avait fait, les attestations étaient techniquement valides. SLSA prouve que le workflow a signé le package, pas que le code est sûr.
- Le dead man's switch :le service systemd/launchctl de destruction conditionnelle (
com.user.gh-token-monitor) n'a pas de détection dédiée.
IOC
Fichiers
# router_init.js (payload principal)
SHA256 ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
SHA1 12ed9a3c1f73617aefdb740480695c04405d7b4b
# tanstack_runner.js (variante 2, propagation worm)
SHA256 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96
SHA1 e7d582b98ca80690883175470e96f703ef6dc497
# Commit malveillant dans TanStack/router
79ac49eedf774dd4b0cfa308722bc463cfe5885c
Réseau
filev2.getsession[.]org # exfiltration Session P2P
api.masscan[.]cloud # C2 direct (StepSecurity)
git-tanstack[.]com # domaine C2 pour payload Python guardrails-ai
Persistance
.claude/router_runtime.js
.claude/settings.json # hooks Claude Code
.claude/setup.mjs
.vscode/setup.mjs
.vscode/tasks.json # auto-run VS Code
com.user.gh-token-monitor # service systemd/launchctl dead man's switch
Identités
voicproducoes # compte GitHub attaquant
claude@users.noreply.github.com # auteur spoofé des commits dead-drop
Sources
- Socket Research Team :TanStack npm Packages Compromised in Ongoing Mini Shai-Hulud Supply-Chain Attack (2026-05-11)
- Ashish Kurmi / StepSecurity :Mini Shai-Hulud Is Back: A Self-Spreading Supply Chain Attack Hits the npm Ecosystem (2026-05-11)
- Stephen Thoemmes / Snyk :TanStack npm Packages Compromised Inside The Mini Shai Hulud Supply Chain Attack (2026-05-11)
Leçon
Les règles de détection ont fonctionné. Le scoring les a écrasées. C'est un pattern récurrent en sécurité : l'ingénierie de fiabilité (réduction des faux positifs) et l'ingénierie de détection (couverture des vrais positifs) se battent pour les mêmes leviers. Chaque threshold conçu pour supprimer les bundles bénins est un threshold qu'un attaquant peut exploiter.
TeamPCP ne contourne pas les détections. Ils contournent le scoring. Leur payload de 2.3 MB exploite l'hypothèse « gros fichier = bundle inoffensif ». Leur absence de lifecycle script exploite le plafond MT-1. Leur fichier injecté exploite le downgrade reachability. Trois assumptions raisonnables individuellement, catastrophiques en combinaison.
La leçon pour MUAD'DIB : les signaux structurellement uniques au malware :env_charcode_reconstruction, function_constructor_require, detached_credential_exfil, ne doivent jamais être atténués par des heuristiques statistiques. Si un fichier reconstruit des noms de variables d'environnement via String.fromCharCode, aucune quantité de FP reduction ne change ce que ça signifie.
La leçon plus large : les attestations SLSA, les badges provenance, les Sigstore transparency logs :tout ce qui prouve qui a signé, ne prouve pas quoi a été signé. Un pipeline compromis produit des attestations parfaitement valides pour du code parfaitement malveillant. La supply chain n'est pas sécurisée par la provenance. Elle est sécurisée par l'analyse du contenu.