Initiation aux expressions régulières en PHP

Guillaume Rossolini

PHP dispose de divers moyens permettant de vérifier le contenu d'une variable. Ce tutoriel traite des expressions régulières, aussi appelées expressions rationnelles.

Commentez ♪

- Titre : Initiation aux expressions régulières en PHP

- Auteur : Guillaume Rossolini

- Parution : 28 mai 2006

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2006 Guillaume Rossolini. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- I-A. Remerciements

- I-B. Problématique

- II. Syntaxe

- II-A. La base : trouver une sous-chaîne dans une chaîne

- II-B. Les alternatives (barre verticale |)

- II-C. L'ancrage (accent circonflexe ^ et signe dollar $)

- II-D. Les classes

- II-E. Les quantifieurs

- II-F. Les parenthèses

- Les parenthèses capturantes

- Les parenthèses non capturantes

- II-G. Les modificateurs

- II-H. Les assertions

- Les assertions négatives avant

- Les assertions négatives arrière

- III. Fonctions

- III-A. preg_grep()

- III-B. preg_match_all()

- III-C. preg_match()

- III-D. preg_quote()

- III-E. preg_replace()

- III-F. preg_replace_callback()

- III-G. preg_split()

- IV. Sécurité

- IV-A. Protéger (escape)

- IV-B. Regex injection

- IV-C. Le modificateur D

- V. Conclusion

- V-A. Épilogue

- V-B. Liens

I - Introduction

I-A - Remerciements

Je tiens à remercier mathieu et titoumimi pour leurs conseils avisés lors de la rédaction de ce tutoriel.

I-B - Problématique

Les expressions régulières que nous allons utiliser (car il en existe plusieurs sortes) sont héritées du langage Perl. Elles sont appelées PCRE pour Perl-Compatible Regular. Expressions bien que, comme le souligne le manuel PHP, cette dénomination soit un abus de langage.
Nous n'utiliserons pas la syntaxe héritée de POSIX, bien qu'elle soit très similaire, car elle est moins intéressante. En effet, les PCRE reprennent la norme POSIX en l'agrémentant de nouvelles fonctionnalités que nous aborderons ici. De plus, le temps d'exécution des PCRE est souvent plus court que celui des expressions POSIX. Il n'y a vraiment que des avantages à passer de l'un à l'autre !

Les regex (pour simplifier, j'utiliserai ce terme par la suite) sont un moyen de parcourir avec précision le contenu d'une chaîne de caractères.

Les PCRE sont composées (le code couleur sera repris tout au long de l'article) :

- De délimiteurs : //, ##, [], <>, etc. ;

- Du masque à proprement parler, situé entre les délimiteurs ;

- De modificateurs, situés après le délimiteur de fin : U, s, i, m, etc.

Tout au long de l'article, je vous donnerai le code PHP permettant de reproduire les exemples.

II - Syntaxe

Nous allons commencer par des regex très simples afin de nous familiariser avec la bestiole. Pour cela, nous allons écrire des expressions qui permettront de faire la même chose que certaines fonctions déjà mises à disposition par PHP. Bien entendu, ces fonctions sont à utiliser de préférence : nous ne les remplaçons ici qu'à des fins didactiques.

II-A - La base : trouver une sous-chaîne dans une chaîne

En PHP, la fonction strpos() permet de savoir si une chaîne se trouve dans une autre chaîne :

Exemple d'utilisation de strpos()

<?php

$pattern  = "rossolini";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(strpos($subject, $pattern) !== FALSE)
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Tester ce script

N. B. En cas de succès, la fonction strpos() retourne la position de la sous-chaîne dans la chaîne, mais cela ne nous est pas nécessaire : nous cherchons simplement à déterminer son existence.

Du moment que nous ne souhaitons pas récupérer cette valeur, nous pouvons simuler strpos() au moyen d'une regex. Pour cela, nous utiliserons la fonction preg_match() :

Exemple d'utilisation de preg_match()

<?php

$pattern  = "/rossolini/";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/rossolini/ => La chaîne "rossolini" telle quelle.

Tester ce script

Évidemment, ce premier exemple n'est pas très intéressant. Il nous permet à peine d'introduire les délimiteurs.
Les délimiteurs sont, ici, les deux barres obliques encadrant la chaîne à chercher. En réalité, il est possible d'utiliser une grande variété de délimiteurs. J'aurais pu écrire #rossolini#, `rossolini`, [rossolini], <rossolini>, etc. Reportez-vous au manuel PHP pour en avoir la liste complète. Je ne recommande pas l'utilisation des parenthèses, crochets et accolades, car cela peut compliquer les choses dans l'expression (cf. plus loin).
L'une des différences majeures entre les expressions POSIX et les PCRE est l'existence de ces délimiteurs ; ils permettent de spécifier des modificateurs à la suite de l'expression, par exemple :

Exemple de modificateur

<?php

$pattern  = "/ROSSOLINI/i";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/ROSSOLINI/i => La chaîne "rossolini" quelle que soit la casse.

Tester ce script

Ici, j'ai ajouté le modificateur i permettant de rendre l'expression insensible à la casse, c'est-à-dire de chercher toutes les variantes majuscules/minuscules de "rossolini". Dans l'exemple ci-dessus, c'est équivalent à invoquer la fonction stripos().

II-B - Les alternatives (barre verticale |)

L'un des intérêts des regex est de permettre de donner des alternatives. Ainsi, nous pouvons savoir si une chaîne contient au moins l'une de plusieurs sous-chaînes, cela sans à avoir à appeler plusieurs fois la fonction à la suite :

Exemple d'alternatives avec strpos()

<?php

$pattern_1 = "rossolini";
$pattern_2 = "www";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern_1</strong> ou <strong>$pattern_2</strong> correspond-il à :<ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(strpos($subject, $pattern_1) !== FALSE
or strpos($subject, $pattern_2) !== FALSE)
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Tester ce script

Exemple d'alternatives avec une regex

<?php

$pattern  = "/rossolini|www/";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/rossolini|www/ => Soit la chaîne "rossolini", soit "www".

Tester ce script

Cela dit, s'il s'agit de trouver deux sous-chaînes dans une chaîne, alors il est préférable de lancer deux regex plutôt que d'essayer de le faire en une fois : cela pourrait s'avérer bien complexe, illisible et très long à exécuter.

II-C - L'ancrage (accent circonflexe ^ et signe dollar $)

Il est possible d'ancrer une regex, de manière à ne trouver de correspondances qu'au début de la chaîne, à la fin ou les deux (chaîne entière). Cela se fait au moyen des symboles ^ et $.

Exemples d'ancrages de regex

<?php

$patterns = array();
$patterns[0][0] = "#^http$#";
$patterns[0][1] = "#http$#";
$patterns[0][2] = "#^http#";
$patterns[1][0] = "#^.com/$#";
$patterns[1][1] = "#.com/$#";
$patterns[1][2] = "#^.com/#";
$patterns[2][0] = "#^Guillaume Rossolini$#";
$patterns[2][1] = "#Guillaume Rossolini$#";
$patterns[2][2] = "#^Guillaume Rossolini#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

foreach($patterns as $pattern)
{
echo "Où se trouve <strong>".trim($pattern[0], '#^$')."</strong> ?<br /><ul>";
foreach($subjects as $subject)
{
echo "<li>dans <strong>$subject</strong> ?<br />";
if(preg_match($pattern[0], $subject))
{
echo "C'est la chaîne complète";
}
elseif(preg_match($pattern[1], $subject))
{
echo "À la fin";
}
elseif(preg_match($pattern[2], $subject))
{
echo "Au début";
}
else
{
echo "Nulle part";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition de la regex : [?]

#^http$# => La chaîne "http".

#http$# => La chaîne "http" en fin de chaîne.

#^http# => La chaîne "http" en début de chaîne.

Tester ce script

J'ai pris soin d'utiliser un délimiteur différent de la barre oblique, dans la mesure où elle se trouve dans le masque ($pattern). Sans cela, il aurait fallu échapper la barre oblique, or je préfère simplifier la lecture de la regex.

Il y a bien sûr des équivalences tant que nous cherchons une sous-chaîne fixe :

Simulation d'ancrage de début ^

<?php

$pattern = "http";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> est-il au début :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li>dans <strong>$subject</strong> ?<br />";
if(strpos($subject, $pattern) === 0)
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Tester ce script

Simulation d'ancrage de fin $

<?php

$pattern  = ".com/";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> est-il à la fin :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li>dans <strong>$subject</strong> ?<br />";
if(strpos($subject, $pattern) === (strlen($subject) - strlen($pattern)))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Tester ce script

Simulation d'ancrage complet ^ et $

<?php

$pattern  = "Guillaume Rossolini";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> est-il équivalent à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if($subject == $pattern)
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Tester ce script

Des recommandations de sécurité sont recensées en fin d'article lorsque l'ancrage de fin de chaîne est utilisé.

II-D - Les classes

Les regex permettent une chose intéressante : savoir si une chaîne contient une liste de caractères (plutôt qu'une sous-chaîne précise).

Exemple de classe alphabétique

<?php

$pattern  = "/[a-z]/";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "<strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/[a-z]/ => Il suffit d'une lettre minuscule dans la chaîne.

Tester ce script

Dans cet exemple, la regex regarde s'il y a au moins une lettre dans la chaîne. Le trait d'union permet d'indiquer une série : "[a-z]" comprend l'alphabet entier, "[0-9]" tous les chiffres, "[4-7]" uniquement les chiffres de 4 à 7 inclus. Nous pourrions indiquer "[4567]" à la place de "[4-7]", mais bon… Idem pour les lettres, il est possible d'écrire tout l'alphabet dans les crochets. Le trait d'union permet de simplifier l'expression.

Précisons que ce qui se trouve à l'intérieur d'une classe (à l'exception des délimiteurs, de la barre oblique arrière \ et du trait d'union -) n'a pas besoin d'être échappé. Ici, par exemple, le point est pris en tant que tel et non comme joker :

Exemple de classe mixte

<?php

$pattern  = "#^[a-z:/.-]+$#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#^[a-z:/.-]+$# => Un ou plusieurs caractères parmi les lettres de l'alphabet, les deux points, la barre oblique, le point et le trait d'union.

Tester ce script

Enfin, une note sur le .* : bien que pratique, il est souvent peu judicieux de l'utiliser. Il est généralement préférable d'utiliser une classe dite négative :

Exemple de classe négative

<?php

$pattern  = "#^[^ ]+$#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#^[^ ]+$# => N'importe quel caractère, mais pas une seule espace dans la chaîne.

Tester ce script

Voici certaines des classes prédéfinies et leurs équivalents :

- Caractère décimal
[[:digit:]] ou [0-9] ou \d
Équivalent PHP pour is_int($subject) : preg_match('/^\d+$/', $subject)
Équivalent PHP approximatif pour is_numeric($subject) : preg_match('/^\d+(\.\d*)?$/', $subject)

- Caractère alphabétique
[[:alpha:]] ou [a-zA-Z]
Équivalent PHP approximatif pour ctype_alpha($subject) : preg_match('/^[a-z]+$/i', $subject)

- Caractère alphanumérique
[[:alnum:]] ou [a-zA-Z0-9]

- Caractère blanc (espace, tabulation, saut de ligne)
[[:space:]] ou [\040\r\t] ou \s
Équivalent C : isspace()

- Limite de mot
\b

Les classes du genre [[::]] sont héritées de la norme POSIX. Pour une liste plus complète, je vous laisse vous rendre vers l'article de mon collègue Hugo Étiévant.

II-E - Les quantifieurs

Il est possible de dire combien de fois le dernier caractère (ou la dernière classe) peut/doit être répété. C'est l'objectif des quantifieurs.

Liste des quantifieurs :

- "Zéro ou une fois" : point d'interrogation ?

- "Zéro, une ou plusieurs fois" : étoile *

- "Au moins une fois" : signe plus +

- "Deux fois" : {2}

- "De deux à quatre fois" : {2,4}

- "Au moins deux fois" : {2,}

À noter que :

- ? est équivalent à {0,1}

- * est équivalent à {0,}

- + est équivalent à {1,}

Les regex pouvant être très rapidement compliquées, il est préférable de les simplifier tant que possible. Conseil d'ami.
Il est évidemment possible de mettre n'importe quel nombre dans les accolades (pourvu que le premier nombre soit inférieur au second).

N'oublions pas le fameux joker : le point. Il permet de remplacer n'importe quel caractère.

Récupérer les domaines et sous domaines

<?php

$pattern  = "#http://[a-z.-]+\.[a-z]{2,4}/#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

# http://[a-z.-]+\.[a-z]{2,4}/ #

Détail :

- http://

- [a-z.-]+ : au moins un caractère parmi les lettres de l'alphabet, le point et le trait d'union

- \.[a-z]{2,4} : un point suivi de deux à quatre lettres de l'alphabet

Tester ce script

II-F - Les parenthèses

Les regex permettent une autre chose intéressante, à savoir de capturer des portions de la chaîne parcourue. Cela se fait au moyen des parenthèses.
Pour récupérer une capture, il faut spécifier le paramètre optionnel de preg_match(). Cette variable contiendra un tableau des résultats : l'index zéro contiendra la chaîne complète correspondant au masque puis chaque index suivant sera rempli par une capture dans l'ordre où elles sont définies.

Les parenthèses capturantes

Exemple de capture simple

<?php

$pattern  = "#http://([a-z-]+)\.developpez.com/#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$match = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject, $match))
{
echo "Oui :<br /><pre>";
print_r($match);
echo "</pre>";
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

# http://([a-z-]+)\.developpez.com/ #

Détail :

- http://

- ([a-z-]+) : un ou plusieurs caractères parmi les lettres et le trait d'union

- \.developpez.com/

Tester ce script

Exemple de capture multiple

<?php

$pattern  = "#http://([a-z-]+)\.([a-z]+)\.([a-z]+)/#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$match = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject, $match))
{
echo "Oui :<br /><pre>";
print_r($match);
echo "</pre>";
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

# http://([a-z-]+)\.([a-z]+)\.([a-z]+)/ #

Détail :

- http://

- \.([a-z]+) : un point suivi d'une ou plusieurs lettres.

- /

Tester ce script

Une version plus intéressante (car moins redondante) serait : [?]

# http://([a-z-]+)(?:\.([a-z]+)){2}/ #

Détail :

- http://

- (?:\.([a-z]+)){2} : un point suivi d'un ou plusieurs caractères parmi les lettres et le trait d'union. Ce bloc est répété une fois.

- /

Pour récupérer plusieurs fois un masque dans une même chaîne, nous pouvons utiliser la fonction preg_match_all() :

Exemple permettant de récupérer le sous-domaine, le domaine et l'extension racine d'une l'URL

<?php

$pattern  = "#[./]([a-z-]+)#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui :<br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#[./]([a-z-]+)# => Un point ou une barre oblique, puis au moins une lettre de l'alphabet.

Tester ce script

Une autre utilisation des captures est la référence arrière :

Exemple de remplacement

<?php

$pattern  = "#http://([a-z-]+)\.(developpez\.com)/#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";
$subjects[] = "Guillaume Rossolini";
$subjects[] = "0123456789";
$subjects[] = "01 23 45 67 89";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo preg_replace(
$pattern,
"Le sous-domaine est '\1', le domaine est '\2'",
$subject);
echo "<br />";
echo preg_replace(
$pattern,
"Le sous-domaine est '$1', le domaine est '$2'",
$subject);
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

# http://([a-z-]+)\.(developpez\.com)/ #

Détail :

- http://

- ([a-z-]+)\. : une ou plusieurs lettres de l'alphabet (ou traits d'union) suivies d'un point

- (developpez\.com)/ : la chaîne "developpez.com"

Tester ce script

Ici, j'ai utilisé les deux manières d'appeler une référence arrière : au moyen de la barre oblique inversée \ ou au moyen du signe dollar $, suivies du numéro de la référence (en commençant à 1, non à 0). Les deux méthodes sont équivalentes. Faites attention si vous utilisez plus de neuf références, car la convention de nommage peut poser problème. Reportez-vous à la documentation PHP pour toute information complémentaire.

Les parenthèses non capturantes

Il est possible d'utiliser des parenthèses sans pour autant définir une capture. Cela peut servir dans certaines situations, notamment lorsque nous voulons donner une alternative :

Parenthèses non capturantes

<?php

$pattern  = "/[a-z]+\s(?:[a-z]+['\s])?([a-z]+(?:es|ons|ez|ent|e))/i";

$subjects = array();
$subjects[] = "Je m'appelle, tu t'appelles, il s'appelle,"
." nous nous appelons, vous vous appelez, ils s'appellent";
$subjects[] = "Je programme, tu programmes, il programme,"
." nous programmons, vous programmez, ils programment";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui : <br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/ [a-z]+\s(?:[a-z]+['\s])?([a-z]+(?:es|ons|ez|ent|e)) / i

Détail :

- [a-z]+\s : une ou plusieurs lettres de l'alphabet suivies d'une espace

- (?:[a-z]+['\s])? : une ou plusieurs lettres de l'alphabet suivies soit d'une espace soit d'une apostrophe (ce bloc est facultatif)

- ([a-z]+(?:es|ons|ez|ent|e)) : tout mot terminant par "e", "es", "ons", "ez" ou "ent" (on capture le mot entier)

Tester ce script

Dans cet exemple, ce qui nous intéresse n'est pas uniquement la terminaison du verbe, mais le verbe entier. Il y a donc un couple de parenthèses capturantes autour du verbe et un couple de parenthèses non capturantes autour de la terminaison.

II-G - Les modificateurs

Les modificateurs sont un apport très consistant des PCRE depuis la norme POSIX. Ils permettent de modifier la manière dont le moteur de regex va parcourir la chaîne.

Voici mes trois modificateurs favoris.

- U
"Ungreedy", c'est-à-dire non gourmand. Cela signifie que l'expression trouvera des résultats aussi petits que possible.
Essayez l'expression ci-dessous avec et sans le modificateur U. Dans certains cas, la regex pourrait même retourner depuis le premier <b> jusqu'au dernier </b> (cf. exemple ci-dessous) !

- s
Permet de demander au point de contenir également les sauts de ligne. Par défaut, ce n'est pas le cas.

- i
Permet de ne pas tenir compte de la casse. Ainsi, les masques [a-z], [A-Z] et toutes leurs variantes sont équivalents. Il est inutile de préciser [a-zA-Z], ce qui peut être pratique dans de nombreux cas.

Essayez ces exemples avec et sans leur modificateur :

Exemple de modificateur 'U' (expression non gourmande)

<?php

$patterns = array();
$patterns[] = "#<strong>(.*)</strong>#";
$patterns[] = "#<strong>(.*)</strong>#U";

$subjects = array();
$subjects[] = "Je m'appelle <strong>Guillaume</strong>,"
." mon site est <strong>http://g-rossolini.developpez.com/</strong>.";
$subjects[] = "Il s'appelle <strong>Pierre-Baptiste</strong>,"
." son site est <strong>http://pbnaigeon.developpez.com/</strong>.";

foreach($patterns as $pattern)
{
echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui : <br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition de la regex : [?]

#<b>(.*)</b>#U => N'importe quelle suite de caractères encadrée d'une balise de mise en gras.

Tester ce script

Exemple de modificateur 's'

<?php

$patterns = array();
$patterns[] = "#<strong>(.*)</strong>#";
$patterns[] = "#<strong>(.*)</strong>#s";

$subjects = array();
$subjects[] = "Je m'appelle <strong>Guillaume</strong>,\n"
." mon site est <strong>http://g-rossolini.developpez.com/</strong>. (avec saut de ligne)";
$subjects[] = "Il s'appelle <strong>Pierre-Baptiste</strong>,"
." son site est <strong>http://pbnaigeon.developpez.com/</strong>. (sans saut de ligne)";

foreach($patterns as $pattern)
{
echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui : <br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition de la regex : [?]

#<i>(.*)</i>#s => N'importe quelle suite de caractères encadrée d'une balise de mise en italique.

Tester ce script

II-H - Les assertions

Les assertions permettent de déterminer la présence ou l'absence de caractères avant ou après la position du curseur dans la chaîne. Pour plus de détails sur ce curseur, veuillez vous reporter à la documentation officielle.
Il existe des assertions simples telles que \b, décrit succinctement plus haut, ainsi que des assertions plus complexes. Nous allons nous attarder sur ces dernières.
Je ne traiterai pas des assertions positives, car elles me semblent totalement inutiles : autant mettre la chaîne sans assertion, cela revient au même que faire une assertion positive. Pour les curieux, il s'agit de (?=) et (?<=). Je présenterai donc uniquement les assertions négatives : (?!) et (?<!).

N. B. Les assertions ont besoin de parenthèses, mais elles ne sont pas capturantes.

Les assertions négatives avant

Elles permettent de déterminer si une chaîne est suivie d'une autre chaîne (par son absence).

Le domaine ou sous domaine n'est pas...

<?php

$patterns = array();
$patterns[] = "#http://(?!www)#";
$patterns[] = "#http://(?!php)#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";

foreach($patterns as $pattern){
echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition des regex : [?]

#http://(?!www)# => La chaîne "http://" non suivie de "www" #http://(?!php)# => La chaîne "http://" non suivie de "php"

Tester ce script

Les assertions négatives arrière

Elles permettent de déterminer si une chaîne est précédée d'une autre chaîne (par son absence).

Le sous-domaine n'est pas...

<?php

$patterns = array();
$patterns[] = "#(?<!www)\.developpez#";
$patterns[] = "#(?<!php)\.developpez#";

$subjects = array();
$subjects[] = "ftp://ftp.developpez.com/g-rossolini/";
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "http://www.developpez.com/";
$subjects[] = "http://php.developpez.com/";

foreach($patterns as $pattern)
{
echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition des regex : [?]

#(?<!www)\.developpez# = La chaîne ".developpez" non précédée de "www" #(?<!php)\.developpez# = La chaîne ".developpez" non précédée de "php"

Tester ce script

III - Fonctions

III-A - preg_grep()

Permet d'appliquer une regex à chaque élément d'un tableau. Retourne un tableau des éléments pour lesquels la regex s'applique avec succès.

Trouver les noms propres dans un tableau

<?php

$pattern  = "/^[A-Z]/";

$subjects = array();
$subjects[] = array('Yogui', 'Guillaume', 'expression régulière');
$subjects[] = array('tutoriel', 'expressions', 'régulières');

echo "Le masque <strong>$pattern</strong> correspond-il à : <ul>";
foreach($subjects as $subject)
{
echo "<li><strong>";
print_r($subject);
echo "</strong><br />";
if(preg_grep($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/^[A-Z]/ => La chaîne commence par une majuscule de l'alphabet

Tester ce script

III-B - preg_match_all()

Permet de trouver toutes les occurrences qui satisfont un masque dans une chaîne. Retourne le nombre de succès.

Trouver les noms propres dans une chaîne

<?php

$pattern  = "/[A-Z][a-z]+/";

$subjects = array();
$subjects[] = "je suis Guillaume (alias Yogui)";
$subjects[] = "tutoriel sur les expressions régulières";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui :<br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/[A-Z][a-z]+/ => Une lettre majuscule de l'alphabet puis au moins une lettre minuscule.

Tester ce script

Trouver les liens dans une chaîne

<?php

$pattern  = "#<a href="([^"]+)">(.*)</a>#Usi";

$subjects = array();
$subjects[] = '<a href="http://www.developpez.com/">Accueil de Développez.com</a> - '."\n"
.'<a href="http://www.developpez.net/forums/">Forums de Développez.com</a> - ';
$subjects[] = '<a href="http://g-rossolini.developpez.com/">Mes articles</a> - '."\n"
.'<a href="http://php.developpez.com/cours/">Les articles PHP de Développez.com</a>';

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match_all($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui :<br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/#<a href="([^"]+)">(.*)</a>#Usi/ => Une lettre majuscule de l'alphabet puis tous les caractères jusqu'à la première espace.

Détail :

- <a href="([^"]+)"> : une chaîne quelconque encadrée de guillemets, le tout dans une balise d'ancrage HTML.

- (.*)</a> : une chaîne quelconque suivie de la balise fermante de l'ancrage.

Tester ce script

III-C - preg_match()

Similaire à preg_match_all(), mais s'arrête au premier résultat. Idéal pour déterminer l'existence d'une sous-chaîne satisfaisant un masque. Retourne un état du succès de l'opération (vrai/faux).

Déterminer si une adresse e-mail est syntaxiquement valide

<?php

// Regex fournie par Chris Shiflett
$pattern  = "/^([^@\s<&>]+)@(?:([-a-z0-9]+)\.)+([a-z]{2,})$/iD";

$subjects = array();
$subjects[] = "http://g-rossolini.developpez.com/";
$subjects[] = "truc@domaine.com";
$subjects[] = "bidule@autre-domaine.fr";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
$matches = array();
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject, $matches, PREG_SET_ORDER))
{
echo "Oui :<br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/ ^([^@\s<&>]+)@(?:([-a-z0-9]+)\.)+([a-z]{2,})$ / iD

Détail :

- ^([^@\s<&>]+)@ : au début de la chaîne, tous les caractères qui ne sont pas espace, arobase, <, & et > ; s'ensuit l'arobase

- (?:([-a-z0-9]+)\.)+ : les lettres, chiffres et traits d'union ; le tout est suivi d'un point

- ([a-z]{2,})$ : à partir de deux lettres

- iD : insensible à la casse, pas de caractère de fin de ligne en fin de chaîne

Tester ce script

III-D - preg_quote()

L'équivalent d'addslashes() pour les regex (mais en plus efficace). Utile pour construire le masque des fonctions PCRE. Retourne la chaîne échappée.

Déterminer si une chaîne est une question (présence d'une majuscule et d'un point d'interrogation)

<?php

$patterns = array();
$patterns[] = "/^[A-Z].*'.preg_quote('?').'$/";
$patterns[] = "/^[A-Z].*'.preg_quote('...').'$/";

$subjects = array();
$subjects[] = "Expression régulière ?";
$subjects[] = "J'aime bien les ...";

foreach($patterns as $pattern)
{
echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui";
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";
}

?>

Décomposition de la regex : [?]

/ ROSSOLINI / i

Détail :

- ^[A-Z] : une majuscule en début de chaîne

- .* : n'importe quels caractères (voire aucun)

- \?$ : un point d'interrogation ferme la chaîne

- \*$ : une étoile ferme la chaîne

Tester ce script

III-E - preg_replace()

Permet de remplacer au moyen d'une regex. Retourne la chaîne modifiée.

BBCode simple

<?php

$pattern  = "#\[b\](.*)\[/b\]#";

$subjects = array();
$subjects[] = "Initiation aux [b]expressions régulières[/b]";
$subjects[] = "Initiation aux expressions [b]rationnelles[/b]";
$subjects[] = "Initiation aux expressions <strong>rationnelles</strong>";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui : ".preg_replace($pattern, "<strong>$1</strong>", $subject);
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#\[b\](.*)\[/b\]# => Un bloc BBCode [b] et [/b] encadre n'importe quelle suite de caractères

Tester ce script

III-F - preg_replace_callback()

Idem que preg_replace(), mais permet d'utiliser une fonction utilisateur pour effectuer les remplacements complexes (plus d'une instruction). Retourne la chaîne modifiée.

BBCode complexe

<?php

function color_string($match)
{
// Ici, nous pourrions faire
// une connexion à une base de données
switch($match[1])
{
case "red":
$new_color = "#FF0000";
break;

case "green":
$new_color = "#00FF00";
break;

case "blue":
$new_color = "#0000FF";
break;

default:
$new_color = NULL;
break;
}

if($new_color)
{
return '<font color="'.$new_color.'">'.$match[2].'</font>';
}
else
{
return $match[2];
}
}

$pattern  = '#\[colou?r="([a-z]+)"\](.*)\[/colou?r\]#';

$subjects = array();
$subjects[] = 'expression [color="blue"]régulière[/color]';
$subjects[] = 'expression [colour="green"]régulière[/color]';
$subjects[] = 'expression [colour="red"]régulière[/colour]';
$subjects[] = 'expression [couleur="blue"]régulière[/couleur]';
$subjects[] = 'expression [color="green"]régulière[/col]';
$subjects[] = 'expression [font-color="red"]régulière[/font-color]';

echo "Le masque <strong>$pattern</strong> se trouve-t-il :<ul>";
foreach($subjects as $subject)
{
echo "<li>dans <strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui : ".preg_replace_callback(
$pattern, //'color' et 'colour' fonctionnent
"color_string",
$subject);
}
else
{
echo "Non";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

# \[colou?r="([a-z]+)"\](.*)\[/colou?r\] #

Détail :

- \[colou?r="([a-z]+)"\] : un bloc [color=""] ou [colour=""] contient un nom de couleur (à partir d'une lettre)

- (.*) : n'importe quels caractères (voire aucun) et on les capture

- \[/colou?r\] : un bloc [color] ou [colour]

Tester ce script

III-G - preg_split()

Alternative PCRE à split(). Idéal pour parcourir un fichier CSV dont on ne connaît pas à l'avance le séparateur de champs.

Découper une chaîne

<?php

$pattern  = "/[,&\s()]/";

$subjects = array();
$subjects[] = "je m'appelle Guillaume Rossolini (alias Yogui)";
$subjects[] = "ceci est un article sur les expressions régulières";

echo "Le masque <strong>$pattern</strong> correspond-il à :<ul>";
$foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if($matches = preg_split($pattern, $subject))
{
echo "Oui : <br /><pre>";
print_r($matches);
echo "</pre>";
}
else
{
echo "Non";
}
}
echo "</ul>";

?>

Décomposition de la regex : [?]

/[,&\s()]/ => Utiliser la virgule, l'esperluette, les caractères d'espacement et les parenthèses pour découper une chaîne.

Tester ce script

IV - Sécurité

IV-A - Protéger (escape)

Comme souvent, PHP propose une fonction permettant de protéger les chaînes contre les mauvaises utilisations. J'imagine que vous avez entendu parler de fonctions comme mysql_real_escape_string() pour envoyer des chaînes dans des requêtes SQL sans être victime d'injections SQL. De même, des fonctions comme escapeshellcmd() permettent de protéger d'autres aspects du système hôte lorsque vous utilisez des chaînes fournies par votre utilisateur.

Le rôle de preg_quote() est identique : protéger votre système lorsque vous utilisez dans votre regex une chaîne soumise par votre utilisateur. Vous devriez systématiquement l'utiliser lorsque vous utilisez une chaîne soumise par l'utilisateur dans votre "pattern" (le 1° paramètre de preg_match()).

Malheureusement, cela signifie que vous ne pouvez pas laisser le choix à votre utilisateur d'entrer des wildcards directement dans les patterns. Pour y parvenir, par exemple si vous souhaitez laisser à votre utilisateur la possibilité de rechercher tous les fichiers nommés d'une certaine manière, par exemple "img*.jpg", au moyen d'une expression rationnelle, vous devrez utiliser dans le pattern une chaîne soumise par formulaire. C'est là qu'il faut utiliser preg_quote() sous peine d'ouvrir une faille de sécurité. Utiliser preg_quote() sur la chaîne "img*.jpg" va la convertir en "img\*\.jpg", car l'astérisque et le point sont deux caractères spéciaux. La recherche de votre utilisateur ne donnera donc pas le résultat attendu, mais (et c'est ce qui nous intéresse) vous serez protégé contre les attaques.

Pour y remédier, vous devrez soit proposer une interface à votre utilisateur, soit trouver une alternative similaire au BBCode pour le HTML. Ce n'est pas l'objet de ce tutoriel, ainsi je vous laisse méditer sur la question.

La fonction preg_quote() doit être utilisée pour protéger le masque de n'importe quelle fonction preg_…(), dès lors que vous introduisez une chaîne soumise par votre utilisateur. Il n'est pas suffisant de protéger uniquement le masque de preg_match(), car les autres fonctions sont également vulnérables.

IV-B - Regex injection

Il existe une technique appelée "regex injection". Le principe est de tirer parti du modificateur "e" utilisé dans certaines regex afin d'introduire et d'exécuter du code PHP arbitraire, à la manière de l'injection SQL.

Reprenons l'exemple précédent de preg_replace() afin d'illustrer l'utilisation du modificateur 'e'

<?php

$pattern  = "#\[b\](.*)\[/b\]#e";

$replacement = "'<strong>'.strtoupper('$1').'</strong>'";

$subjects = array();
$subjects[] = "Initiation aux [b]expressions régulières[/b]";
$subjects[] = "Initiation aux expressions [b]rationnelles[/b]";
$subjects[] = "Initiation aux expressions <strong>rationnelles</strong>";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui : ".preg_replace($pattern, $replacement, $subject);
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#\[b\](.*)\[/b\]#e => N'importe quelle suite de caractères encadrée d'une balise BBCode de mise en gras.

Tester ce script

La regex apparaît avec le modificateur "e", utilisable uniquement avec la fonction preg_replace().
La fonction preg_replace() n'utilise plus seulement '$1', mais "'<b>'.strtoupper('$1').'</b>'" afin de mettre en majuscules la chaîne trouvée.
Cette regex fonctionne, mais il y a un inconvénient : elle peut être vulnérable à une injection de code. Dans le cas présent, puisque l'utilisateur ne peut rien introduire dans la chaîne, nous sommes en sécurité. Toutefois, il me semble périlleux d'utiliser une méthode qui, sous certaines conditions, permet à quelqu'un d'exécuter du code PHP de son choix.

En fait, la faille de sécurité (injection de regex) est utilisable uniquement si l'utilisateur a un contrôle sur la chaîne de remplacement. Il est difficile de trouver un exemple représentatif, dans la mesure où ces situations sont très rares.

Christian Wenz propose une démonstration dans son article Regular Expression Injection. Je me permets de reformuler son code afin de vous proposer une version qui me semble plus adaptée : utiliser la fonction md5() plutôt que strtolower(). C'est l'appel à ces fonctions qui rend le modificateur "e" obligatoire lorsque nous utilisons preg_replace().

users.xml : les mots de passe sont tous deux 'secret' en MD5

<?xml version="1.0"?>
<userdata>
<user>
<username>administrator</username>
<password>1e6947ac7fb3a9529a9726eb692c8cc5</password>
</user>
<user>
<username>John Doe</username>
<password>1e6947ac7fb3a9529a9726eb692c8cc5</password>
</user>
</userdata>

Démonstration de l'injection de regex

<?php

//Script original par Christian Wenz, XML original par Amit Klein

$pattern = "#(<username\s*>\s*$username\s*</username>\s*"
."<password\s*>)".md5($password)."(</password>)#e";

$replacement = "'\\1'.md5('$newpassword').'\\2'";

// Données en entrée
$username = (isset($_POST['username'])) ? $_POST['username'] : '';
$password = (isset($_POST['password'])) ? $_POST['password'] : '';
$newpassword = (isset($_POST['newpassword'])) ? $_POST['newpassword'] : '';

?>

<!-- Formulaire de saisie des modifications -->
<form method="post" action="<?php echo basename(__FILE__); ?>">
<label>
Nom d'utilisateur :
<input type="text" name="username" value="<?php echo htmlentities($username); ?>" />
</label><br />
<label>
Mot de passe actuel :
<input type="password" name="password" value="<?php echo htmlentities($password); ?>" />
</label><br />
<label>
Nouveau mot de passe :
<input type="password" name="newpassword" value="<?php echo htmlentities($newpassword); ?>" />
</label><br />
<input type="submit" value="Modifier le mot de passe" />
</form>

<?php

// Application des modifications
if(!empty($_POST))
{
// Récupération du XML
$users = file_get_contents('users.xml');

// Remplacement du mot de passe
$users = preg_replace(
$pattern,
$replacement,
$users, // subject
-1, // limit
$nb_of_changes // count
);

// Si le remplacement a fonctionné, on écrit le nouveau XML
if($nb_of_changes)
{
file_put_contents('users.xml', $users);
}
}

?>

Décomposition de la regex : [?]

#(>username\s*<\s*$username\s*>/username<\s*>password\s*<)".md5($password)."(>/password<)#e => Les balises XML "username" et "password" contenant les valeurs saisies par l'utilisateur et un nombre variable d'espaces.

La vulnérabilité est visible lorsque vous essayez ce genre de nouveau mot de passe :

- '.die('Arrêt du script').'

- '.system('reboot').'

- '.eval('echo "Informations de debug:<br />"; echo "<pre>"; print_r($_POST); echo "</pre>"; exit;').'

Il n'est pas possible de passer plus d'une commande PHP avec cette vulnérabilité. Cependant, s'il n'est pas désactivé sur le serveur, le construct PHP eval() permet d'exécuter toutes les commandes que nous voulons.

Une solution sûre consiste à ne pas utiliser le modificateur "e" et à déléguer les traitements à une autre fonction (plutôt que de tout faire dans preg_replace()), appelée "fonction de callback". C'est rendu possible par preg_replace_callback().

Alternative sécurisée au premier exemple

<?php

function str_enhance($match)
{
return "<strong>".strtoupper($match[1])."</strong>";
}

$pattern  = "#\[b\](.*)\[/b\]#";

$subjects = array();
$subjects[] = "Initiation aux [b]expressions régulières[/b]";
$subjects[] = "Initiation aux expressions [b]rationnelles[/b]";
$subjects[] = "Initiation aux expressions <strong>rationnelles</strong>";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
echo "<li><strong>$subject</strong> ?<br />";
if(preg_match($pattern, $subject))
{
echo "Oui : ".preg_replace_callback($pattern, "str_enhance", $subject);
}
else
{
echo "Non<br /><br />";
}
echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]

#\[b\](.*)\[/b\]# => N'importe quelle suite de caractères encadrée d'une balise BBCode de mise en gras.

Tester ce script

Alternative sécurisée au second exemple

<?php

// Script original par Christian Wenz, XML original par Amit Klein

function change_password($match)
{
global $newpassword;
return $match[1].md5($newpassword).$match[2];
}

$pattern = "#(<username\s*>\s*$username\s*</username>\s*"
."<password\s*>)".md5($password)."(</password>)#";

// Données en entrée
$username = (isset($_POST['username'])) ? $_POST['username'] : '';
$password = (isset($_POST['password'])) ? $_POST['password'] : '';
$newpassword = (isset($_POST['newpassword'])) ? $_POST['newpassword'] : '';

?>

<!-- Formulaire de saisie des modifications -->
<form method="post" action="<?php echo basename(__FILE__); ?>">
<label>
Nom d'utilisateur :
<input type="text" name="username" value="<?php echo htmlentities($username); ?>" />
</label><br />
<label>
Mot de passe actuel :
<input type="password" name="password" value="<?php echo htmlentities($password); ?>" />
</label><br />
<label>
Nouveau mot de passe :
<input type="password" name="newpassword" value="<?php echo htmlentities($newpassword); ?>" />
</label><br />
<input type="submit" value="Modifier le mot de passe" />
</form>

<?php

// Application des modifications
if(!empty($_POST))
{
// Récupération du XML
$users = file_get_contents('users.xml');

// Remplacement du mot de passe
$users = preg_replace_callback(
$pattern,
"change_password", // callback
$users, // subject
-1, // limit
$nb_of_changes // count
);

// Si le remplacement a fonctionné, on écrit le nouveau XML
if($nb_of_changes)
{
file_put_contents('users.xml', $users);
}
}

?>

Décomposition de la regex : [?]

#(>username\s*<\s*$username\s*>/username<\s*>password\s*<)".md5($password)."(>/password<)# => Les balises XML "username" et "password" contenant les valeurs saisies par l'utilisateur et un nombre variable d'espaces.

IV-C - Le modificateur D

Suite à un commentaire de Hardened-PHP dans leur "mois des bugs PHP", j'ajoute un commentaire sur le modificateur "D".

Le problème survient lorsque l'on valide une chaîne avec l'ancrage $ (fin de chaîne) sans utiliser le modificateur "D" (comme le font tous mes exemples ci-dessus). Dans cette situation, le signe dollar $ est soit la fin de la chaîne soit le caractère précédent si c'est un caractère de fin de ligne "

".

$strings = array();
$strings[] = "premier";
$strings[] = "second\n";

foreach($strings as $string)
{
if(preg_match('/^[a-z]+$/', $string))
{
echo '<span style="background: lightgreen;">'.nl2br($string)." est valide</span>";
}
else
{
echo '<span style="background: red;">'.nl2br($string)." n'est pas valide</span>";
}

echo '<br />';
}

En exécutant ce code, on voit que les deux chaînes passent la validation alors que l'on s'attend à n'avoir que des lettres grâce à notre masque [a-z]+. Cependant, puisque le modificateur "D" n'est pas donné, le caractère de fin de ligne "\n" est également autorisé s'il est seul et en fin de chaîne.

Cela peut causer des problèmes par exemple dans le cas de l'envoi d'un e-mail (possibilité d'injection de headers), d'emploi dans une requête SQL (possibilité d'injection SQL), dans un fichier log (possibilité d'enregistrement d'une fausse ligne), dans un appel à popen (possibilité d'exécution de deux commandes au lieu d'une seule), etc.

Le remède est simple : utiliser le modificateur "D".

$strings = array();
$strings[] = "premier";
$strings[] = "second\n";

foreach($strings as $string)
{
if(preg_match('/^[a-z]+$/D', $string))
{
echo '<span style="background: lightgreen;">'.nl2br($string)." est valide</span>";
}
else
{
echo '<span style="background: red;">'.nl2br($string)." n'est pas valide</span>";
}

echo '<br />';
}

Cette fois, la seconde chaîne est invalidée par l'expression rationnelle, tel que l'on peut s'y attendre.

V - Conclusion

V-A - Épilogue

Utilisées à bon escient, les expressions régulières permettent de faire beaucoup de choses.
Gardez à l'esprit qu'il est plus rapide d'exécuter une fonction native de PHP qu'une expression régulière, ainsi il ne faut pas en mettre à toutes les sauces.
Le moteur de PCRE optimise vos expressions avant de les exécuter, mais il est toujours préférable de penser à les optimiser. Réfléchissez bien à vos regex, ne partez pas dans des solutions trop alambiquées. Si l'expression vous semble complexe à relire, alors il est certainement temps de la scinder en plusieurs petites. Je n'ai pas traité de la possibilité de commenter une regex, mais sachez que c'est également possible.

Comparatif rapide

- L'équivalent de ctype_alpha() en regex met 150 % plus de temps à trouver le résultat que la fonction native de PHP.

- L'équivalent de is_numeric() en regex met 250 % plus de temps à trouver le résultat que la fonction native de PHP.

V-B - Liens

Liens de Developpez

- Les expressions régulières POSIX par Hugo Étiévant

- Post processing d'une page Web pour la réécriture de ses URL (une utilisation parmi tant d'autres pour les regex)

- Le script utilisé pour les tests tout au long de ce cours

- Outil de test d'expressions régulières

Liens externes

- Le manuel PHP sur les PCRE

- Le site officiel des PCRE

- Regular Expression Injection par Christian Wenz