Le 30 avril 2026, intercom/intercom-php 5.0.2 embarque un plugin Composer qui lance un voleur d’identifiants. Trois semaines plus tard, des tags historiques de quatre paquets laravel-lang sont republiés vers du code malveillant grâce à un token GitHub fuité. Aucune de ces attaques n’exploite une faille de PHP : elles exploitent ce que Composer fait par construction — exécuter du code tiers à l’installation et faire confiance à un tag.
Une attaque supply chain PHP consiste à compromettre un paquet Packagist — via un compte, un token ou un tag — pour exécuter du code malveillant chez tous ceux qui l’installent. Cet article explique pourquoi ça marche, ce que Packagist a changé, et ce qu’un projet Symfony doit verrouiller. Les comportements de Composer cités ont été testés sur Composer 2.10.3, ceux de zizmor sur la version 1.31.0, tous deux en Docker.
Ce qui s’est réellement passé en 2026
intercom-php (30 avril). L’attaquant n’a pas publié une nouvelle version : il a écrasé la 5.0.2 avec un code qui transforme le paquet en plugin Composer. À l’installation, le plugin télécharge le runtime Bun et lance une charge obfusquée, la même que celle de la campagne npm visant intercom-client. Elle collecte tokens GitHub, clés SSH, identifiants cloud et variables d’environnement, puis les exfiltre (analyse Semgrep).
laravel-lang (22 et 23 mai). Avec un token GitHub personnel fuité, l’attaquant republie des tags historiques de laravel-lang/lang, http-statuses, attributes et actions. Le payload tient dans un src/helpers.php déclaré en autoload.files : il s’exécute à chaque requête, chaque commande console, chaque worker — sans plugin ni script. Packagist retire les versions et déliste les paquets le 23 mai. Le volume est contesté : 233 versions selon Aikido, environ 700 selon Socket et Snyk, ce dernier expliquant le premier chiffre par un instantané précoce. Retenez l’ordre de grandeur — des centaines de versions — plutôt qu’un chiffre exact.
D’autres cas complètent le tableau. En mai, huit paquets Packagist cachaient leur charge dans un hook postinstall de package.json : un outil qui ne lit que les métadonnées Composer ne la voit pas. Mi-juin, la campagne PolinRider a compromis des comptes de mainteneurs et injecté du code dans des branches de développement, notamment visanduma/nova-two-factor, avec une exécution à l’ouverture du dépôt via .vscode/tasks.json (analyse Socket). Les décomptes globaux de ces deux campagnes varient selon les sources.
Pourquoi ça marche : quatre vecteurs
Packagist le dit lui-même : presque chaque attaque récente a consisté à modifier des tags git existants après coup. Un numéro de version épinglé ne protège donc de rien tant que le lock ne fixe pas le commit et que personne ne regarde ce qu’il change. Les vecteurs se rangent en quatre familles :
- Le plugin Composer : du code exécuté pendant
installouupdate, avec les droits de l’utilisateur — en CI, ceux d’un job souvent riche en secrets. C’est ce que bloqueallow-plugins. - Les fichiers autoload : du code exécuté à chaque démarrage de l’application, donc en production. Ni
--no-pluginsni--no-scriptsn’y changent rien ; seule la non-installation de la version malveillante protège. - Le retag et les tokens volés : un token GitHub fuité suffit à réécrire l’historique des tags. D’où l’importance du lock, qui fixe la référence du commit, et de l’immuabilité côté registre.
- Les branches de dev et les fichiers hors composer.json :
dev-mainbouge sans retag ;package.json,.vscode/tasks.jsonet les workflows échappent à un audit Composer.
Le même schéma — du code exécuté au moment où l’on fait confiance — se retrouve avec les agents de code qui installent des dépendances, comme nous l’avons détaillé dans notre article sur la sécurité des agents de code et la supply chain.
Ce que Packagist et l’écosystème ont mis en place
- Le flux malware d’Aikido, importé par Packagist depuis mars 2026. Composer 2.10 (28 mai) bloque ces versions par défaut — la configuration détaillée est dans notre article sur config.policy, le blocage des malwares et l’audit en CI.
- Un transparency log, financé par le Sovereign Tech Agency, qui journalise les changements de propriétaires, de mainteneurs et de références de versions. Il a servi à reconstituer la chronologie des retags.
- Des versions stables immuables depuis le 7 juillet 2026 : le commit associé à une version stable ne peut plus changer, un retag est rejeté. Limites : les branches de dev sont exclues, les archives zip générées par GitHub ne sont pas couvertes, et un lock déjà pollué n’est pas corrigé (billet Packagist).
- Le durcissement des workflows de Packagist lui-même avec zizmor.
- Une équipe Ecosystem Security à la PHP Foundation, annoncée le 18 mai 2026 et financée par Alpha-Omega. Sa mission est le triage des vulnérabilités et l’outillage pour l’écosystème au sens large : ce n’est pas un programme dédié à Composer.
Ce qui manque encore : le MFA obligatoire pour les mainteneurs, des artefacts immuables avec provenance, et le « cooldown » qui retarderait l’adoption des versions trop fraîches. La documentation de Composer décrit déjà policy.cooldown, mais nos tests montrent qu’il est sans effet en 2.10.3 : ne comptez pas dessus.
Ce que Composer bloque vraiment : le test
Nous avons fabriqué un faux paquet acme/evil-plugin de type composer-plugin, dont l’activation affiche un message. Sans entrée dans allow-plugins et en mode non interactif, comme en CI, l’installation échoue :
$ composer install --no-interaction
...
In PluginManager.php line 821:
acme/evil-plugin contains a Composer plugin which is blocked by your allow-
plugins config. You may add it to the list if you consider it safe.Avec true, le code du plugin s’exécute. Avec false, il est ignoré sans bruit. Avec --no-plugins, il ne tourne pas. Le comportement par défaut est donc sûr en CI — à condition de ne pas avoir mis true par réflexe pour faire passer un build.
Le second test reproduit le cas laravel-lang : une dépendance qui déclare un fichier en autoload.files et un script post-install-cmd, installée avec --no-scripts --no-plugins. Le script de la dépendance ne s’exécute jamais — Composer ne lance que les scripts du projet racine. Mais dès que vendor/autoload.php est chargé, le fichier s’exécute. Les options d’installation bloquent le vecteur « install », pas le vecteur « runtime ». Seuls le lock, le blocage des malwares et la revue des mises à jour couvrent ce dernier.
Checklist défensive côté composer.json et lock
allow-plugins en liste blanche explicite. Un projet Symfony n’a en général besoin que de quelques plugins. Partez de ceux réellement présents dans votre lock, et faites passer chaque nouvelle entrée en revue de PR :
Votre dette technique s'accumule ?
Audit complet en 10 jours. Recommandations priorisées et actionnables.
{
"config": {
"allow-plugins": {
"symfony/flex": true,
"symfony/runtime": true,
"phpstan/extension-installer": true
},
"sort-packages": true
}
}Lock commité, install seulement. En CI comme dans le build de l’image, jamais de composer update : le lock fige les références. Les scripts du projet se rejouent ensuite explicitement, après revue.
# Build reproductible, sans exécuter de plugin ni de script à l’installation
composer install --no-interaction --no-progress --no-scripts --prefer-dist \
--no-dev --classmap-authoritative
# Rejouer uniquement ce qu’on maîtrise (recettes Flex : cache:clear, assets:install…)
composer run-script auto-scripts
# Audit du lock : code de sortie 1 en cas d’échec depuis Composer 2.10
composer audit --lockedRevue du diff de lock. C’est la défense la plus sous-estimée. Une même version avec une reference différente est la signature d’un tag réécrit ; l’apparition d’un paquet de type composer-plugin mérite toujours une question.
# Même version, référence différente = tag réécrit
git diff origin/main -- composer.lock | grep -E '^[+-]\s+"(version|reference)"'- Pas de
dev-*en production. Si une branche est inévitable, épinglez un commit précis avec la syntaxedev-main#sha. - Bannir
--no-blockingdes scripts CI. L’option existe en 2.10.3 et désactive justement le blocage des malwares. - Rester sur la dernière 2.10.x. Composer a lui-même corrigé plusieurs failles en 2026, dont une fuite de token GitHub Actions et des traversées de chemin sur les binaires.
Durcir la CI GitHub Actions
Dans tous ces incidents, la cible est la CI : c’est elle qui détient tokens, clés de déploiement et secrets. Nous avons passé à zizmor un workflow PHP tel qu’on en voit trop souvent — déclencheur pull_request_target, actions épinglées par tag mutable, aucun bloc permissions, titre de PR interpolé dans un run. Résultat : 9 findings, dont 4 de sévérité haute.
$ docker run --rm -v "$PWD":/w -w /w ghcr.io/zizmorcore/zizmor:1.31.0 --offline .
error[dangerous-triggers]: use of fundamentally insecure workflow trigger
error[template-injection]: code injection via template expansion
error[unpinned-uses]: unpinned action reference
warning[excessive-permissions]: overly broad permissions
warning[artipacked]: credential persistence through GitHub Actions artifacts
9 findings (3 suppressed, 2 unsafe fixes): 0 informational, 0 low, 2 medium, 4 highLa version corrigée ne remonte plus aucun finding : actions épinglées par SHA de commit complet, avec le tag en commentaire pour que Dependabot ou Renovate continuent de proposer les mises à jour ; aucun droit par défaut ; identifiants non persistés après le checkout ; déclencheur pull_request.
name: CI
on:
pull_request:
push:
branches: [main]
permissions: {} # aucun droit par défaut
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # strict minimum pour le checkout
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
with:
php-version: '8.4'
- run: composer install --no-interaction --no-scripts --no-progress
- run: composer audit --lockedPiège de l’épinglage : pour un tag annoté, le SHA à retenir est celui du commit, obtenu en déréférençant le tag (git ls-remote avec le suffixe ^{}), pas celui de l’objet tag. Pour aller plus loin sur la CI, notre article sur la sécurisation d’un pipeline GitHub Actions détaille les permissions et l’isolation des secrets. Enfin, faites tourner zizmor dans une PR qui touche .github/workflows/, en job requis : un rapport que personne ne lit ne protège rien.
Si vous avez été touché
- Reconstruire plutôt que nettoyer : traitez l’environnement d’installation comme compromis, isolez-le et repartez d’une image saine.
- Faire tourner tous les secrets lisibles par le processus PHP et par la CI : clés cloud, tokens GitHub, fichiers
.env, clés SSH, tokens de registre. - Fouiller l’historique, pas seulement l’état courant : les numéros de version ayant été remappés vers du code légitime après coup, un lock « propre » aujourd’hui ne prouve pas qu’une version empoisonnée n’a pas été installée le 22 mai. Cherchez dans l’historique git du lock.
- Restreindre les sorties réseau de la CI et de la production : c’est ce qui transforme un vol de secrets en tentative avortée.
La checklist en huit points
allow-pluginsen liste blanche explicite, revue en PR.composer.lockcommité,composer installuniquement en CI et au build.--no-scriptsà l’installation, scripts du projet rejoués explicitement.composer audit --lockedbloquant etconfig.policyexplicite.- Revue du diff de lock : nouvelle référence pour une version inchangée, nouveau plugin.
- Aucune branche
dev-*en production. - GitHub Actions épinglées par SHA,
permissions: {},persist-credentials: false, pas depull_request_target, zizmor en CI. - Secrets de CI à portée minimale et durée courte, sorties réseau restreintes.
Les garde-fous de Packagist protègent le registre, pas votre pipeline. Trois actions tiennent en une heure cette semaine : passer sur la dernière 2.10.x, fermer allow-plugins, épingler vos actions par SHA. Le reste est un travail de fond.
Cette checklist couvre ce qu’une équipe peut vérifier seule en une demi-journée. Ce qui prend du temps, c’est de cartographier les plugins et scripts réellement exécutés dans votre chaîne, les droits de vos tokens CI et vos dépendances transitives. C’est le type de point que couvre le Bear Scan, un audit de dix jours qui rend un état des lieux priorisé de votre exposition.

Écrit par
Flavien Métivier
Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.
Mon parcours