DevOps

Shai-Hulud / CHAINDROP : quand une provenance SLSA valide livre un ver npm — le plan de durcissement de vos pipelines

Début août 2026, une nouvelle vague Shai-Hulud (ver CHAINDROP) a contaminé le monorepo keyv/cacheable et publié des centaines de paquets npm dont les tarballs portaient une provenance SLSA parfaitement valide, générée via le trusted publishing OIDC de GitHub Actions. Cet article décortique le mécanisme, explique pourquoi une attestation prouve l'origine d'un build et non l'innocuité du code, et propose un plan de détection, de réponse à incident et de durcissement applicable dès aujourd'hui.

| | | 11 min read
Chaîne de build logicielle stylisée avec un badge de vérification vert dont un maillon est corrompu

Depuis 2025, l’écosystème npm vit au rythme des vagues Shai-Hulud : un ver qui vole des secrets sur les machines de build, puis se republie lui-même dans d’autres paquets en réutilisant les jetons qu’il vient de dérober. La campagne détectée le 4 août 2026, baptisée CHAINDROP par les équipes de threat intelligence, reprend cette mécanique — mais avec une nouveauté qui mérite l’attention de tous les ingénieurs qui exploitent une supply chain sécurisée : les tarballs malveillants étaient accompagnés d’une provenance SLSA authentique, émise par un workflow GitHub Actions légitime via le trusted publishing OIDC.

Autrement dit : npm audit signatures renvoyait un résultat propre, le badge de provenance s’affichait sur npmjs.com, et le code exfiltrait vos credentials.

Les volumes cités ci-dessous (plus de 430 paquets et 1 300 versions affectés, plus de 2 milliards d’installations mensuelles cumulées) proviennent des analyses publiées par plusieurs éditeurs (Elastic Security Labs, Wiz, Sonatype, Sygnia) et des alertes officielles, dont celle de la Cyber Security Agency de Singapour du 6 août. Comme toujours dans les premiers jours d’un incident, ces chiffres sont des estimations susceptibles d’être révisées : traitez-les comme un ordre de grandeur, pas comme un périmètre définitif.

Chronologie : 4 au 6 août 2026

Le déroulé rapporté est d’une banalité inquiétante.

  1. Compromission d’un compte de mainteneur disposant des droits d’écriture sur le monorepo keyv/cacheable — une famille de paquets de cache extrêmement présente en dépendance transitive (keyv est notamment tiré par de nombreux clients HTTP et couches de cache).
  2. Push direct sur main, sans passer par une pull request. Les commits apparaissaient avec un badge Verified et un auteur usurpé (nous y revenons plus bas).
  3. Cascade de releases sur l’ensemble du monorepo : les workflows de publication existants, déclenchés par les commits ou les tags, ont fait leur travail — construire, attester, publier.
  4. Auto-propagation : les jetons de publication collectés sur les runners et postes compromis ont servi à publier de nouvelles versions vérolées d’autres paquets, hors du périmètre initial.
  5. Alertes publiques à partir du 5-6 août, dépublications, et invalidation en urgence des jetons npm concernés.

Le point structurel à retenir : aucune étape de cette chaîne n’a exigé de contourner un contrôle cryptographique. L’attaquant s’est contenté d’entrer par la porte du mainteneur et de laisser l’usine tourner.

Anatomie du ver

Le mode opératoire de CHAINDROP prolonge celui des vagues précédentes, avec quatre briques fonctionnelles :

  • Exécution à l’installation. Le payload est déclenché par un script de cycle de vie (preinstall/postinstall) ou par un module chargé au premier require/import. C’est ce qui transforme un npm install en exécution de code arbitraire.
  • Collecte de secrets. Balayage du système de fichiers et des variables d’environnement à la recherche de jetons npm (~/.npmrc), de credentials GitHub, de clés cloud (~/.aws, variables GOOGLE_APPLICATION_CREDENTIALS, secrets Azure), de fichiers .env, de clés SSH et des secrets injectés dans le contexte CI. Les vagues antérieures s’appuyaient sur des scanners de secrets open source embarqués dans le payload.
  • Auto-propagation. Avec un jeton de publication valide, le ver énumère les paquets sur lesquels la victime a des droits, incrémente la version, réinjecte son payload et publie. La propagation est donc proportionnelle au nombre de mainteneurs touchés, pas au nombre de paquets initiaux.
  • Persistance dans l’environnement de développement. Élément marquant des vagues récentes : l’altération des configurations d’IDE et d’agents de code (fichiers de règles et de contexte pour les assistants, tâches automatiques, extensions). L’objectif est de survivre à un simple rm -rf node_modules et d’obtenir une réexécution lors d’une session de développement ultérieure.

Le point qui change tout : une provenance valide

C’est ici que CHAINDROP se distingue.

Le trusted publishing de npm permet à un workflow GitHub Actions de publier sans jeton longue durée : le workflow demande un token OIDC (id-token: write), le registry vérifie l’identité de l’appelant, et npm publish --provenance génère une attestation de provenance SLSA signée via Sigstore et enregistrée dans un log de transparence public. Cette attestation lie le tarball publié à un dépôt source, un commit, un fichier de workflow et un builder.

C’est un excellent mécanisme. Il répond précisément à la question : « ce paquet a-t-il bien été construit par ce workflow, dans ce dépôt, à partir de ce commit ? »

Il ne répond pas du tout à la question : « le code de ce commit est-il bénin ? »

Puisque l’attaquant contrôlait le compte du mainteneur et poussait sur main, la chaîne complète était honnête de bout en bout. Le commit était même affiché comme Verified : lorsqu’un commit est créé via l’API GitHub avec un jeton valide, GitHub le signe avec sa propre clé, tandis que les champs author/committer du commit restent des métadonnées librement renseignées. Badge vert, nom d’un mainteneur respecté, signature techniquement valide, auteur réel : l’attaquant.

La leçon est à graver au-dessus de vos tableaux de bord supply chain :

Une attestation prouve l’origine d’un artefact. Elle ne prouve rien sur l’intention du code. La provenance déplace la confiance vers le contrôle d’accès du dépôt source et de la chaîne de release — elle ne l’élimine pas.

Diagnostic express : ai-je consommé une version compromise ?

Commencez par les lockfiles, pas par node_modules. Sur un lockfile v2/v3 :

# Toutes les entrées de la famille suspecte, avec version résolue
jq -r '.packages | to_entries[]
       | select(.key | test("node_modules/(@keyv/|keyv|cacheable)"))
       | "\(.key)\t\(.value.version)"' package-lock.json

# Tous les paquets autorisés à exécuter un script d'installation
jq -r '.packages | to_entries[]
       | select(.value.hasInstallScript == true) | .key' package-lock.json

Puis élargissez le périmètre au-delà du dépôt applicatif :

  • Historique Git des lockfiles : git log -p --since=2026-08-01 -- package-lock.json pnpm-lock.yaml yarn.lock. Une version compromise résolue puis corrigée reste un incident.
  • Caches CI : les caches ~/.npm, node_modules et les caches de layers Docker peuvent encore contenir le tarball malveillant même après remédiation en amont.
  • Images conteneurisées déjà poussées : inventoriez via vos SBOM. Si vous n’en générez pas, c’est le moment (npm sbom --sbom-format cyclonedx).
  • Logs du registry : vos journaux de proxy interne (Artifactory, Nexus, Verdaccio) donnent la liste exacte des versions téléchargées et par qui. C’est la source de vérité la plus fiable.
  • Journaux d’audit npm et GitHub : recherchez toute publication non prévue sur vos propres paquets, la création de dépôts publics inattendus dans votre organisation (canal d’exfiltration privilégié des vagues Shai-Hulud), l’ajout de clés SSH ou de jetons.

Réponse à incident en 60 minutes

Si un runner ou un poste de développement a exécuté une version compromise, considérez tout secret accessible depuis ce contexte comme divulgué. L’ordre compte : révoquez avant de reconstruire.

  1. T+0 à 10 min — jetons de publication. Révoquez tous les tokens npm d’automatisation et personnels des comptes concernés. Basculez la publication sur trusted publishing sans jeton, ou régénérez des jetons à portée granulaire.
  2. T+10 à 20 min — identités GitHub. Révoquez les PAT, les clés SSH et de déploiement, les autorisations d’OAuth/GitHub Apps suspectes. Vérifiez les webhooks et les workflows ajoutés récemment (.github/workflows/).
  3. T+20 à 35 min — cloud et CI. Rotation des clés statiques AWS/GCP/Azure exposées aux jobs, des secrets de repository et d’organisation, des credentials de base de données. Coupez les rôles OIDC dont la condition de confiance est trop large (sub en wildcard sur toutes les branches) : c’est le vecteur de pivot le plus direct.
  4. T+35 à 45 min — caches et artefacts. Purgez les caches CI, les caches de votre proxy npm, invalidez les tarballs concernés côté registry interne.
  5. T+45 à 60 min — reconstruction. Reconstruisez les images depuis un lockfile corrigé, avec --no-cache, et redéployez. Bloquez les versions incriminées côté proxy pour éviter toute résolution accidentelle.

En parallèle, surveillez les signaux d’exfiltration : trafic sortant inhabituel depuis les runners, création de dépôts publics, activité API anormale sur vos comptes cloud.

Durcissement durable

Aucune de ces mesures n’est nouvelle. Leur combinaison, elle, aurait fortement réduit la surface d’impact.

1. Installations déterministes. npm ci en CI, jamais npm install. Versions épinglées et lockfile committé et revu comme du code.

# .npmrc à la racine du dépôt
save-exact=true
ignore-scripts=true
audit-signatures=true
fund=false

ignore-scripts=true par défaut est la mesure au meilleur rapport bénéfice/coût contre les payloads d’installation. Quelques paquets à binding natif en souffrent : traitez-les par exception explicite (pnpm propose onlyBuiltDependencies, ou lancez un npm rebuild <pkg> ciblé).

2. Quarantaine avant adoption. Aucune release ne doit entrer en production le jour de sa publication. Les vagues Shai-Hulud sont détectées et dépubliées en 24 à 72 heures : un délai de quarantaine transforme un incident en non-événement.

{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "7 days",
  "internalChecksFilter": "strict",
  "npmrc": "registry=https://npm.interne.example.com/"
}

Dependabot propose un mécanisme équivalent via son option de cooldown.

3. Proxy de registry interne. Un seul point d’entrée pour toutes les dépendances, avec cache immuable, journalisation exhaustive et capacité de blocage immédiat d’une version. C’est aussi ce qui rend le diagnostic de l’étape précédente possible en quelques minutes.

4. Isolation des runners. Politique d’egress par défaut en refus, avec allowlist (registry interne, endpoints de déploiement). Un ver qui ne peut pas joindre son point d’exfiltration perd l’essentiel de sa valeur. Runners éphémères, jamais réutilisés entre jobs.

5. Permissions minimales dans les workflows.

permissions:
  contents: read          # défaut restrictif au niveau du workflow

jobs:
  publish:
    environment: release  # reviewers requis + secrets scopés
    permissions:
      contents: read
      id-token: write     # uniquement dans le job de publication
    steps:
      - uses: actions/checkout@<sha-complet>   # actions épinglées par SHA
      - run: npm ci --ignore-scripts
      - run: npm publish --provenance --access public

6. Verrouillage du chemin de release. Branche main protégée, revue obligatoire, interdiction des pushs directs et des force-pushs, 2FA par passkey pour tous les mainteneurs, publication déclenchée uniquement par un tag signé et validée par un environnement à approbation manuelle. C’est exactement l’étape 2 de la chronologie qui aurait été bloquée.

Ce que la vérification de provenance doit devenir

Vérifier qu’une provenance existe ne sert à rien. Il faut vérifier quelle provenance.

Le registry expose les attestations d’un paquet, ce qui permet d’inspecter le prédicat SLSA et d’y appliquer une politique :

PKG=cacheable; VER=1.10.4
curl -s "https://registry.npmjs.org/-/npm/v1/attestations/${PKG}@${VER}" \
  | jq -r '.attestations[]
           | select(.predicateType | test("slsa.dev/provenance"))
           | .bundle.dsseEnvelope.payload' \
  | base64 -d \
  | jq '.predicate.buildDefinition.externalParameters.workflow'
# → { "ref": "refs/tags/v1.10.4", "repository": "https://github.com/jaredwray/cacheable", "path": ".github/workflows/release.yml" }

Une politique d’admission utile impose, pour chaque dépendance critique :

  • une allowlist de dépôts sources attendus par paquet (un changement de repository dans la provenance est un signal fort) ;
  • une allowlist de fichiers de workflow et de refs autorisés (refs/tags/* plutôt que n’importe quelle branche) ;
  • une vérification de l’identité du publisher et le refus des publications par jeton longue durée quand le trusted publishing est disponible ;
  • un rapprochement avec le tag Git amont : le commit attesté existe-t-il dans l’historique public, dans une release signée ?

Sur les artefacts de conteneur, la même logique s’implémente avec un contrôleur d’admission (Sigstore policy-controller, Kyverno) qui valide l’identité du signataire, pas seulement la présence d’une signature. La provenance npm mérite le même traitement : un contrôle bloquant dans la CI, pas un badge dans une interface web.

Checklist à coller dans votre dépôt

[ ] npm ci uniquement en CI ; lockfile committé et revu
[ ] ignore-scripts=true par défaut ; exceptions listées et justifiées
[ ] toutes les dépendances via un proxy de registry interne journalisé
[ ] quarantaine >= 7 jours (minimumReleaseAge / cooldown)
[ ] actions GitHub épinglées par SHA complet
[ ] permissions: contents: read au niveau workflow ; id-token: write scopé au job de publication
[ ] main protégée, pushs directs interdits, 2FA par passkey, publication via tag + environnement à approbation
[ ] trusted publishing activé ; jetons npm longue durée supprimés
[ ] rôles OIDC cloud conditionnés au dépôt ET à la ref
[ ] runners éphémères, egress en allowlist
[ ] SBOM générée et archivée à chaque build
[ ] politique d'admission de provenance : repository + workflow + ref vérifiés

Indicateurs à suivre en continu : délai médian entre publication amont et adoption interne ; nombre de paquets autorisés à exécuter des scripts d’installation ; pourcentage de dépendances directes couvertes par une provenance vérifiée contre allowlist ; nombre de secrets longue durée accessibles depuis un job CI (objectif : zéro) ; délai de rotation complète des credentials CI, mesuré lors d’un exercice, pas estimé.

Ce que CHAINDROP nous laisse

Cet incident ne discrédite pas SLSA, Sigstore ou le trusted publishing : il en clarifie le périmètre. Ces outils ont fait exactement ce qu’ils promettent — établir un lien vérifiable entre un artefact et son build. Le maillon faible n’était pas cryptographique, il était humain et organisationnel : un compte de mainteneur détourné, une branche main accessible en écriture directe, une chaîne de release entièrement automatisée sans point de contrôle humain.

Le corollaire opérationnel est net. Investir dans la vérification de provenance sans investir simultanément dans la protection des identités de mainteneurs, la gouvernance des branches de release, la quarantaine des versions et l’isolation des runners, c’est déplacer la confiance sans la réduire. La question à poser à votre pipeline cette semaine n’est pas « mes dépendances sont-elles signées ? », mais « qui, exactement, peut faire signer du code en mon nom — et combien de temps me faut-il pour le révoquer ? ».

Comments (0)

Please log in to leave a comment.

Log In

No comments yet. Be the first to comment!