Blog

PHPStan et Psalm : pourquoi deux analyseurs statiques sur nos projets Symfony

PHPStan vérifie les types de notre code, Psalm suit les données jusqu'à une requête SQL. Pourquoi nous faisons tourner les deux analyseurs statiques sur nos projets Symfony, chacun sur un rôle précis.

Mathieu Ducrot Mathieu Ducrot
|
|
16 min de lecture
| Tech
Résumez cette page avec votre IA préférée :

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.

En résumé, pour extraire l'essentiel

  • 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 audit pour 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.

FamilleLa question poséeL’outil qui y répondPourquoi celui-là
PrésenceCette fonction est-elle appelée, quelle que soit la donnée ?PHPStan, extension phpstan-disallowed-callseval() ou phpinfo() sont dangereux sans qu’il y ait de flux à suivre
FluxUne entrée utilisateur atteint-elle un point d’exécution ?Psalm, analyse de teinte seuleLe seul moteur maintenu qui suit une donnée à travers le code
DépendancesUne bibliothèque installée porte-t-elle une CVE connue ?composer auditLit 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 sniffCe qu’il détectaitRepris parMécanisme
SQLFunctions, MysqliSQL construit avec une entrée utilisateurPsalmtaint sql, natif pour mysqli et PDO, stub maison pour Doctrine
SystemExecFunctionsexec, system, passthru avec une entrée utilisateurPsalm et PHPStantaint shell, plus bannissement de la fonction
Backticksl’opérateur `Psalmtaint shell : un opérateur ne se bannit pas par présence
EasyRFIinclude ou require avec une entrée utilisateurPsalmtaint include
EasyXSSecho ou print d’une entrée utilisateurPsalmtaint html, echo natif et Response via le plugin Symfony
CallbackFunctions, FunctionHandlingFunctionscall_user_func et consorts avec une entrée utilisateurPsalmtaint callable et eval
FilesystemFunctionsfopen, unlink avec une entrée utilisateurPsalmtaint file
FringeFunctionsextract, parse_str avec une entrée utilisateurPsalmtaint extract, couverture partielle
NoEvals, Phpinfos, Assertseval(), phpinfo(), assert()PHPStandisallowedFunctionCalls
CryptoFunctionschiffrement faiblePHPStanmd5 et sha1 bannis, mcrypt_* retiré de PHP 8
ErrorHandlingerror_reporting(), display_errors à l’exécutionPHPStanerror_reporting et ini_set bannis
PregReplacemodificateur /epersonneretiré depuis PHP 7
CVE/20132110, CVE/20134113deux CVE de PHP 5.3composer auditaudit de dépendances
BadCorsHeaderAccess-Control-Allow-Origin: *projetconfiguration 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éthodes where, andWhere, having, groupBy du QueryBuilder sont marquées comme sinks SQL, là où PHPCS ne connaissait que mysqli_* ;
  • des familles entières : SSRF via curl_*, injection LDAP via ldap_search, injection d’en-tête via header(), injection d’objet via unserialize, cookie via setcookie ;
  • 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.

Psalm est-il encore maintenu ?

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.

Rendu du retour de la commande vendor/bin/psalm

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.

IndicateurPsalm (analyse de teinte)phpcs-security-audit
Durée22 secondes2 secondes
Scope de l’analyseSuit le flux de donnée réel entre une source et un sinkRecherche de motifs fichier par fichier, sans suivre la donnée (ce qui explique le temps d’éxecution plus rapide)
Faux positifsRéduits, grâce à la compréhension des types entre appels de méthodePlus élevés, faute de contexte sur les types manipulés
Gestion de baselinepsalm-taint-baseline.xml, correction progressive possibleAucune, mention @phpcs-ignore dans le code à chaque occurence
Intégration SymfonyNative via psalm/plugin-symfony (Doctrine, Response)Générique, aucune connaissance du framework
Coût de mise en placeStub Doctrine à suivre, annotation @psalm-taint-escape à connaîtreAucun, un seul fichier de règles à activer
Maintenabilité du dépôtActif : 6.17.0 en septembre 2026, 7.0 en bêtaAbandonné 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

Mathieu Ducrot
Mathieu Ducrot CTO
Mots clés :
#Technique#Qualité

Articles similaires

Vous avez un projet ?

Contactez-nous pour savoir comment nous pouvons vous aider.