Exécuter des scripts externes en toute sécurité en PHP
Utilisez Symfony Process avec un tableau d’arguments pour exécuter un script externe de confiance sans transformer ses arguments en commandes shell. Ce guide CLI crée un projet, capture le message de salutation d’un processus enfant et montre comment signaler un code de sortie en échec ou arrêter un processus enfant trop lent. Le processus enfant s’exécute toujours avec les permissions de votre compte : une gestion sûre des arguments ne constitue donc pas un bac à sable.
Prérequis
Les exemples ci-dessous ont été reproduits sous Linux avec PHP CLI 8.5.10, Composer 2.10.3, Bash 5.3.15 et Symfony Process 7.4.19. Utilisez ce profil pour reproduire le guide ; les autres versions de l’environnement d’exécution et le comportement sous Windows ne sont pas couverts ici. PHP 8.5 est une branche de PHP activement prise en charge.
Vous avez besoin de php et composer dans un PATH de confiance, d’un répertoire parent accessible en
écriture et de la fonction proc_open de PHP activée. Symfony Process utilise proc_open
pour démarrer le processus enfant. Composer a également besoin de ses extensions PHP habituelles et
d’un accès réseau pour télécharger la dépendance.
Le script lanceur et le script enfant utilisent l’option -n de PHP,
qui ignore php.ini. Cela rend la démonstration CLI indépendante de la
configuration PHP locale ; ce n’est pas une configuration recommandée pour une application existante.
Composer, en revanche, s’exécute avec sa configuration PHP habituelle.
Configurer l’environnement
Collez ce bloc dans Bash depuis le répertoire où vous souhaitez créer le projet :
(
if [ -n "${COMPOSER:-}" ] || [ -n "${COMPOSER_VENDOR_DIR:-}" ]; then
printf '%s\n' 'Use the default Composer manifest and vendor directory for this example.' >&2
exit 1
fi
mkdir -- php-external-scripts &&
cd -- php-external-scripts &&
export COMPOSER_HOME="$PWD/.composer-home" &&
composer --no-plugins --no-scripts init \
--name=example/php-external-scripts \
--description='PHP child process example' \
--require='symfony/process:7.4.19' \
--no-interaction &&
composer --no-plugins --no-scripts install --no-interaction
)
Les parenthèses confinent les changements de répertoire et d’environnement dans un sous-shell. Chaque
commande dépendante est enchaînée avec &&, de sorte qu’un échec de mkdir, de cd ou d’une commande
Composer interrompt la configuration sans exécuter les étapes suivantes dans le mauvais répertoire.
Votre shell reste dans le répertoire parent, en cas de succès comme d’échec. Si php-external-scripts existe
déjà, choisissez un autre répertoire parent ; ne supprimez pas un projet existant pour faire réussir
cette commande. Une installation échouée peut laisser derrière elle le répertoire nouvellement créé :
inspectez-le donc avant de décider de réessayer ailleurs.
Le répertoire Composer home local évite d’hériter des paramètres de projet ou des plugins globaux, et
la vérification rejette les surcharges du manifeste et du répertoire vendor. La commande install de Composer
crée composer.lock et vendor/ dans ce nouveau projet. La version exacte de Process rend cet
exemple reproductible ; examinez séparément les mises à jour de dépendances avant de l’utiliser dans
une application.
Écrire un script personnalisé à exécuter
Enregistrez ceci sous php-external-scripts/hello.php :
<?php
$name = $argv[1] ?? 'World';
$mode = $argv[2] ?? 'hello';
if ($mode === 'fail') {
fwrite(STDERR, "Example child failed.\n");
exit(23);
}
if ($mode === 'slow') {
sleep(5);
}
echo "Hello, {$name}!\n";
Le mode par défaut affiche un message de salutation. Les deux autres modes nous fournissent de vrais
échecs de sous-processus à vérifier : fail se termine avec le statut 23, tandis que slow attend
cinq secondes avant d’afficher quoi que ce soit.
Exécuter le script depuis PHP
Enregistrez ceci sous php-external-scripts/run-script.php :
<?php
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
require __DIR__ . '/vendor/autoload.php';
$nameArgument = $argv[1] ?? 'Developer';
$mode = $argv[2] ?? 'hello';
if (!in_array($mode, ['hello', 'fail', 'slow'], true)) {
fwrite(STDERR, "Usage: run-script.php [name] [hello|fail|slow]\n");
exit(64);
}
$process = new Process(
[PHP_BINARY, '-n', __DIR__ . '/hello.php', $nameArgument, $mode],
__DIR__
);
$process->setTimeout(1);
try {
$process->mustRun();
echo $process->getOutput();
} catch (ProcessTimedOutException $exception) {
fwrite(STDERR, "Child exceeded the 1-second timeout.\n");
exit(124);
} catch (ProcessFailedException $exception) {
fwrite(STDERR, sprintf("Child exited with status %d.\n", $process->getExitCode()));
exit(1);
}
La constante PHP_BINARY
sélectionne l’exécutable PHP qui exécute ce script CLI au lieu de rechercher un second php dans le PATH.
Le chemin absolu et le répertoire de travail du script enfant proviennent de __DIR__, et non d’une
entrée de l’appelant. L’autoloader local de Composer fournit les classes de Process.
mustRun() lève ProcessFailedException
lorsque le processus enfant renvoie un code de sortie non nul. Nous n’affichons la sortie standard
capturée qu’en cas de succès. Un dépassement du délai total lève une ProcessTimedOutException distincte. Une
seconde est un délai volontairement court pour cette démonstration ; choisissez donc une échéance
réaliste pour votre tâche réelle.
Toujours dans le répertoire parent, exécutez :
php -n php-external-scripts/run-script.php
Sortie standard attendue, avec le statut de sortie 0 :
Hello, Developer!
Pour comprendre l’importance des arguments séparés, passez un nom contenant de la syntaxe shell :
php -n php-external-scripts/run-script.php 'Developer; touch SHOULD_NOT_EXIST'
Sortie standard attendue :
Hello, Developer; touch SHOULD_NOT_EXIST!
Le point-virgule fait partie du nom ; ce n’est pas un séparateur de commandes. Cet appel ne crée pas
SHOULD_NOT_EXIST. Conservez cette distinction en adaptant l’exemple : ne concaténez pas un nom dans une
chaîne de commande et ne passez pas à Process::fromShellCommandline() pour transmettre des données.
Exemples pratiques
Vérifier l’échec d’un processus enfant
Exécutez les mêmes fichiers enregistrés avec le mode d’échec :
php -n php-external-scripts/run-script.php Developer fail
Le lanceur écrit ceci sur la sortie d’erreur standard et se termine avec le statut 1, sans afficher de message de salutation :
Child exited with status 23.
Le statut du processus enfant et celui du lanceur diffèrent délibérément. Ce wrapper CLI signale toute sortie non nulle du processus enfant par son propre statut 1 ; il ne prétend pas que la tâche a réussi et ne transmet pas la sortie d’erreur du processus enfant à son appelant.
Vérifier un dépassement de délai
php -n php-external-scripts/run-script.php Developer slow
Le lanceur écrit ceci sur la sortie d’erreur standard et se termine avec le statut 124 :
Child exceeded the 1-second timeout.
Symfony vérifie le délai d’exécution total pendant l’attente et arrête ce processus enfant avant la fin de sa pause de cinq secondes. Aucun message de salutation n’est affiché. Nous ne définissons pas de délai d’inactivité : un processus enfant silencieux n’est pas forcément défaillant.
Gestion des erreurs et bonnes pratiques
Considérations de sécurité
Un tableau d’arguments est la forme de commande recommandée par Symfony.
Sur ce chemin Linux, ses arguments sont transmis sans interpolation shell. Vous n’avez pas besoin de
escapeshellarg() autour de chaque élément du tableau.
Cela règle l’injection shell à cette frontière, mais pas tous les risques liés à l’exécution de commandes :
- Gardez l’exécutable et le script sous le contrôle de l’application. Cet exemple accepte un message de salutation et une courte liste de modes autorisés, et non une commande ou un chemin de script.
- Validez les arguments en fonction du programme enfant. Un autre outil pourrait interpréter un trait
d’union initial comme une option ou reconnaître une syntaxe de nom de fichier spéciale, même si le
shell ne la voit jamais. N’utilisez
--que lorsque cet outil le documente comme marqueur de fin des options. - Exécutez du code de confiance avec des permissions appropriées. Process n’isole ni l’accès au système de fichiers, ni le réseau, ni les informations d’identification. N’utilisez pas cet exemple pour exécuter du code téléversé ou tout autre code non fiable.
Gestion complète des erreurs
Les deux blocs catch distinguent un échec du processus enfant d’un dépassement de délai. Les modes
invalides sont rejetés avant le démarrage d’un processus enfant, avec un texte d’utilisation et le
statut 64. Des dépendances manquantes, un script absent ou un proc_open indisponible signalent un
problème de configuration, et non une opération réussie.
Pour une application web, renvoyez une erreur fixe et nettoyée plutôt que d’exposer des messages d’exception, des traces de pile ou la sortie capturée du processus enfant. Veillez à expurger tout diagnostic côté serveur. Ce tutoriel est un exemple CLI local et ne couvre pas le traitement des requêtes PHP-FPM.
Gestion des ressources
Le délai total limite la durée pendant laquelle le lanceur attend ce processus enfant simple ; ce n’est ni une limite de mémoire, ni une limite de taille de sortie, ni une garantie que des programmes arbitraires ne laissent aucun descendant ni fichier partiel. Le message de salutation ne produit qu’une faible quantité de sortie. Un programme à sortie volumineuse nécessite une conception distincte, fondée sur le streaming ou la limitation de la sortie.
Pour un travail durable en arrière-plan, utilisez une file d’attente de tâches ou un superviseur de services. Démarrer un processus de manière asynchrone n’équivaut pas à le faire survivre à son parent, comme l’explique la documentation de Process. La conversion d’images et les tâches planifiées nécessitent leurs propres politiques d’entrée, de sortie et de déploiement ; ce guide ne fournit pas ces flux de travail.
Conclusion
Avant de remplacer hello.php par une tâche réelle, déterminez quels arguments elle accepte, ce que
signifie son succès et ce qui doit se passer après un échec ou un dépassement de délai. Gardez ces
vérifications aux côtés du tableau d’arguments plutôt que de considérer un échappement sûr comme une
politique de sécurité complète.
