DevOps

Après l'attaque keyv : durcir vos workflows npm et GitHub Actions contre les vers de supply chain

La compromission du compte mainteneur de keyv et cacheable, suivie de dix paquets malveillants embarquant un ver voleur de secrets, rappelle que la chaîne d'approvisionnement JavaScript reste le maillon faible du CI/CD. Cet article décortique les deux vecteurs exploités — prise de contrôle de compte npm et workflow `pull_request_target` non audité — puis propose une checklist de durcissement immédiatement applicable : épinglage par SHA, permissions minimales, cloisonnement des caches, Trusted Publishing OIDC et exercice de simulation d'exfiltration.

| | | 11 min read · by
Représentation abstraite d'un pipeline CI/CD compromis : maillons de chaîne dont un est corrompu, sur fond de terminal.
{# Render article content from markdown with pagination support. Cached: markdown_to_html re-parses the full body on every request otherwise, for content that's identical for every reader who reaches this point (the access check that decides whether this template renders at all already ran in the view — caching here never bypasses it, it just skips re-rendering the same HTML for the next reader of the same page). Keyed on content_version (see the view — it's the actual content row's updated_at, not Article's, since editing a page doesn't touch the article) so an edit invalidates this immediately instead of waiting out the timeout. #}

Le 4 août 2026, le compte GitHub du mainteneur de keyv et cacheable a été compromis. Dans la fenêtre qui a suivi, dix paquets de la galaxie « cache » ont été publiés sur npm avec une charge malveillante : un ver conçu pour collecter les secrets présents dans l’environnement d’exécution — variables d’environnement CI, tokens npm, credentials cloud — et pour se propager en republiant d’autres paquets accessibles avec les identifiants volés. La campagne a été surnommée « mini Shai-Hulud », par analogie avec le ver qui avait frappé l’écosystème npm un an plus tôt. Avec un volume de téléchargements hebdomadaires rapporté autour de 127 millions pour les paquets concernés, la surface d’exposition est de l’ordre de « tout le monde », en transitif.

Dans la même fenêtre, une seconde compromission — côté TanStack — n’est pas partie d’un compte npm mais d’un workflow GitHub Actions : un pull_request_target non audité, une clé de cache trop large, et des permissions OIDC non restreintes. GitHub a depuis publié un billet sur le démantèlement de ces campagnes.

Ces deux vecteurs sont complémentaires et méritent d’être traités ensemble, parce qu’ils se referment avec les mêmes garde-fous.

Mécanique du ver : pourquoi ça marche si bien

Le schéma est désormais stabilisé, et il tient en quatre temps.

1. Point d’entrée humain. Phishing ciblé sur un mainteneur, souvent via un faux e-mail de sécurité du registre ou une fausse demande de révision. Le facteur de bus faible de nombreux paquets critiques — un seul mainteneur, une seule 2FA, parfois un token classique de longue durée dans un fichier ~/.npmrc — suffit.

2. Publication d’une version mineure. Pas de fork, pas de nouveau paquet : une version patch d’un paquet légitime, qui sera absorbée par tous les ^ et ~ des lockfiles régénérés dans les heures suivantes.

3. Exécution au moment de l’installation. Un script postinstall (ou du code exécuté au premier require) collecte l’environnement. En CI, c’est le jackpot : NPM_TOKEN, GITHUB_TOKEN, clés de registre privé, credentials cloud statiques, contenu de ~/.aws, ~/.docker/config.json.

4. Propagation. Avec un token npm valide récupéré sur un runner, le ver publie à son tour sur les paquets pour lesquels ce token a les droits. La croissance est exponentielle et la fenêtre de détection se compte en heures.

Le point important : la victime n’est pas seulement le mainteneur compromis, c’est votre runner CI. Le ver ne se propage pas sans machines qui exécutent du code arbitraire avec des secrets en clair dans l’environnement. C’est là que vous avez du levier.

pull_request_target, le trou noir de GitHub Actions

Rappel de sémantique, parce que la confusion est la cause première des incidents :

  • pull_request — la définition du workflow provient de la branche de la PR ; pour une PR issue d’un fork, le GITHUB_TOKEN est en lecture seule et les secrets du dépôt ne sont pas exposés. C’est le mode sûr.
  • pull_request_target — la définition du workflow provient de la branche de base, mais le job s’exécute avec le contexte privilégié du dépôt cible : secrets disponibles, GITHUB_TOKEN en écriture, et scope de cache de la branche de base.

Ce mode existe pour des cas légitimes (étiqueter une PR, poster un commentaire). Le piège est le pattern suivant, qu’on trouve encore dans des milliers de dépôts :

# ANTI-PATTERN — ne pas reproduire
name: PR checks
on:
  pull_request_target:
    types: [opened, synchronize]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # code non revu
      - run: npm ci                                        # exécute ses scripts
      - run: npm run build
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Trois lignes suffisent à transformer une PR d’inconnu en exécution de code arbitraire avec vos secrets. Le checkout du head.sha ramène le code non revu ; npm ci exécute les scripts de cycle de vie déclarés dans le package.json de la PR ; et le job dispose du contexte privilégié.

Le contournement structurant : ne jamais construire du code non revu dans un contexte privilégié. Deux patterns propres.

Pattern A — séparation pull_request + workflow_run : la construction s’exécute sans secret sur l’événement pull_request et publie un artefact ; un second workflow, déclenché par workflow_run, consomme l’artefact et détient les secrets sans jamais exécuter le code de la PR.

Pattern B — gating explicite par environnement protégé, quand un pull_request_target est inévitable :

on:
  pull_request_target:
    types: [labeled]
permissions: {}
jobs:
  e2e:
    if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
    runs-on: ubuntu-latest
    environment: pr-e2e        # reviewers requis → approbation humaine
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          persist-credentials: false
      - run: npm ci --ignore-scripts

Le label ne se pose qu’après lecture du diff, l’environnement impose une approbation, persist-credentials: false évite de laisser un token Git exploitable dans .git/config, et --ignore-scripts coupe le vecteur postinstall.

Les trois erreurs récurrentes

Caches non cloisonnés. Le cache Actions est scopé par branche : un run peut lire les caches de sa branche, de la branche de base et de la branche par défaut. Un job pull_request_target s’exécute dans le scope de la base : il peut donc écrire une entrée de cache que les workflows de main — y compris celui de publication — restaureront ensuite. Un node_modules ou un dossier dist empoisonné devient persistant. Corollaire : traitez tout cache comme une entrée non fiable.

# clé trop large : réutilisée entre workflows et entre niveaux de privilège
- uses: actions/cache@v4
  with:
    path: node_modules
    key: node-modules
    restore-keys: node-

# clé cloisonnée : lockfile + identité du workflow, pas de restore-keys permissif
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ github.workflow }}-${{ hashFiles('**/package-lock.json') }}

Mettez en cache le store du gestionnaire de paquets (~/.npm, ~/.pnpm-store), jamais un node_modules résolu ni des binaires exécutables, et n’utilisez jamais le même préfixe de clé entre un job non fiable et un job de release.

Permissions OIDC trop larges. id-token: write accordé au niveau du workflow signifie que tout job, y compris celui qui exécute du code non revu, peut demander un jeton OIDC et l’échanger contre des credentials cloud. Le durcissement se joue des deux côtés : granularité côté workflow, et conditions strictes côté fournisseur sur les claims (repo, ref, environment, job_workflow_ref) — pas seulement repo:org/name:*.

Absence d’épinglage. uses: some/action@v3 est un tag mutable. Un mainteneur d’action compromis repointe le tag et exécute son code dans tous les pipelines qui l’utilisent. Épinglez par SHA de commit, avec le tag en commentaire pour la lisibilité, et laissez Dependabot gérer les bumps.

Checklist de durcissement immédiate

# .github/workflows/ci.yml
permissions: {}          # tout refuser par défaut, élargir job par job
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read     # strictement le nécessaire
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with: { persist-credentials: false }
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with: { node-version: 22, cache: npm }
      - run: npm ci --ignore-scripts
      - run: npm audit signatures
      - run: npm test

À dérouler dans l’ordre :

  1. Au niveau de l’organisation, restreindre les actions autorisées (actions locales + vérifiées + liste blanche par SHA) et fixer le GITHUB_TOKEN par défaut en lecture seule.
  2. permissions: {} en tête de chaque workflow, élargissement explicite par job.
  3. Épinglage par SHA de toutes les actions tierces, y compris les actions imbriquées de vos actions composites.
  4. Séparation stricte build / publish : le job de publication ne compile rien, ne restaure aucun cache de branche non protégée, et consomme uniquement un artefact signé.
  5. Publication derrière un environment protégé avec reviewers requis et branches autorisées limitées aux tags de release.
  6. --ignore-scripts par défaut dans le CI ; autorisation explicite des rares paquets qui en ont légitimement besoin (onlyBuiltDependencies avec pnpm).

Publier sans secret de longue durée

C’est le changement le plus rentable. Le Trusted Publishing npm s’appuie sur l’OIDC de GitHub Actions : plus de NPM_TOKEN dans les secrets du dépôt, donc plus rien à exfiltrer, et une attestation de provenance générée automatiquement. npm a par ailleurs acté la fin des classic tokens ; si vous en avez encore, ils sont à révoquer, pas à faire tourner.

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read
      id-token: write      # uniquement ici
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with: { persist-credentials: false }
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm publish --provenance --access public

Côté consommation, npm audit signatures vérifie les signatures du registre et les attestations de provenance des paquets installés : intégrez-le comme étape bloquante, pas comme information.

Détection et réaction

Ce que vous devez pouvoir faire en moins d’une heure, un dimanche soir :

# 1. Les versions réellement installées, sans faire confiance aux ranges
npm ls --all keyv cacheable 2>/dev/null

# 2. Diff du lockfile sur la fenêtre suspecte
git log -p --since="2026-08-03" -- package-lock.json

# 3. Repérer les scripts de cycle de vie ajoutés récemment
grep -rn '"\(pre\|post\)\?install"' node_modules/*/package.json | head -50

# 4. Vérifier signatures et provenance
npm audit signatures

# 5. Inventaire des tokens de publication encore actifs
npm token list

Côté GitHub, l’audit log de l’organisation reste la source de vérité : filtrez sur les créations de PAT, les modifications de secrets et de protections de branche, et les workflow_run déclenchés sur pull_request_target. Mettez en place une alerte sur toute publication npm non corrélée à un tag de release — un webhook registre vers votre canal d’incident coûte une demi-journée et détecte la propagation d’un ver en minutes.

Le plan de révocation doit être écrit avant l’incident : ordre de rotation (tokens de publication d’abord, puis PAT, puis credentials cloud statiques, puis clés de déploiement), invalidation des sessions, et vérification qu’aucun secret rotationné ne subsiste dans un cache Actions ou un log de run. Pensez à purger les caches (gh cache delete --all) : la rotation d’un secret ne nettoie pas un node_modules empoisonné.

Aller plus loin

Un SBOM généré à chaque release (CycloneDX ou SPDX, stocké comme artefact d’attestation) transforme la question « suis-je exposé ? » d’une enquête de plusieurs heures en une requête.

Une politique de cooldown est probablement la mesure la plus efficace par unité d’effort : la plupart des paquets malveillants sont retirés du registre en quelques heures à quelques jours. Différer l’adoption des nouvelles versions absorbe la majorité des campagnes. Avec pnpm :

# .npmrc
minimum-release-age=1440      # ignorer les versions publiées il y a moins de 24 h

Dependabot propose l’équivalent via cooldown dans dependabot.yml. En complément, un registre proxy interne permet de geler et d’inspecter les versions entrantes.

Enfin, la revue périodique de vos dépendances directes à faible facteur de bus — un mainteneur unique sur un paquet critique — n’est pas de la paranoïa : c’est exactement le profil ciblé le 4 août.

Un exercice pratique : simuler l’exfiltration

Ne validez pas vos garde-fous sur le papier. Créez un dépôt bac à sable, placez un secret canari dans un environnement protégé, puis écrivez un workflow qui joue le rôle du ver : lister l’environnement, chercher un .npmrc, tenter une sortie réseau vers un collecteur que vous contrôlez.

name: canary-exfil-drill
on: workflow_dispatch
permissions: {}
jobs:
  drill:
    runs-on: ubuntu-latest
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            registry.npmjs.org:443
            github.com:443
      - name: simulation de collecte
        run: |
          env | grep -Ei 'token|secret|key' || echo "aucun secret dans l'environnement"
          test -f ~/.npmrc && echo "npmrc présent" || echo "pas de npmrc"
          curl -m 5 -X POST https://collecteur-interne.example/drill \
            -d "probe=1" && echo "SORTIE AUTORISÉE" || echo "sortie bloquée"

Les critères de réussite sont explicites : aucun secret ne doit apparaître dans l’environnement d’un job qui exécute du code non fiable ; la sortie réseau non déclarée doit échouer ; et le run doit produire une trace exploitable. Rejouez ensuite le scénario en ouvrant une PR depuis un fork sur votre workflow réel — si un secret devient lisible, vous venez de trouver votre pull_request_target.


L’attaque keyv n’a rien introduit de nouveau sur le plan technique : elle a simplement exploité, à l’échelle, des propriétés du système que nous acceptons par défaut — un tag mutable, un postinstall exécuté sans question, un cache partagé entre niveaux de privilège, un token de publication qui vit plus longtemps que le projet. Les contre-mesures listées ici sont toutes disponibles aujourd’hui, sans outil commercial, et la plupart tiennent en quelques lignes de YAML. Le vrai coût n’est pas technique, il est organisationnel : accepter que le pipeline de release soit un système de production, avec ses revues, ses environnements protégés et ses exercices. Commencez par deux choses cette semaine — Trusted Publishing sur votre paquet le plus téléchargé, et un audit de tous vos pull_request_target. Le reste s’enchaîne.

Comments (0)

Please log in to leave a comment.

Log In

No comments yet. Be the first to comment!