Le 22 mai 2026, un attaquant qui avait un accès push sur l’organisation GitHub Laravel-Lang a réécrit plus de 700 tags git sur plusieurs packages Composer (laravel-lang/http-statuses, laravel-lang/actions, laravel-lang/attributes) en une quinzaine de minutes. Pas une nouvelle version publiée par-dessus l’ancienne : les tags existants, réécrits.
Le payload injectait un fichier helpers.php dans la section autoload.files du composer.json, ce qui le fait s’exécuter à chaque démarrage de l’application. De là, il exfiltrait les secrets présents sur la machine.
Le détail qui pique : épingler une version précise dans votre composer.json ne suffisait pas. Pourquoi ? On va y revenir plus loin.
Le premier article de cette série posait le décor et démontait les premiers réflexes. Celui-ci passe au concret : qu’est-ce qui, côté Laravel/PHP et côté JavaScript, aurait changé quelque chose pour Laravel-Lang et pour les attaques du même genre. Avec une bonne nouvelle (les gestionnaires de paquets ont beaucoup progressé) et une moins bonne (il reste des trous, et ils ne sont pas au même endroit selon l’écosystème).
Quelques principes, vite fait
Le premier article a déjà creusé ça, donc on va vite. Quatre idées tiennent tout le reste :
- Le lock file est la source de vérité.
composer.lock,package-lock.json,pnpm-lock.yaml: c’est la photo exacte de ce qui a été installé, avec les hashs pour vérifier l’intégrité. On installe à partir de lui, il est essentiel de le maintenir et de s’assurer qu’il est bien utilisé partout. - Le délai de quarantaine (le “cooldown”). La plupart des versions malveillantes sont repérées et retirées dans les heures qui suivent leur publication. Attendre quelques jours avant d’installer une version toute fraîche filtre une grande partie des attaques. Cela va certes en contradiction avec le fait d’avoir les derniers correctifs à jour, mais prévient d’avoir la dernière backdoor qui n’a pas encore été repérée.
- Réduire la surface d’exécution. Moins vous laissez de code tourner au moment de l’installation, moins vous offrez de portes.
- Auditer est un acte, pas un outil magique. Un scanner voit ce qu’il connaît déjà. Ça aide, ça ne suffit jamais.
La nouveauté, depuis un an, c’est que ces principes ne sont plus seulement des bonnes pratiques à bricoler soi-même. Une partie est désormais native dans les gestionnaires de paquets. Mais pas la même partie selon que vous êtes côté PHP ou côté JS, et c’est tout l’intérêt de regarder les deux.
Côté Laravel : Composer
Déjà, côté Composer, commençons par éclaircir et distinguer deux gestes qui n’ont pas du tout le même niveau de risque : install d’un côté, update et require de l’autre.
composer install rejoue le lock. C’est ce que vous lancez en CI, en prod, sur la machine d’un nouveau développeur. C’est répétitif, automatisable, et ça ne devrait jamais introduire de nouvelle version. composer update et composer require, eux, modifient l’arbre de dépendances. C’est un acte volontaire, fait par un humain, et c’est précisément là que de nouvelles versions (potentiellement piégées) entrent dans votre projet.
Une fois cette séparation en tête, on peut placer chaque défense au bon endroit.
Sur install : verrouiller, et --no-scripts en ceinture-bretelle
En CI et en prod, vous installez à partir du lock, point. composer install, jamais composer update.
Un cas particulier mérite un mot : les librairies. Par tradition, un package destiné à être inclus par d’autres ne versionne pas son composer.lock (Composer ignore de toute façon le lock d’une dépendance, seul celui de l’application finale compte). Résultat : la CI d’une librairie re-résout son arbre à chaque exécution, exactement comme un update. C’est d’autant plus gênant que cette CI détient souvent les jetons de publication du package, ce qui permet de la compromettre, et donc d’empoisonner le package pour tous ses consommateurs à son tour. La pratique commence à bouger sur ce point : de plus en plus de mainteneurs versionnent quand même un composer.lock, uniquement pour fiabiliser et sécuriser leur propre pipeline, sans aucun effet en aval puisque ce lock n’est jamais réutilisé par les consommateurs.
Aussi, le deuxième réflexe, ajoutez --no-scripts :
composer install --no-dev --no-scripts --prefer-dist
L’idée : en CI ou en prod, vous n’avez en général pas besoin que les scripts de cycle de vie des packages (post-install-cmd et compagnie) s’exécutent. Les couper, c’est fermer un vecteur classique d’exécution de code à l’installation. C’est une vraie ceinture-bretelle, et ça ne coûte presque rien.
Mais soyons honnêtes sur sa limite, car l’actualité nous l’a montré. --no-scripts n’aurait pas bloqué Laravel-Lang. Le vecteur d’exécution n’était pas un script de cycle de vie, c’était autoload.files. Et l’autoload, ce n’est pas un script : c’est le mécanisme normal de Composer pour charger du code.
L’autoload, c’est ça le piège
Quand un package déclare un fichier dans autoload.files, Composer le câble dans vendor/composer/autoload_files.php. Et ce fichier est inclus à chaque fois que vendor/autoload.php est chargé. C’est-à-dire en permanence : chaque requête web, chaque php artisan, chaque worker de queue, chaque exécution de la suite de tests, chaque déploiement.
Ça a deux conséquences.
La première, c’est que --no-scripts ne désarme pas la bombe. Sur un composer require dans un projet Laravel, c’est le hook @php artisan package:discover (un script post-autoload-dump) qui boote l’application et déclenche le payload pendant la commande. Si vous passez --no-scripts, ce hook est sauté, donc le require ne boote pas l’app : vous évitez le déclenchement immédiat sur votre machine. Mais le fichier malveillant est déjà inscrit dans l’autoloader. Au tout prochain chargement de vendor/autoload.php, il s’exécute. --no-scripts vous évite d’appuyer vous-même sur la détente, il ne retire pas la balle du chargeur qui partira au premier script PHP qui suit.
La seconde conséquence est plus importante encore : une fois l’erreur commise, c’est toujours trop tard. Un scanner, un audit, une alerte de la communauté, un filtre malware qui se met à jour : tout ça ne prévient qu’après coup. Et des vecteurs d’exécution, il en existera toujours d’autres : c’est la nature même d’une dépendance que de voir son code exécuté chez vous. Les attaques par supply chain appartiennent à la famille des RCE (exécution de code à distance), leur but est de faire tourner du code sur vos machines. Or une fois ce code exécuté, avec vos permissions, à côté de vos secrets, le dégât est irréversible : une clé d’API ou un token de CI exfiltré ne se « dé-fuite » pas, être prévenu le lendemain n’y change rien. Il ne reste plus qu’à tout auditer, puis révoquer et regénérer l’ensemble des secrets.
Retenez cet enchaînement, parce que tout le reste de la partie PHP en découle : pour un vecteur comme autoload.files, seule la prévention compte vraiment. Détecter après coup limite la propagation et la durée, mais n’annule pas une exécution déjà survenue.
Sur update / require : Composer 2.10 et son filtre malware
C’est là qu’arrive la vraie nouveauté côté PHP. Composer 2.10 embarque un filtrage natif des malwares. Les données viennent d’Aikido (sous licence CC-BY 4.0) et c’est activé par défaut pour tous les utilisateurs de Packagist.org. Concrètement, une version identifiée comme malveillante est bloquée pendant install, update et require, et composer audit échoue dessus par défaut.
Le détail malin : le contrôle tourne aussi sur install. Donc si une version a glissé dans votre composer.lock avant d’être identifiée comme malveillante, le prochain composer install échoue. Une version piégée qui aurait atterri dans votre lock ne sera pas réinstallée silencieusement en CI ou en prod.
Au passage, la 2.10 range tout ça dans un cadre unifié, config.policy, qui remplace l’ancien config.audit et gère d’une même main les avis de sécurité, les packages abandonnés et les malwares. Et elle déprécie le fallback “source” lors des téléchargements (suppression complète prévue en 2.11), qui était une voie d’attaque possible.
En pratique, le réflexe à câbler, c’est de garantir une version de Composer ≥ 2.10 en CI (sinon pas de filtre malware actif), puis de lancer composer audit sur la PR d’update :
composer audit
Vous transformez une mise à jour en acte vérifiable : une PR dédiée, une diff de lock lisible, un audit qui passe ou qui casse le build.
Le trou qui reste : pas de cooldown natif
Voilà le point honnête, et c’est le plus important de la partie PHP. Le filtre malware de Composer 2.10 est réactif. Il ne voit une version que si elle a déjà été identifiée par Aikido (ou signalée par la communauté). Il y a donc une fenêtre d’exposition, et cette fenêtre, c’est le temps de détection.
Le scénario concret : vous faites un composer require ou composer update sur une version publiée il y a une heure et déjà piégée. Aikido ne l’a pas encore flaggée, donc Composer la laisse passer sans broncher. Et avec un vecteur autoload.files, le code s’exécute immédiatement. Si personne n’a encore repéré la version (et la détection n’est ni instantanée ni garantie), rien ne vous arrête.
C’est exactement ce qu’un cooldown comblerait : refuser d’installer une version trop récente, le temps que la communauté ait une chance de repérer un problème. Sauf que Composer n’a pas de cooldown natif aujourd’hui. Sur ce point précis, PHP est en retard sur l’écosystème JavaScript.
Et la raison est instructive. Un cooldown n’a de valeur que si la date de publication est fiable. Or, côté Composer, cette date vient du timestamp du commit ou du champ time du composer.json : des données contrôlées par le mainteneur, donc par l’attaquant. Avec Laravel-Lang, qui a réécrit les tags, un attaquant pourrait tout aussi bien antidater une version pour passer sous le radar du cooldown. C’est précisément ce que pointe la discussion sur ce sujet (issue #12793 de Composer) :
“An attacker could right now easily fake the release date to put it in the past, as the release is under control of the package authors.”
Une PR a tenté d’ajouter un minimum-release-age (PR #12692), une autre issue a demandé une date de publication contrôlée par le registre (issue #12793) : les deux ont été fermées, la seconde “as not planned”. La vraie limite, on le voit, n’est pas dans Composer, elle est chez Packagist.
La nouvelle encourageante, c’est que le socle vient d’être posé. Comme l’explique l’équipe de Packagist, les versions stables sont désormais immuables : on ne peut plus réécrire un tag publié, ce qui ferme le vecteur de re-tagging utilisé par Laravel-Lang. Leur formulation résume bien le prérequis :
“We need release metadata to be reliably immutable before we can safely treat ‘publication time’ as a security input.”
Le socle est posé, donc, mais la fonctionnalité de cooldown, elle, n’est pas encore là. En attendant, côté développeur, il reste trois leviers, tous non natifs :
- Renovate
minimumReleaseAgeou le cooldown de Dependabot. Efficace, mais seulement si vous passez par ces outils, et seulement sur les PR d’update automatisées (pas sur uncomposer requirelancé à la main).
// renovate.json : on n'accepte pas une version de moins de 3 jours
{
"minimumReleaseAge": "3 days"
}
- Private Packagist, qui propose quarantaine et détection de malware à l’ingestion. Solide, mais c’est de l’infrastructure que peu de petites équipes feront tourner.
- La discipline manuelle, faute de mieux : ne pas faire un
require/updatesur une release du jour, lire la diff du lock, s’appuyer sur les hashs.
Épingler : utile, mais pas suffisant tout seul
L’épinglage de version dans le composer.json, pourquoi pas, mais ce n’est pas là que se joue la sécurité, et honnêtement je n’en suis pas fan. Jouer sur les contraintes du composer.json brouille la lecture pour les développeurs, et surtout ça ne verrouille pas l’ensemble de l’arbre de dépendances, seulement ce que vous déclarez en direct.
Ce qui protège, c’est le composer.lock, qui fige bien plus qu’un numéro de version : il enregistre le commit SHA exact de chaque package (dist.reference). Un composer install rejoué à partir d’un lock validé va donc chercher le contenu à ce SHA, pas au tag réécrit. C’est pour ça qu’un install à partir d’un lock de confiance est plus sûr qu’un update à l’aveugle. On pourrait discuter de la confiance qu’on accorde à ce SHA, mais depuis l’immuabilité des versions stables côté Packagist, le vecteur de re-tagging est de toute façon refermé au niveau du registre.
Côté JavaScript : npm, yarn, pnpm
Même grille de lecture qu’avec Composer. npm ci rejoue le lock (c’est lui qu’on utilise en CI et en prod), npm install et npm update modifient l’arbre. Tout ce qu’on a dit sur la séparation install / update tient aussi ici.
Mais l’écosystème JS a un atout que PHP n’a pas encore, et un manque que PHP, lui, a comblé. C’est ce contraste qui est intéressant.
L’atout : le cooldown, parfois activé par défaut
Le cooldown, ce fameux délai de quarantaine qui manque à Composer, existe nativement côté JS. Avec une nuance qui change tout : est-il activé par défaut ou pas ?
- pnpm :
minimumReleaseAgeest activé par défaut depuis pnpm v11 (1 jour, soit 1440 minutes). pnpm a été le premier gestionnaire majeur à livrer un cooldown par défaut. La fenêtre publication/détection est donc fermée d’office. - Yarn (Berry) :
npmMinimalAgeGateest lui aussi activé par défaut (depuis Yarn 4.10), et à 3 jours, encore plus prudent que pnpm. - npm :
min-release-agea été introduit dans npm 11.10.0 (février 2026), mais il est désactivé par défaut. Tant que vous ne l’activez pas, npm a exactement le même trou que Composer. npm v12 devrait l’activer par défaut, mais en attendant, c’est à vous de le faire.
Pour npm, ça se règle dans la config :
# .npmrc : on refuse les versions de moins de 7 jours
min-release-age=7
Le raisonnement derrière est toujours le même : la grande majorité des versions malveillantes sont retirées en quelques heures. Un délai de 24 heures filtre déjà les attaques “smash-and-grab”, et une semaine vous met à l’abri de la quasi-totalité d’entre elles. Au prix d’une semaine de retard sur les nouveautés, ce qui, pour des dépendances, n’a jamais tué personne.
Pratiquement, le choix du gestionnaire devient un choix de sécurité : par défaut, pnpm et yarn vous protègent là où npm vous laisse vous débrouiller.
Le manque : pas de filtre malware natif dans le client
Voici l’autre face du contraste. Côté JS, il n’y a pas d’équivalent du filtre malware de Composer 2.10 intégré dans le client. Le registre npm retire bien les packages malveillants quand ils sont identifiés (c’est l’équipe sécurité de GitHub qui s’en occupe), mais ce n’est pas un blocage activé par défaut au moment où vous installez.
La détection proactive passe donc par des outils tiers, parfois appelés “supply chain firewall” :
- Socket, qui inspecte les packages sur 70 et quelques signaux (code obfusqué, accès réseau ou fichiers inattendus, exécution de commandes shell, scripts d’installation).
- Aikido Safe Chain, qui se branche sur npm, yarn et pnpm pour bloquer un package malveillant au moment de l’installation.
- Snyk, OSV-Scanner, et d’autres.
Et il faut le dire clairement : ces moteurs ont le même angle mort réactif que le filtre de Composer. Ils voient ce qui a été identifié, pas un vrai zero-day publié il y a dix minutes. Le scan ne remplace pas le cooldown, il l’accompagne.
Le tableau qui résume tout
Si on met les deux écosystèmes côte à côte, on voit que personne n’est complet :
| Filtre malware natif (client) | Cooldown | |
|---|---|---|
| Composer (PHP) | Oui (2.10, Aikido, par défaut) | Non, aucun natif |
| npm | Non (outil tiers) | Oui, mais désactivé par défaut |
| pnpm / yarn | Non (outil tiers) | Oui, par défaut |
La leçon n’est pas “tel écosystème est meilleur”. C’est qu’une défense robuste combine cooldown et détection malware, et qu’aucun gestionnaire ne vous offre les deux nativement aujourd’hui. Côté PHP, vous avez le filtre mais devez ajouter le cooldown par l’extérieur. Côté JS, vous avez le cooldown (à activer pour npm) mais devez ajouter la détection par un outil tiers.
Réduire la surface : les lifecycle scripts
L’équivalent du --no-scripts de Composer existe partout :
npm install --ignore-scripts
# ou en global, une fois pour toutes
npm config set ignore-scripts true
Le souci, c’est que certains packages ont réellement besoin de leur script de build (les modules natifs, par exemple). Couper tout casse parfois l’installation. pnpm propose une approche plus fine : une allowlist explicite des seuls packages autorisés à exécuter leurs scripts.
// package.json : seul esbuild a le droit d'exécuter ses scripts
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
C’est le bon compromis : par défaut, rien ne s’exécute, et vous ouvrez la porte au cas par cas, en connaissance de cause.
npm audit : utile, mais ne vous croyez pas couvert
npm audit (et composer audit) listent les vulnérabilités connues, référencées dans des bases de données. C’est précieux pour les CVE classiques. Mais face à une supply chain attack, au moment où elle arrive, il n’y a rien dans la base : le malware vient d’être publié. audit ne le voit pas. C’est un filet pour le passé connu, pas pour la menace du jour.
Ce qui ne vous sauvera pas
Récapitulons les faux amis, parce que ce sont eux qui endorment.
- Épingler un tag. Inutile si le tag est réécrit, comme l’a montré Laravel-Lang. C’est le hash du lock qui protège, pas le numéro de version.
- Faire confiance aux packages populaires. Axios totalisait 70 millions de téléchargements par semaine quand il a été compromis. La popularité ne dit rien de la sécurité d’une version donnée.
audit(Composer ou npm). Aveugle à ce qui n’est pas encore référencé. Donc aveugle à l’attaque au moment où elle compte.- Le filtre malware de Composer 2.10. Réactif. Il ne voit une version qu’une fois flaggée. Sur un
require/updateà la main, une version fraîche pas encore détectée passe. Son seul filet (l’échec du prochaininstall) est rétroactif : il n’aide pas la machine qui l’a déjà tirée. --no-scripts/ignore-scripts. Ne bloque pasautoload.filescôté PHP, ni tous les vecteurs côté JS.- L’absence de cooldown natif côté PHP. Rien ne couvre nativement la fenêtre entre la publication et la détection, contrairement à pnpm et yarn qui l’activent par défaut.
Aucune de ces défenses n’est inutile. Elles sont simplement incomplètes prises isolément, et dangereuses si elles vous donnent un faux sentiment de sécurité.
Pour aller plus loin : et si on sandboxait l’installation ?
Puisqu’aucun filet ne couvre parfaitement la fenêtre publication / détection, on peut remonter d’un cran : plutôt que d’espérer détecter le mauvais package, faire en sorte qu’il n’ait rien à voler même s’il s’exécute. L’idée, c’est de lancer l’installation des dépendances dans un conteneur jetable, sans vos secrets, sans vos clés, et surtout sans réseau sortant (au mieux une allowlist vers le registre). Même si un post-install ou un autoload.files s’exécute, il n’a rien à exfiltrer et nulle part où l’envoyer. C’est l’egress, pas le scan local, qui neutralise l’attaque la plus courante.
Ça ne remplace rien (le cooldown, l’intégrité du lock et le moindre privilège restent nécessaires) et ça ne couvre pas le runtime. Mais c’est tout un sujet en soi, et c’est exactement celui de l’article suivant.
À mettre en place dès le prochain commit
Pas besoin d’attendre une refonte. Le concret, tout de suite :
- En CI et en prod,
composer install --no-scriptsetnpm ci, jamaisupdate. On rejoue le lock, on n’introduit rien. - Activez un cooldown. Sur pnpm et yarn, vérifiez juste qu’il est bien là (il l’est par défaut). Sur npm, ajoutez
min-release-age. Sur Composer, faute de natif, branchezminimumReleaseAgedans Renovate ou Dependabot. - Câblez
composer auditetnpm auditdans la CI, sur les PR d’update. Et pour Composer, vous bénéficiez en plus du filtre malware natif de la 2.10. - Réduisez la surface d’exécution.
--no-scripts/ignore-scriptspar défaut, et une allowlist (onlyBuiltDependencies) pour les rares packages qui en ont besoin. - Traitez chaque update comme un acte. Une PR, une diff de lock lue, un changelog parcouru. Pas un effet de bord d’un
installlancé sans réfléchir.
Un mot, pour finir, sur l’autre bout de la chaîne. Si vous publiez vous-même des packages, l’arsenal a beaucoup bougé : le trusted publishing via OIDC (qui supprime les tokens longue durée), les attestations de provenance, les tokens granulaires npm limités à 7 jours pour la publication, la 2FA matérielle (FIDO) plutôt que par SMS. C’est un sujet à part entière, côté mainteneur, qu’on gardera pour plus tard.
Le prochain article restera sur ce fil tendu en fin de partie : s’en protéger avec Docker. Images, registres, scan, builds reproductibles, et ce fameux sandbox d’installation qu’on vient juste d’effleurer. Parce que FROM node:latest, comme on le disait dans le premier article, n’est pas une commande anodine.