Introduction
Un outil qui ne bouge plus n’est pas forcément un outil qui ne sert plus. Mais quand il s’agit de sécurité, la
question finit par se poser, et elle s’est posée pour pheromone/phpcs-security-audit, le jeu de règles qui portait
depuis des années la détection d’injections et de XSS dans notre chaîne de qualité. Sa dernière version date d’août
2019. Ses 32 sniffs (règle de contrôle du code) cherchaient des motifs dans le code, ligne par ligne, et signalaient toute variable passant près
d’une requête ou d’un echo.
Le remplacer n’était pas une question d’outil, c’était une question de répartition. Nous avions déjà PHPStan au niveau 10 sur tous nos projets Symfony, et la tentation était de lui demander de reprendre le travail. Il ne le peut pas, structurellement, et son mainteneur l’a dit clairement. Nous avons donc ajouté un second analyseur statique, Psalm, en lui interdisant de faire ce que PHPStan fait déjà.
Cet article raconte cet arbitrage. Il est le pendant de celui que nous avons consacré à Nuclei : là où nous avions refusé d’ajouter un scanner qui recoupait l’existant, nous avons ici ajouté un outil, parce qu’un manque précis le justifiait. La méthode est la même dans les deux sens.
- Le point de départ :
phpcs-security-audit, 32 sniffs de détection par motif, dernière version en août 2019. - Ce que PHPStan ne fait pas encore : suivre une donnée depuis une entrée HTTP jusqu’à une requête SQL. C’est l’analyse de teinte : son mainteneur l’a refusée en 2022, avant d’annoncer en juillet 2026 qu’il s’y attellerait fin d’année ou début de la suivante.
- Notre répartition : PHPStan pour les types et la présence de fonctions dangereuses, Psalm pour le flux de
données uniquement,
composer auditpour les CVE des dépendances. Un outil par famille de vérification. - La reprise des anciennes règles : 15 règles vers Psalm, 7 vers PHPStan, 2 vers
composer audit, 1 obsolète, 1 hors périmètre. Et une dizaine de détections que nous n’avions pas avant : SSRF, LDAP, en-têtes,unserialize. - Trois arbitrages invisibles dans la config : Psalm bridé au niveau le plus lâche pour ne pas concurrencer PHPStan, un fichier de stubs maison parce qu’aucun plugin ne marque Doctrine ORM comme sink SQL, et une extension PHPStan retenue contre une autre pour ses messages et ses exemptions par chemin.
- Ce que ça coûte : deux baselines, une annotation d’échappement à connaître et un stub à resynchroniser quand Doctrine change une signature.
1. Le point de départ : un outil de 2019 qui cherchait des motifs
pheromone/phpcs-security-audit est un jeu de règles pour PHP_CodeSniffer. Il tourne sur les tokens d’un fichier et
lève une alerte quand une fonction sensible reçoit quelque chose qui ressemble à une entrée utilisateur : mysqli_query
avec une variable, exec avec une concaténation, echo d’un $_GET. Il travaille fichier par fichier, sans savoir
d’où vient la variable ni si elle a été échappée deux appels plus haut.
Le résultat est connu de quiconque l’a utilisé : des alertes en masse, et une accumulation de phpcs:ignore pour
faire taire ce qui ne peut pas être corrigé parce que ce n’est pas un problème. Un outil dont on ignore la moitié des
sorties protège de l’autre moitié, au mieux.
S’ajoute le calendrier. La version 2.0.1 date du 5 août 2019. Le dépôt n’est pas archivé, mais rien n’y bouge, et PHP
a changé depuis : mcrypt_* a disparu, le modificateur /e de preg_replace aussi. Certains sniffs cherchaient des
choses qui n’existent plus.
2. Ce qu’un type-checker ne peut pas voir
PHPStan est vert sur du code dangereux
Prenez une méthode de repository qui construit une requête DQL en concaténant un paramètre reçu d’un contrôleur.
PHPStan au niveau 10, avec les extensions Symfony et Doctrine, ne dit rien. Le paramètre est bien une string, le
QueryBuilder est bien utilisé, les types de retour sont justes.
Le code est parfaitement typé. Cependant, rien ne vérifie d’où vient ce paramètre : si un contrôleur le transmet tel quel depuis la requête HTTP, la valeur atteint la base de données sans jamais avoir été filtrée, et la requête peut devenir injectable.
Contrairement à PHPStan, Psalm permet alors de remonter cette information avec le niveau de détail suivant :
ERROR: TaintedSql - src/Repository/QueryBuilderHelper.php:208:23 - Detected tainted SQL (see https://psalm.dev/244)
Symfony\Component\HttpFoundation\InputBag::all - ../../psalm/plugin-symfony/src/Stubs/7/Component/HttpFoundation/InputBag.stubphp:31:47
public function all(?string $key = null): array {}
arrayvalue-fetch - src/Controller/AbstractCRUDController.php:1019:31
$concatFoo = $request->query->all()['foo'] ?? null;
Dans l’exemple ci-dessus Psalm détecte que la query foo est passé sans sanitize au QueryBuilderHelper.
Nous avons déroulé ce cas pas à pas dans notre article sur les référentiels de sécurité, avec le code, la sortie de l’outil et la correction. Nous ne le refaisons pas ici. Ce qui nous intéresse est la raison de fond : la vérification qui manque ne porte pas sur un type, elle porte sur un chemin. D’où vient cette valeur, et où finit-elle ?
Pourquoi PHPStan ne le fait pas aujourd’hui
C’est l’analyse de teinte, ou taint analysis : on déclare des sources (ce qui vient de l’utilisateur : $_GET,
un Request), des sinks (ce qui exécute : une requête SQL, un echo, un exec), et l’outil suit le flux entre
les deux, à travers les appels de méthodes et les fichiers. Il ne signale que lorsqu’un chemin complet existe.
La demande a été faite à PHPStan. Le 21 septembre 2022, un contributeur régulier a ouvert
l’issue #8038 en proposant d’ajouter des annotations
@phpstan-taint-source, @phpstan-taint-sink et @phpstan-taint-escape, calquées sur celles de Psalm. La réponse du
mainteneur est arrivée le jour même : « Nope, right now I’m not interested in reviewing and maintaining this », avec
une suggestion de l’implémenter en paquet tiers. L’issue est fermée depuis. Quatre ans plus tard, aucune extension
PHPStan largement adoptée ne fait ce travail.
Le sujet n’est cependant pas enterré. En juillet 2026, sur une autre issue consacrée aux paramètres sensibles (#14673), nous avons demandé directement au mainteneur où en était l’analyse de teinte. Sa réponse, le 9 juillet 2026 : « Yes, taint analysis is happening later this year and early next year! » Le travail est donc prévu, mais pas encore livré au moment où nous écrivons. En attendant, Psalm reste le remplaçant qui fonctionne aujourd’hui.
Ce n’est pas un défaut de PHPStan. C’est un choix de périmètre, et il est cohérent : un type-checker raisonne sur ce qu’une valeur est, une analyse de teinte sur ce qu’une valeur a traversé. Ce sont deux moteurs différents.
Trois familles, trois outils
Une fois cette frontière posée, la répartition s’est faite d’elle-même. Les vérifications de sécurité statiques se rangent en trois familles, et aucun outil ne couvre bien les trois.
| Famille | La question posée | L’outil qui y répond | Pourquoi celui-là |
|---|---|---|---|
| Présence | Cette fonction est-elle appelée, quelle que soit la donnée ? | PHPStan, extension phpstan-disallowed-calls | eval() ou phpinfo() sont dangereux sans qu’il y ait de flux à suivre |
| Flux | Une entrée utilisateur atteint-elle un point d’exécution ? | Psalm, analyse de teinte seule | Le seul moteur maintenu qui suit une donnée à travers le code |
| Dépendances | Une bibliothèque installée porte-t-elle une CVE connue ? | composer audit | Lit le fichier de verrouillage, pas le code |
Chaque outil garde un seul rôle. C’est la règle qui a guidé toute la configuration, et elle explique les arbitrages de la section 4.
3. Reprise des anciennes règles de contrôle phpcs-security-audit
Avant de retirer l’ancien outil, nous avons repris ses 32 sniffs un par un pour vérifier qui prenait le relais, et par quel mécanisme. C’est le travail documenté dans l’ADR du standard-bundle, que nous résumons ici. Les sniffs propres à l’API Drupal, absente de notre stack, sont écartés d’emblée.
| Ancien sniff | Ce qu’il détectait | Repris par | Mécanisme |
|---|---|---|---|
SQLFunctions, Mysqli | SQL construit avec une entrée utilisateur | Psalm | taint sql, natif pour mysqli et PDO, stub maison pour Doctrine |
SystemExecFunctions | exec, system, passthru avec une entrée utilisateur | Psalm et PHPStan | taint shell, plus bannissement de la fonction |
Backticks | l’opérateur ` | Psalm | taint shell : un opérateur ne se bannit pas par présence |
EasyRFI | include ou require avec une entrée utilisateur | Psalm | taint include |
EasyXSS | echo ou print d’une entrée utilisateur | Psalm | taint html, echo natif et Response via le plugin Symfony |
CallbackFunctions, FunctionHandlingFunctions | call_user_func et consorts avec une entrée utilisateur | Psalm | taint callable et eval |
FilesystemFunctions | fopen, unlink avec une entrée utilisateur | Psalm | taint file |
FringeFunctions | extract, parse_str avec une entrée utilisateur | Psalm | taint extract, couverture partielle |
NoEvals, Phpinfos, Asserts | eval(), phpinfo(), assert() | PHPStan | disallowedFunctionCalls |
CryptoFunctions | chiffrement faible | PHPStan | md5 et sha1 bannis, mcrypt_* retiré de PHP 8 |
ErrorHandling | error_reporting(), display_errors à l’exécution | PHPStan | error_reporting et ini_set bannis |
PregReplace | modificateur /e | personne | retiré depuis PHP 7 |
CVE/20132110, CVE/20134113 | deux CVE de PHP 5.3 | composer audit | audit de dépendances |
BadCorsHeader | Access-Control-Allow-Origin: * | projet | configuration CORS de nelmio/security-bundle, hors bundle |
Le décompte : une quinzaine de sniffs vers Psalm, sept vers PHPStan, deux vers composer audit, un obsolète, un hors
périmètre. Chaque sniff d’origine a donc un mécanisme de reprise identifié, ou une raison écrite de ne pas en avoir.
Ce que la table ne montre pas, c’est ce que le taint ajoute par rapport à l’ancien outil, et qui n’existait pas :
- un flux inter-fichiers : une entrée lue dans un contrôleur et consommée dans un service trois appels plus loin, là où PHPCS ne voyait qu’un fichier à la fois ;
- l’ORM de Doctrine :
createQuery()et les méthodeswhere,andWhere,having,groupByduQueryBuildersont marquées comme sinks SQL, là où PHPCS ne connaissait quemysqli_*; - des familles entières : SSRF via
curl_*, injection LDAP vialdap_search, injection d’en-tête viaheader(), injection d’objet viaunserialize, cookie viasetcookie; - un traitement outillé des faux positifs : une baseline dédiée et une annotation d’échappement par type de teinte, au lieu d’un commentaire d’ignore posé à l’aveugle.
4. Trois arbitrages que la configuration ne dit pas
Un psalm.xml et un phpstan.neon se lisent en une minute. Les décisions qu’ils encodent ne se lisent pas du tout.
En voici trois.
Psalm en mode teinte, et rien d’autre
Psalm est d’abord un type-checker, concurrent direct de PHPStan. En installer un second sur les mêmes fichiers aurait produit deux jeux d’erreurs de types, deux baselines à réconcilier et deux avis parfois contradictoires sur la même ligne. Nous n’en voulions pas.
La configuration le bride donc à son seul rôle :
<psalm
runTaintAnalysis="true"
errorLevel="8"
findUnusedCode="false"
findUnusedVariablesAndParams="false"
...
>
errorLevel="8" mérite une explication, parce qu’il se lit à l’envers de PHPStan. Chez Psalm, le niveau 1 est le plus
strict et le 8 le plus permissif, la valeur par défaut étant 2. Au niveau 8, les problèmes de types sont rétrogradés
en simples informations et ne bloquent rien. Les analyses de teinte, elles, sont émises quel que soit le niveau. Le
résultat est un Psalm qui se tait sur tout ce que PHPStan couvre déjà, et qui ne parle que de flux.
runTaintAnalysis="true" évite d’avoir à passer --taint-analysis à chaque commande, et les options findUnused*
désactivées sont renseigné pour éviter de rentrer en conflit avec phpstan.
Un fichier de stubs maison, parce qu’aucun plugin ne connaît l’ORM
Psalm ne sait rien de Symfony ni de Doctrine. Pour qu’un flux soit détecté, ses deux extrémités doivent être
déclarées. Côté sources, psalm/plugin-symfony fait le travail : ses stubs marquent Request, InputBag,
ParameterBag et HeaderBag comme entrées non fiables, et déclarent le contenu d’une Response comme sink HTML.
Côté sinks SQL, rien ne couvrait le chemin que nos projets empruntent réellement. Psalm connaît nativement mysqli
et PDO. Le plugin communautaire weirdan/doctrine-psalm-plugin ne marque que le DBAL bas niveau, et ses propres stubs
de types entrent en collision avec ceux du plugin Symfony. Or notre code passe par l’ORM : createQuery() et le
QueryBuilder.
Sans déclaration, l’analyse tourne, termine en vert, et ne détecte aucune injection passant par Doctrine. C’est le
scénario le plus trompeur : une vérification qui ne vérifie rien, tout en donnant l’impression du contraire. D’où le
fichier psalm-taint-stubs.php,
livré par le bundle, qui reproduit les signatures de Doctrine en y ajoutant l’annotation qui manque :
namespace Doctrine\ORM;
class QueryBuilder
{
/**
* @psalm-taint-sink sql $where
*/
public function andWhere(mixed ...$where): static
{
}
}
Le même traitement s’applique à createQuery(), aux autres méthodes du QueryBuilder et à executeQuery() et
executeStatement() côté DBAL. Psalm fusionne ces annotations avec les vraies classes du vendor. La contrepartie est
écrite en tête du fichier : si Doctrine change une signature, le stub doit suivre.
phpstan-disallowed-calls plutôt que phpstan-banned-code
Pour la famille « présence », deux extensions PHPStan figurent dans la bibliothèque officielle et semblent faire la même chose. Elles ne la font pas.
ekino/phpstan-banned-code bannit des fonctions globales et une poignée de constructions du langage : echo,
print, eval, exit, les backticks, plus l’import de classes de test dans le code applicatif. C’est tout. Pas de
message personnalisé par règle, pas d’exemption par chemin, pas de bannissement de méthodes, d’appels statiques, de
constantes ni d’attributs.
spaze/phpstan-disallowed-calls fait tout cela, et c’est ce que notre configuration utilise. Chaque fonction bannie
porte un message qui dit quoi faire à la place :
disallowedFunctionCalls:
-
function: 'exec()'
message: 'for security reasons. Use Symfony Process component instead.'
-
function: 'md5()'
message: 'md5 is cryptographically broken. Use hash() with a strong algorithm, or password_hash() for passwords.'
-
function: 'ini_set()'
message: 'configure PHP through php.ini / the environment, not at runtime.'
Douze fonctions sont bannies : exec, eval, system, passthru, shell_exec, phpinfo, popen, md5,
sha1, assert, error_reporting, ini_set. Le message n’est pas un confort. Un développeur qui tombe sur
l’erreur sait immédiatement pourquoi et par quoi remplacer, sans ouvrir de documentation ni demander à un collègue.
C’est ce qui fait la différence entre une règle respectée et une règle contournée.
C’est l’objection qu’on nous a faite en premier, et elle est légitime : Psalm a connu un passage à vide autour de
2023. Les faits au moment où nous écrivons : la version 6.17.0 est sortie le 10 septembre 2026, la branche 7.0 est en
bêta depuis fin 2025, et psalm/plugin-symfony a publié sa 5.3.0 en février 2026. La cadence est celle d’un projet
vivant. Nous la surveillons comme le reste de nos dépendances, et le jour où elle
faiblirait, la question de cet article se reposerait.
5. Ce que ça coûte à faire vivre
Un outil ne coûte pas ce qu’il coûte à installer. Nous l’avions écrit à propos de Nuclei, et l’argument vaut dans l’autre sens : ajouter Psalm a un prix, et il vaut mieux le connaître que le découvrir.
Deux baselines. PHPStan a la sienne, phpstan-baseline.neon. Psalm en a une seconde, psalm-taint-baseline.xml,
séparée parce que la documentation de Psalm le recommande : l’analyse de teinte tourne à part du reste. Sur un projet
repris, on la génère une fois (make psgb) pour figer les remontées historiques, et la CI ne bloque que sur les
nouvelles (make psalm-ci). Il faut la générer avant d’activer le job, sinon la première exécution échoue sur
l’ensemble du code.
Une annotation à connaître. Sur une remontée de teinte, @psalm-suppress ne fait pas ce qu’on attend : dans
notre expérience, les problèmes de teinte sont émis dans une passe différée et le commentaire de suppression classique
ne les atteint pas. Le bon outil est @psalm-taint-escape sql posé sur la valeur qu’on considère comme assainie. La
nuance a un mérite : elle oblige à dire pourquoi la donnée n’est plus dangereuse, au lieu de faire taire l’alerte.
Un stub à suivre. Le fichier Doctrine reproduit des signatures du vendor. Une montée de version de Doctrine qui modifie l’une d’elles demande une resynchronisation, sans quoi Psalm signale une incohérence de déclaration. C’est une ligne dans nos notes de migration Doctrine, pas une surprise.
Un job de plus dans la chaîne. make qa enchaîne désormais PHPStan, php-cs-fixer, Psalm en mode CI, les linters
PHP, Twig, YAML et conteneur, puis composer validate et composer audit. Le tout est livré par la recette du
standard-bundle : un projet qui monte en 1.5 récupère psalm.xml,
le stub et la baseline vide par un composer recipes:install --reset --force.

Le temps de calcul annoncé dans le screenshot précédant vient d’un projet legacy contenant environ 100 fichiers pour un total proche de 8000 lignes de code.
| Indicateur | Psalm (analyse de teinte) | phpcs-security-audit |
|---|---|---|
| Durée | 22 secondes | 2 secondes |
| Scope de l’analyse | Suit le flux de donnée réel entre une source et un sink | Recherche de motifs fichier par fichier, sans suivre la donnée (ce qui explique le temps d’éxecution plus rapide) |
| Faux positifs | Réduits, grâce à la compréhension des types entre appels de méthode | Plus élevés, faute de contexte sur les types manipulés |
| Gestion de baseline | psalm-taint-baseline.xml, correction progressive possible | Aucune, mention @phpcs-ignore dans le code à chaque occurence |
| Intégration Symfony | Native via psalm/plugin-symfony (Doctrine, Response) | Générique, aucune connaissance du framework |
| Coût de mise en place | Stub Doctrine à suivre, annotation @psalm-taint-escape à connaître | Aucun, un seul fichier de règles à activer |
| Maintenabilité du dépôt | Actif : 6.17.0 en septembre 2026, 7.0 en bêta | Abandonné depuis août 2019 |
Conclusion
Nous avons retiré un outil de sécurité qui ne bougeait plus depuis 2019, et nous n’avons pas cherché à le remplacer
par un seul autre. Nous avons regardé ce que chacun de ses 32 sniffs vérifiait, classé ces vérifications en trois
familles, et donné chaque famille à l’outil qui la fait le mieux : PHPStan pour la présence de fonctions dangereuses,
Psalm pour le flux de données, composer audit pour les dépendances.
La conséquence la moins intuitive est celle qui compte : nous avons deux analyseurs statiques, et aucun des deux ne fait le travail de l’autre. Psalm est bridé pour ne pas concurrencer PHPStan sur les types. PHPStan n’essaie pas de suivre une donnée car sa force se base sur l’analyse de type et ne permet pas “encore” de faire d’analyse de taint.
Points à retenir :
- Avant de retirer un outil, lister ce qu’il faisait réellement.
- Avant d’en ajouter un, nommer le manque précis qu’il comble et lui interdire le reste.
- Puis écrire l’arbitrage, avec la date, pour que la question puisse être reposée le jour où les faits changent.
C’est ce que nous faisons sur nos propres projets comme sur ceux que nous suivons en maintenance, et c’est l’un des piliers de notre démarche de qualité logicielle.
Liens utiles
- PHPStan : notre fiche technologie, configuration, niveaux et baseline
- Psalm : notre fiche technologie, sources, sinks et configuration en mode teinte
- L’ADR de la migration, dans le standard-bundle
- Psalm : documentation de l’analyse de sécurité
- Psalm : éviter les faux positifs de teinte
- Psalm : les niveaux d’erreur
- psalm/plugin-symfony
- spaze/phpstan-disallowed-calls
- Nuclei vs ZAP Proxy, le même raisonnement appliqué à un outil que nous n’avons pas retenu