Aller au contenu
grep et les expressions régulières

grep et les expressions régulières

100 Apprenti ⏱ 1 h 40 grepbashlinuxdebianubuntu

À la fin, vous saurez

  • Écrire une expression régulière BRE ou ERE qui sélectionne exactement les lignes voulues, ni plus ni moins, et la vérifier sur des cas limites
  • Choisir entre -E, -F et -P selon le motif, et expliquer pourquoi \d ne veut rien dire pour grep -E
  • Obtenir de grep ce dont on a besoin : des lignes, les seules correspondances (-o), un compte (-c), des noms de fichiers (-l, -L) ou un simple code de sortie (-q)
  • Interpréter les codes de sortie 0, 1 et 2 de grep et écrire un script sous set -euo pipefail qui ne meurt pas quand grep ne trouve rien
  • Diagnostiquer un « binary file matches », un motif développé par le shell ou un motif pris pour une option
  • Mesurer le coût d'une expression (références arrière, -i, -P) et protéger un script contre un motif fourni par un tiers

Prérequis

Testé avec coreutils 9.4 (Ubuntu 24.04), 9.7 (Debian 13) grep 3.11 (Ubuntu 24.04 et Debian 13) gzip 1.12 (Ubuntu 24.04), 1.13 (Debian 13) pcre2 10.42 (Ubuntu 24.04), 10.46 (Debian 13) , vérifié le 9 octobre 2026

Pourquoi

Jeudi 8 octobre, 9 h. Le service relation usagers de la mairie transmet trois messages reçus la veille : « l'application ne charge plus », « erreur en envoyant ma photo », « ça tourne dans le vide ». Tous datent du mercredi 7 en début d'après-midi. Personne, dans l'équipe, n'a reçu d'alerte.

Les réponses sont dans /srv/donnees/journaux/ sur sig-outils : les journaux de sig-app-1 et sig-app-2, 8 000 lignes chacun pour quatre jours, dont les trois quarts sont des vérifications de santé du répartiteur qui ne disent rien. Les lire dans less prendrait la matinée. Le premier réflexe, celui que la leçon 5 de Premiers pas a installé, est grep 503. Il répond 144 lignes sur sig-app-2. Il y en a en réalité 101 : les 43 autres sont des horodatages dont les microsecondes contiennent 503, comme 15:28:38.503086. Avec un motif un peu moins naïf, grep ' 500 ', on se croit protégé ; on rate les erreurs dont le code est en fin de ligne d'un autre format. Et le script de supervision qu'un collègue a écrit l'an dernier, qui compte les « Out of memory » sous set -euo pipefail, s'arrête net et en silence chaque nuit où il n'y en a aucun, ce qui est le cas normal.

grep est l'outil que tout le monde croit connaître. Il est simple dans son principe : garder les lignes qui correspondent à un motif. Toute la difficulté est dans le motif, une expression régulière dont la syntaxe change selon l'option choisie, et dans ce que l'on fait du résultat : des lignes, des morceaux de lignes, un compte, ou un simple oui ou non que lit un if. Cette leçon apprend à écrire des motifs qui sélectionnent exactement ce que l'on veut, à choisir la bonne variante de grep, et à l'utiliser dans un script sans qu'il se retourne contre vous.

Les concepts

Ce que fait grep

grep lit ses fichiers (ou son entrée standard) ligne par ligne. Pour chaque ligne, il se pose une seule question : le motif apparaît-il quelque part dans cette ligne ? Si oui, la ligne est sélectionnée. Par défaut, il affiche les lignes sélectionnées ; ses options changent ce qu'il fait de cette sélection (la compter, n'en afficher que la partie qui correspond, n'afficher que le nom du fichier, ne rien afficher du tout).

Deux conséquences, qu'il faut garder en tête toute la leçon :

  • Le motif cherche une correspondance n'importe où dans la ligne. grep 503 ne veut pas dire « le code vaut 503 », mais « la suite de caractères 5, 0, 3 apparaît quelque part ». Pour dire « au début », « en fin de ligne », « comme un mot entier », il faut l'écrire.
  • L'unité est la ligne. Un motif ne traverse pas un saut de ligne (sauf avec -z, vu plus bas). Pour un format où un enregistrement occupe plusieurs lignes, comme du JSON indenté, grep n'est pas le bon outil.

Le nom vient de l'éditeur ed d'Unix, où la commande g/re/p signifiait « globalement, pour chaque ligne qui correspond à l'expression régulière re, l'afficher » (print). Ken Thompson en a fait un programme à part en 1973.

Quatre langages de motifs

grep ne comprend pas une, mais quatre syntaxes, choisies par une option :

OptionSyntaxeNormalisée parUsage
(aucune) ou -Gexpressions régulières basiques (BRE)POSIXhistorique ; la plupart des métacaractères doivent être précédés d'une barre oblique inverse
-Eexpressions régulières étendues (ERE)POSIXla syntaxe à privilégier pour tout motif un peu riche
-Fchaînes fixes, aucun métacaractèrePOSIXchercher un texte littéral, en particulier un texte fourni par quelqu'un d'autre
-Pexpressions compatibles Perl (PCRE2)aucune (extension GNU)\d, regards en avant et en arrière, quantificateurs paresseux

egrep et fgrep sont d'anciens noms pour grep -E et grep -F. Ils ne figurent plus dans POSIX, et depuis grep 3.8 (2022) la version amont affiche un avertissement à chaque appel (« egrep: warning: egrep is obsolescent; using grep -E »). Debian, et Ubuntu qui reprend son paquet, ont ajouté un correctif qui supprime cet avertissement : sur sig-outils, egrep fonctionne en silence. Le même script lancé sur une Fedora, une Arch ou une image Alpine affichera l'avertissement sur sa sortie d'erreur. Écrivez grep -E : c'est la seule forme qui fonctionne partout sans bruit.

Les éléments d'une expression régulière

Une expression régulière se lit de gauche à droite comme une suite d'éléments, chacun correspondant à un caractère ou à une position. Voici l'essentiel, en syntaxe ERE ; le cours Expressions régulières, à venir, ira beaucoup plus loin.

Les caractères ordinaires se correspondent à eux-mêmes : sshd correspond à sshd.

Le point . correspond à n'importe quel caractère (un seul). /.env correspond donc à /.env, mais aussi à /xenv ou à /venv. Pour un point littéral : \. ou [.].

Les ancres ne correspondent à aucun caractère, mais à une position : ^ au début de la ligne, $ à la fin. ^2026-10-07 ne retient que les lignes qui commencent par cette date ; 503 -$ que celles qui se terminent par 503 -.

Les classes entre crochets correspondent à un caractère parmi un ensemble : [0-9] un chiffre, [aeiou] une voyelle, [^ ] (avec ^ en tête) tout caractère sauf l'espace. À l'intérieur des crochets, la plupart des métacaractères perdent leur sens : [.] est un point littéral. Trois cas particuliers : ] doit venir en premier pour être littéral ([]a]), - en premier ou en dernier ([a-]), ^ ailleurs qu'en premier.

Les classes POSIX se placent à l'intérieur de crochets : [[:digit:]], [[:alpha:]], [[:alnum:]], [[:space:]] (espace, tabulation, saut de ligne, retour chariot...), [[:upper:]], [[:lower:]], [[:punct:]]. Elles ont un avantage sur les intervalles : leur sens ne dépend pas de l'ordre de tri de la locale. [a-z] peut, selon la locale et l'implémentation, inclure ou non les lettres accentuées, voire des majuscules ; [[:lower:]] désigne toujours les minuscules de la locale. Les crochets doublés sont obligatoires : [:digit:] seul est une classe qui contient les caractères :, d, i, g, t. GNU grep détecte cette erreur courante et la refuse (« character class syntax is [[:space:]], not [:space:] »).

Les quantificateurs s'appliquent à l'élément qui les précède :

ERESens
*zéro fois ou plus
+une fois ou plus
?zéro ou une fois
{3}exactement trois fois
{1,3}de une à trois fois
{2,}au moins deux fois

[0-9]{3} correspond à trois chiffres ; python3\[[0-9]+\] à python3[ suivi d'au moins un chiffre puis de ].

L'alternance | sépare des branches : admin|root. Elle a la priorité la plus faible : ^Invalid user admin|root signifie « (^Invalid user admin) ou (root n'importe où) ». D'où les groupes entre parenthèses, qui délimitent une alternance ou portent un quantificateur : ^Invalid user (admin|root), ([0-9]{1,3}\.){3}.

Les références arrière \1 à \9 correspondent au même texte que celui capturé par le groupe numéro n : (..)\1 correspond à deux caractères répétés, comme abab. POSIX ne les définit que pour les BRE ; GNU grep les accepte aussi en ERE. Elles sont puissantes et coûteuses, on verra pourquoi.

BRE : les mêmes idées, d'autres barres obliques

Dans la syntaxe basique, celle de grep sans option, les caractères +, ?, |, (, ), { et } sont littéraux. Pour leur donner leur sens d'opérateur, il faut les précéder d'une barre oblique inverse : \(admin\|root\), [0-9]\{3\}, x\+. Seuls ., *, [ ], ^ et $ sont des métacaractères sans barre oblique.

IntentionBREERE
un ou plusieurs xxx* (POSIX) ou x\+ (GNU)x+
trois chiffres[0-9]\{3\}[0-9]{3}
admin ou rootadmin|root (GNU)admin|root sans barre : `admin
groupe répété\(ab\)*(ab)*
un + littéral+\+ ou [+]

\+, \? et \| en BRE sont des extensions GNU : POSIX ne leur donne pas de sens, et le grep de macOS ou de BusyBox peut les traiter autrement. C'est la raison principale de préférer -E dès qu'un motif contient autre chose que des points, des étoiles et des crochets : la syntaxe est la même partout, et elle se lit sans compter les barres obliques.

-F : quand le motif est un texte

Avec -F, le motif n'est pas une expression régulière : chaque caractère vaut pour lui-même, point, crochets et étoiles compris. C'est l'option à employer chaque fois que l'on cherche un texte connu (un chemin, une adresse IP, un identifiant de requête), et surtout un texte qui vient d'ailleurs : un argument de script, une ligne d'un fichier de motifs. Elle évite les surprises (/.env qui correspond à /venv) et elle est en général la plus rapide.

-P : la syntaxe de Perl, et ce qu'elle coûte

-P confie le motif à la bibliothèque PCRE2 (depuis grep 3.8 ; l'ancienne PCRE auparavant). On y trouve les raccourcis que beaucoup de développeurs connaissent par Python, JavaScript ou Perl : \d (un chiffre), \s (un blanc), \w (un caractère de mot), \b (une frontière de mot), les quantificateurs paresseux (.*?), les regards ((?<=from ), « précédé de from », sans l'inclure dans la correspondance), les groupes nommés. C'est commode, mais c'est une extension GNU, absente de nombreux grep (macOS, BusyBox), et son moteur fonctionne par retour arrière, ce qui a un coût, parfois catastrophique (partie Sécurité).

Un piège en découle : \d n'a aucun sens en BRE ni en ERE. GNU grep 3.11 le lit comme la lettre d. grep -E 'code \d+' ne trouve donc pas code 503, mais trouve code ddd. La version amont de grep, depuis la 3.8, avertit (« stray \ before d ») ; le correctif de Debian et d'Ubuntu rend cet avertissement muet par défaut (il ne s'affiche que si la variable DEB_GREP_ENABLE_STRAY_BACKSLASH_WARN est définie, d'après le bogue Debian n° 1019724). Sur sig-outils, l'erreur passe donc inaperçue : le motif ne trouve rien et grep le dit par son code de sortie, c'est tout. En ERE, écrivez [0-9] ou [[:digit:]].

Le plus long à gauche, ou le premier trouvé

Quand un motif peut correspondre à plusieurs morceaux d'une ligne, lequel est retenu ? La règle compte dès qu'on extrait les correspondances avec -o. POSIX impose la règle du plus long à gauche (leftmost-longest) : parmi les correspondances qui commencent le plus à gauche, la plus longue. PCRE applique la règle du premier trouvé : les branches d'une alternance sont essayées dans l'ordre, et la première qui réussit l'emporte. Sur GET /signalements/12345 et le motif signalement|signalements, grep -oE extrait signalements, grep -oP extrait signalement. Rien de grave tant qu'on le sait ; c'est une source classique d'écart quand on porte un motif d'un outil à l'autre.

Ce que l'on demande à grep

La même sélection de lignes peut produire des résultats très différents :

OptionRésultatExemple d'usage
(aucune)les lignes sélectionnéeslire les erreurs
-vles lignes non sélectionnéesretirer le bruit (/sante)
-cle nombre de lignes sélectionnées, par fichiercompter
-oseulement les parties qui correspondent, une par ligne de sortieextraire des IP, des codes
-l / -Lle nom des fichiers qui contiennent / ne contiennent pas une correspondancetrouver où chercher
-qrien ; seul le code de sortie parletester dans un if
-n, -bpréfixe du numéro de ligne, du décalage en octetsretrouver la ligne dans un éditeur
-H, -havec, sans nom de fichier en préfixe-h pour enchaîner sur sort
-A n, -B n, -C nn lignes de contexte après, avant, autourvoir ce qui précède une erreur
-m ns'arrêter après n lignes sélectionnées (par fichier)« la première occurrence »

Et ce qui modifie la façon de correspondre :

OptionEffet
-iignorer la casse
-wle motif doit former un mot entier : précédé et suivi d'un caractère qui n'est ni lettre, ni chiffre, ni _ (ou du début ou de la fin de la ligne)
-xle motif doit correspondre à toute la ligne
-e motifdonner un motif explicitement (répétable : plusieurs motifs, c'est un « ou »)
-f fichierlire les motifs dans un fichier, un par ligne
-zles lignes sont séparées par l'octet nul et non par le saut de ligne

Parmi toutes ces options, POSIX ne normalise que -E, -F, -c, -e, -f, -i, -l, -n, -q, -s, -v et -x. -o, -w, -r, le contexte, -m, -z sont des extensions, présentes dans GNU grep et dans le grep des BSD, mais pas forcément dans un grep minimal.

Les codes de sortie

Le manuel de GNU grep est explicite : le code de sortie vaut 0 si au moins une ligne a été sélectionnée, 1 si aucune ne l'a été, 2 en cas d'erreur (fichier absent ou illisible, motif invalide). Il ajoute une exception : avec -q, si une ligne est sélectionnée, le code vaut 0 même si une erreur s'est produite sur un autre fichier, puisque grep s'arrête dès la première correspondance.

Le point délicat est le 1. Pour grep, « rien trouvé » est une réponse, pas une erreur. Pour Bash sous set -e, tout code différent de zéro est un échec. La leçon 9 du cours Bash a montré set -euo pipefail ; grep est la commande avec laquelle il se heurte le plus souvent, et on le verra en pratique.

Récursif, fichiers, motifs multiples

-r parcourt un répertoire récursivement. Il ne suit pas les liens symboliques rencontrés pendant le parcours, seulement ceux donnés sur la ligne de commande ; -R les suit tous. --include='*.log' et --exclude='*.gz' filtrent les fichiers d'après leur nom (un motif du shell, pas une expression régulière), --exclude-dir= écarte des répertoires. Quand plusieurs fichiers sont examinés, grep préfixe chaque ligne du nom de son fichier ; -h supprime ce préfixe, -H l'impose même pour un seul fichier.

-f fichier lit un motif par ligne : la ligne est sélectionnée si l'un d'entre eux correspond. Combiné à -F, c'est la façon efficace de chercher des centaines de chaînes à la fois, une liste d'adresses bloquées ou de chemins suspects : GNU grep construit une seule structure de recherche pour toutes.

En pratique

Les commandes se lancent depuis le bac à sable de la leçon 1, ~/essais-texte, avec GNU grep 3.11 et LC_ALL=C.UTF-8. Pour alléger, on nomme les deux journaux :

$ cd ~/essais-texte
$ j1=journaux/sig-app-1.pn-signalements.internal/syslog.log
$ j2=journaux/sig-app-2.pn-signalements.internal/syslog.log

Le 503 qui en cache d'autres

$ grep -c 503 "$j2"
144
$ grep 503 "$j2" | grep -v ' 503 ' | head -n 2
2026-10-05T09:44:07.250349+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 09:44:07] "GET /sante HTTP/1.1" 200 -
2026-10-05T10:12:49.750384+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 10:12:49] "GET /signalements/12375 HTTP/1.1" 200 -

Le second tube garde les lignes qui contiennent 503 sans espaces autour. Sur ces deux exemples, on cherche en vain un code 503 : il n'y en a pas. On ne devine pas où un motif court correspond, on le demande à grep, avec -o, en élargissant le motif à tout le mot qui l'entoure :

$ grep 503 "$j2" | grep -v ' 503 ' | grep -o '[^ ]*503[^ ]*' | head -n 4
2026-10-05T09:44:07.250349+00:00
2026-10-05T10:12:49.750384+00:00
2026-10-05T14:17:25.150313+00:00
2026-10-05T15:28:38.503086+00:00

[^ ]* correspond à toute suite de caractères autres que l'espace : la correspondance s'étend donc jusqu'aux espaces de part et d'autre, et l'on voit le « mot » entier. Les microsecondes de l'horodatage (.250349, .750384) contiennent 503. Sur les 43 lignes en trop, 35 viennent ainsi de l'horodatage ; les autres, d'identifiants de signalements (/signalements/11503), de noms de pièces jointes hexadécimaux (808c918ebaa4e503.jpg) et de numéros de processus sshd. Un motif de trois chiffres, sans contexte, correspond à n'importe quelle suite de trois chiffres.

Trois façons d'être précis, de la plus fragile à la plus juste :

$ grep -c ' 503 ' "$j2"
101
$ grep -cw 503 "$j2"
101
$ grep -c '" 503 -$' "$j2"
101
  • ' 503 ' exige une espace de chaque côté. Correct ici, mais une durée 0.503 s ou un champ taille 503 octets seraient aussi comptés.
  • -w exige un mot entier : ni chiffre, ni lettre, ni _ de part et d'autre. Les microsecondes et /signalements/11503 ne passent plus. Mais une requête vers /signalements/503 passerait.
  • '" 503 -$' décrit la structure du format : le code suit le guillemet fermant de la requête, et la ligne finit par -. C'est le plus précis, parce qu'il ancre le code à sa place. C'est la démarche à retenir : un bon motif décrit le contexte, pas seulement la valeur.

Tous les codes, d'un coup d'œil

Avec -o, grep n'affiche que la partie qui correspond. On peut donc extraire le code de chaque ligne de l'API et les compter avec les outils de la leçon 1 :

$ grep -hoE '" [0-9]{3} -$' "$j1" "$j2" | sort | uniq -c | sort -rn
  15015 " 200 -
    351 " 201 -
    207 " 404 -
    193 " 304 -
    101 " 503 -
     13 " 500 -
  • -h supprime le nom du fichier, qui gênerait le comptage.
  • [0-9]{3} : trois chiffres, en ERE (-E).
  • Les 503 sont tous sur sig-app-2, les 500 répartis sur les deux hôtes. Vérifions :
$ grep -cE '" 5[0-9]{2} -$' "$j1" "$j2"
journaux/sig-app-1.pn-signalements.internal/syslog.log:7
journaux/sig-app-2.pn-signalements.internal/syslog.log:107

Avec plusieurs fichiers, -c donne un compte par fichier. 7 erreurs 500 sur sig-app-1, 6 sur sig-app-2 plus ses 101 erreurs 503 : l'incident concerne un seul serveur.

Isoler la fenêtre de l'incident

Les messages des usagers parlent de « début d'après-midi ». L'horodatage RFC 3339 en tête de ligne se prête bien aux ancres. Toutes les lignes entre 14 h 02 et 14 h 19 :

$ grep -cE '^2026-10-07T14:(0[2-9]|1[0-9])' "$j2"
150
$ grep -E '^2026-10-07T14:(0[2-9]|1[0-9])' "$j2" | grep -c '" 503 -$'
101

(0[2-9]|1[0-9]) décrit les minutes 02 à 19. Les expressions régulières ne connaissent pas les nombres, seulement des caractères : un intervalle numérique s'écrit chiffre par chiffre, en découpant aux dizaines. Les 101 erreurs 503 tombent toutes dans cette fenêtre.

Leur répartition minute par minute, en extrayant le début de l'horodatage :

$ grep '" 503 -$' "$j2" | grep -oE '^2026-10-07T14:[0-9]{2}' | uniq -c
      5 2026-10-07T14:02
     10 2026-10-07T14:03
      3 2026-10-07T14:04
      3 2026-10-07T14:05
      6 2026-10-07T14:06
      7 2026-10-07T14:07
      6 2026-10-07T14:08
      6 2026-10-07T14:09
      5 2026-10-07T14:10
      9 2026-10-07T14:11
      4 2026-10-07T14:12
      7 2026-10-07T14:13
      5 2026-10-07T14:14
      6 2026-10-07T14:15
      1 2026-10-07T14:16
      8 2026-10-07T14:17
      7 2026-10-07T14:18
      3 2026-10-07T14:19

Pas besoin de sort avant uniq -c : le journal est déjà dans l'ordre chronologique, donc les lignes d'une même minute sont consécutives. Le profil est plat, une erreur toutes les dix secondes environ pendant dix-sept minutes : ce n'est pas un pic de charge passager, c'est une panne franche.

Ce qui s'est passé juste après

-B et -A affichent du contexte autour de chaque ligne sélectionnée. Que montre le journal autour de l'arrêt du service ?

$ grep -n -B2 -A3 'Stopping signalements' "$j2"
5345-2026-10-07T14:19:07.585664+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:19:07] "GET /sante HTTP/1.1" 200 -
5346-2026-10-07T14:19:18.750472+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:19:18] "GET /signalements HTTP/1.1" 200 -
5347:2026-10-07T14:20:00.000000+00:00 sig-app-2 systemd[1]: Stopping signalements.service - API Signalements...
5348-2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: signalements.service: Deactivated successfully.
5349-2026-10-07T14:20:01.000000+00:00 sig-app-2 systemd[1]: Stopped signalements.service - API Signalements.
5350-2026-10-07T14:20:02.000000+00:00 sig-app-2 systemd[1]: Started signalements.service - API Signalements.

Avec -n, grep sépare le numéro de ligne par : pour les lignes sélectionnées et par - pour les lignes de contexte : on repère d'un coup d'œil laquelle a déclenché l'affichage. Quand plusieurs blocs de contexte sont affichés, grep les sépare par une ligne --.

Deux observations, que l'enquête retiendra. D'abord, la ligne 5345 : à 14:19:07, en pleine panne, /sante répondait 200. La vérification de santé du répartiteur ne consulte pas la base ; elle a déclaré sig-app-2 en bonne santé pendant toute la panne, et le répartiteur a continué à lui envoyer un usager sur deux. Ensuite, quelqu'un (ou quelque chose) a redémarré le service à 14:20. Le redémarrage se vérifie au numéro de processus de l'API :

$ grep -oE 'python3\[[0-9]+\]' "$j2" | sort | uniq -c
   2772 python3[2817]
   5228 python3[790]

Le PID passe de 790 à 2817. \[ et \] désignent des crochets littéraux : sans la barre oblique, [0-9]+ serait bien une classe, mais python3[ ouvrirait une classe sans fin.

Le journal JSON de l'API donne la cause. On ne l'analysera vraiment qu'à la leçon 7, avec jq ; grep suffit à y lire une ligne :

$ grep -m1 'pool de connexions' journaux/sig-app-2.pn-signalements.internal/api.jsonl
{"horodatage":"2026-10-07T14:02:06.000Z","niveau":"warning","hote":"sig-app-2","message":"pool de connexions saturé","pool":{"taille":10,"en_attente":37}}

-m1 arrête la lecture à la première ligne sélectionnée : sur un gros fichier, c'est aussi un gain de temps.

Le JSON n'est pas du texte comme les autres

Tentation : compter les 503 dans le journal JSON avec grep.

$ grep -c '"statut":503' journaux/sig-app-2.pn-signalements.internal/api.jsonl
101
$ grep -c '"statut": 503' journaux/sig-app-2.pn-signalements.internal/api.jsonl
0

Le résultat est juste, mais par chance : le journal est écrit sans espace après les deux-points. Une mise à jour de la bibliothèque de journalisation qui ajouterait une espace, ou réordonnerait les clés, ou un statut imbriqué ailleurs dans l'objet, et le motif ne trouve plus rien, sans erreur. Le JSON se décrit par sa structure, pas par sa mise en forme : grep convient pour repérer des lignes (un identifiant de requête, un message), pas pour interroger des champs. Ce sera le rôle de jq.

Les tentatives SSH

En parcourant les lignes qui ne viennent pas de l'API, on tombe sur sshd. Les deux serveurs ont leur port 22 ouvert sur internet, ce qui n'était pas prévu.

$ grep -c 'Invalid user' "$j1" "$j2"
journaux/sig-app-1.pn-signalements.internal/syslog.log:98
journaux/sig-app-2.pn-signalements.internal/syslog.log:23
$ grep -m2 -n 'Invalid user' "$j2"
127:2026-10-05T01:51:27.186473+00:00 sig-app-2 sshd[24240]: Invalid user test from 203.0.113.150 port 45308
459:2026-10-05T06:44:05.401776+00:00 sig-app-2 sshd[21893]: Invalid user ubuntu from 203.0.113.150 port 49469

D'où viennent-elles ? On extrait l'adresse qui suit from :

$ grep -h 'Invalid user' "$j1" "$j2" | grep -oE 'from [0-9.]+' | sort | uniq -c | sort -rn
     51 from 203.0.113.77
     38 from 203.0.113.150
     32 from 198.51.100.23

Le mot from sert de contexte : il garantit que l'on extrait l'adresse source, et pas un numéro de port ou un autre nombre pointé. Pour se débarrasser du préfixe from , deux solutions : un second filtre (cut -d' ' -f2), ou un regard en arrière en syntaxe Perl, qui exige le contexte sans l'inclure dans la correspondance :

$ grep -h 'Invalid user' "$j1" "$j2" | grep -oP '(?<=from )[0-9.]+' | sort -u
198.51.100.23
203.0.113.150
203.0.113.77

Pratique pour un essai au terminal ; dans un script destiné à durer, on préférera un second filtre portable, ou awk (leçon 5), qui sait extraire un champ proprement.

Et si l'on cherche toutes les adresses IPv4 du journal, sans contexte ?

$ grep -hoE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$j1" | sort | uniq -c | sort -rn | head -n 5
   7880 172.16.8.20
     95 203.0.113.77
     69 203.0.113.150
     54 198.51.100.23
      7 192.0.2.5

([0-9]{1,3}\.){3} : trois fois « un à trois chiffres suivis d'un point », puis un dernier groupe de chiffres. 172.16.8.20 est le répartiteur, présent sur chaque ligne de l'API. 203.0.113.77 apparaît 95 fois et non 51 : il figure aussi sur les lignes Connection closed by ... qui suivent chaque tentative, et sur celles des tentatives contre root. Notez que ce motif accepterait 999.1.1.1 : il décrit la forme d'une adresse, pas sa validité. L'exercice 5 y revient.

Compter des lignes ou des correspondances

$ grep -c 'SRC=' "$j1"
24
$ grep -oE '(SRC|DST)=[0-9.]+' "$j1" | wc -l
48

Les 24 lignes [UFW BLOCK] du pare-feu contiennent chacune deux adresses, source et destination. -c compte des lignes sélectionnées, jamais des occurrences ; pour compter des occurrences, -o puis wc -l. Cette distinction explique bien des chiffres qui ne collent pas d'un outil à l'autre.

Retirer le bruit avec -v

Les trois quarts du journal sont des vérifications de santé :

$ wc -l < "$j2"
8183
$ grep -c '"GET /sante ' "$j2"
5760
$ grep -vc '"GET /sante ' "$j2"
2423

-v inverse la sélection, -c compte les lignes non sélectionnées. On ne garde que 2 423 lignes à lire. Le guillemet devant GET et l'espace après /sante évitent d'écarter par erreur un hypothétique /sante-detaillee ou une requête dont le paramètre contiendrait /sante.

Un fichier de motifs, et pourquoi -F

L'équipe tient une liste des chemins que visent les sondes de vulnérabilités, ces requêtes automatiques de robots qui cherchent des failles connues. Un chemin par ligne, dans lib/sondes.motifs :

$ cat lib/sondes.motifs
/wp-login.php
/.env
/.git/config
/admin/config.php
/phpmyadmin/
$ grep -c -F -f lib/sondes.motifs "$j1" "$j2"
journaux/sig-app-1.pn-signalements.internal/syslog.log:48
journaux/sig-app-2.pn-signalements.internal/syslog.log:48
$ grep -h -o -F -f lib/sondes.motifs "$j1" "$j2" | sort | uniq -c | sort -rn
     23 /admin/config.php
     21 /wp-login.php
     20 /phpmyadmin/
     19 /.git/config
     13 /.env

Pourquoi -F ? Sans lui, chaque ligne du fichier est une expression régulière : le point de /.env correspond à n'importe quel caractère, celui de config.php aussi. Sur nos données, le compte serait le même ; mais un jour, une requête légitime vers /xenv ou /admin/configXphp serait comptée comme une sonde. Avec -F, les motifs sont des textes, sans interprétation. Règle simple : un motif qui n'a pas été écrit comme une expression régulière doit être cherché avec -F.

Notez que ces 96 sondes sont toutes en 404 : l'API ne sert ni WordPress ni phpMyAdmin. C'est du bruit de fond d'internet, pas une attaque ciblée ; mais un pic soudain, ou une sonde en 200, mériterait une alerte.

Chercher dans une arborescence

Quels fichiers contiennent l'erreur de base de données ? Et lesquels n'en contiennent pas ?

$ cd journaux
$ grep -rl --include='*.jsonl' 'OperationalError' .
./sig-app-2.pn-signalements.internal/api.jsonl
$ grep -rL --include='*.jsonl' 'OperationalError' .
./sig-app-1.pn-signalements.internal/api.jsonl
$ grep -rc --exclude-dir=sig-app-1.pn-signalements.internal 'Started signalements' .
./sig-app-2.pn-signalements.internal/syslog.log:1
./sig-app-2.pn-signalements.internal/api.jsonl:0
$ cd ..
  • -l liste les fichiers qui ont au moins une correspondance, et s'arrête de lire chacun dès la première : c'est rapide même sur de gros fichiers. -L liste ceux qui n'en ont aucune.
  • --include restreint la recherche aux fichiers dont le nom correspond au motif ; les guillemets empêchent le shell de développer *.jsonl lui-même.
  • L'ordre des fichiers suit celui dans lequel le système de fichiers les rend, pas l'ordre alphabétique.

Un mot sur les noms de fichiers : avec -l, grep écrit un nom par ligne, ambigu si un nom contient un saut de ligne (leçon 5 du cours Bash). grep -lZ termine chaque nom par un octet nul, à lire avec xargs -0 ou read -d ''. À l'inverse, -z fait lire à grep des entrées séparées par l'octet nul, ce qui permet de filtrer la sortie de find -print0 sans jamais la découper sur des sauts de ligne :

$ find journaux -name '*.jsonl' -print0 | grep -z 'sig-app-2' | tr '\0' '\n'
journaux/sig-app-2.pn-signalements.internal/api.jsonl

Les journaux tournés et compressés

Sur sig-outils, logrotate compresse les journaux de la veille (syslog.log.2.gz). zgrep décompresse à la volée et passe ses options à grep :

$ mkdir -p archives
$ cp "$j2" archives/syslog.log.2 && gzip archives/syslog.log.2
$ zgrep -c '" 503 -$' archives/syslog.log.2.gz
101

zgrep est un script fourni par gzip ; il accepte la plupart des options de grep, mais pas toutes : zgrep -r répond -r: option not supported. Pour d'autres compressions : xzgrep, zstdgrep, ou plus simplement zstdcat fichier.zst | grep ....

Les codes de sortie, et le script qui meurt en silence

$ grep -q 'too many clients' journaux/sig-app-2.pn-signalements.internal/api.jsonl; echo "code $?"
code 0
$ grep -q 'too many clients' journaux/sig-app-1.pn-signalements.internal/api.jsonl; echo "code $?"
code 1
$ grep -q x absent.log; echo "code $?"
grep: absent.log: No such file or directory
code 2
$ grep -qs x absent.log; echo "code $?"
code 2
$ grep -q x absent.log "$j2"; echo "code $?"
grep: absent.log: No such file or directory
code 0
  • -q ne produit aucune sortie : c'est la forme à employer dans un if, où seul le code compte (leçon 4 du cours Bash).
  • -s supprime les messages d'erreur sur les fichiers absents ou illisibles, mais pas le code 2. Il cache le symptôme, pas l'échec.
  • Le dernier cas illustre l'exception du manuel : un fichier manquait, mais une ligne a été trouvée dans l'autre, et -q renvoie 0. Un if grep -q ne distingue donc pas « trouvé, avec des erreurs » de « trouvé ».

Voici maintenant le script de supervision du collègue, compter-oom :

#!/usr/bin/env bash
set -euo pipefail
journal=journaux/sig-app-2.pn-signalements.internal/syslog.log
n=$(grep -c 'Out of memory' "$journal")
echo "événements OOM : $n"
$ bash compter-oom; echo "code $?"
code 1

Aucune sortie, code 1. grep -c a bien écrit 0 sur sa sortie, que la substitution a capturé ; mais il a renvoyé 1, puisqu'aucune ligne n'était sélectionnée. Sous set -e, une affectation dont la substitution de commande échoue arrête le script. La ligne echo n'est jamais atteinte, et la supervision, au lieu de dire « 0 », ne dit rien. C'est le cas normal qui fait échouer le script.

Il faut dire à Bash que 1 est une réponse acceptable, sans accepter le 2 :

n=$(grep -c 'Out of memory' "$journal") || (( $? == 1 ))

Si grep renvoie 0, la partie droite n'est pas évaluée. S'il renvoie 1, (( $? == 1 )) est vrai et la ligne réussit, n contient 0. S'il renvoie 2, la comparaison échoue, et set -e arrête le script comme il se doit. Vérifions les deux cas dans compter-oom-corrige, avec un second appel sur un fichier absent :

#!/usr/bin/env bash
set -euo pipefail
journal=journaux/sig-app-2.pn-signalements.internal/syslog.log
n=$(grep -c 'Out of memory' "$journal") || (( $? == 1 ))
echo "événements OOM : $n"
n=$(grep -c 'Out of memory' absent.log) || (( $? == 1 ))
echo "jamais affiché"
$ bash compter-oom-corrige; echo "code $?"
événements OOM : 0
grep: absent.log: No such file or directory
code 1

La forme répandue grep ... || true accepte aussi le code 2 : un fichier de journal disparu, un motif invalide, et la supervision affiche un compte vide avec un code 0. Préférez la comparaison explicite.

Même chose dans un tube sous pipefail : grep motif fichier | wc -l échoue quand il n'y a rien à compter, parce que le code de grep remonte. Encapsuler grep dans une petite fonction rend l'intention lisible :

# grep, mais « rien trouvé » n'est pas une erreur
chercher() { grep "$@" || (( $? == 1 )); }

Un premier outil : resume-securite

L'équipe range ces recherches dans un script du dépôt signalements-outils, bin/resume-securite, que l'on lancera chaque matin et dont la leçon 8 reprendra les chiffres dans le rapport hebdomadaire :

#!/usr/bin/env bash
# resume-securite : tentatives SSH et sondes de vulnérabilités vues dans les journaux reçus.
# Usage : resume-securite [RÉPERTOIRE_JOURNAUX]
set -euo pipefail
export LC_ALL=C.UTF-8

racine=${1:-/srv/donnees/journaux}
motifs=${SONDES_MOTIFS:-$(dirname -- "$0")/../lib/sondes.motifs}
shopt -s nullglob
journaux=("$racine"/*/syslog.log)
if (( ${#journaux[@]} == 0 )); then
    printf 'resume-securite : aucun journal sous %s\n' "$racine" >&2
    exit 1
fi

# grep renvoie 1 quand il ne trouve rien : ce n'est pas une erreur ici.
compter() { grep "$@" || (( $? == 1 )); }

printf '== Sources des tentatives SSH (comptes inexistants ou root)\n'
compter -hE 'sshd\[[0-9]+\]: (Invalid user [^ ]+ from|Connection closed by authenticating user root) ' \
        -- "${journaux[@]}" |
    grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort | uniq -c | sort -rn

printf '\n== Sondes de vulnérabilités, par hôte\n'
compter -c -F -f "$motifs" -- "${journaux[@]}"

printf '\n== Connexions SSH acceptées\n'
compter -h 'sshd\[[0-9]*\]: Accepted ' -- "${journaux[@]}" | cut -d' ' -f1,2,4-9

Les choix qui méritent une explication :

  • Le motif SSH retient deux formes de ligne : Invalid user X from IP (compte inexistant) et Connection closed by authenticating user root IP (tentative sur root, qui existe). [^ ]+ correspond au nom de compte, quel qu'il soit, sans déborder sur l'espace suivante. On ne compte pas les lignes Connection closed by invalid user, qui doublonneraient les Invalid user.
  • -- avant la liste de fichiers : un nom de fichier qui commencerait par un tiret ne serait pas pris pour une option. Les motifs, eux, sont passés par une option (-E, -f), donc sans ambiguïté.
  • -F -f pour les sondes, pour la raison vue plus haut.
  • cut -d' ' -f1,2,4-9 garde l'horodatage, l'hôte et le message, sans le sshd[PID]:. Le format de cette ligne est fixe, une espace simple entre les champs : cut suffit.
  • LC_ALL=C.UTF-8 fixe le comportement des classes et du tri, quelle que soit la locale de la tâche planifiée.
$ bin/resume-securite journaux
== Sources des tentatives SSH (comptes inexistants ou root)
     67 203.0.113.77
     50 203.0.113.150
     35 198.51.100.23

== Sondes de vulnérabilités, par hôte
journaux/sig-app-1.pn-signalements.internal/syslog.log:48
journaux/sig-app-2.pn-signalements.internal/syslog.log:48

== Connexions SSH acceptées
2026-10-05T09:12:00.000000+00:00 sig-app-1 Accepted publickey for deploiement from 172.16.8.30
2026-10-06T09:12:00.000000+00:00 sig-app-1 Accepted publickey for deploiement from 172.16.8.30
...
2026-10-08T09:12:00.000000+00:00 sig-app-2 Accepted publickey for deploiement from 172.16.8.30
$ bin/resume-securite vide; echo "code $?"
resume-securite : aucun journal sous vide
code 1

Les seules connexions acceptées viennent du compte deploiement, depuis sig-outils (172.16.8.30), par clé, chaque matin à 9 h 12 : c'est le déploiement. Rien d'anormal de ce côté, mais le port 22 n'a rien à faire ouvert sur internet ; l'action corrective (fermer le port dans le groupe de sécurité, n'autoriser SSH que depuis le réseau privé) est notée pour l'équipe.

Sous le capot

Des automates plutôt que des essais

Il y a deux grandes façons d'exécuter une expression régulière. La première, celle des moteurs de Perl, Python, Java ou PCRE, est le retour arrière (backtracking) : le moteur essaie une façon de faire correspondre le motif, et quand elle échoue, revient au dernier choix et en essaie une autre. Simple, et capable de tout (références arrière, regards), mais le nombre d'essais peut croître de façon exponentielle avec la longueur du texte pour certains motifs.

La seconde, que Ken Thompson a décrite en 1968 et que l'article de Russ Cox, Regular Expression Matching Can Be Simple And Fast, a remise en lumière, traduit le motif en automate fini. Un automate déterministe (DFA) lit le texte caractère par caractère, en changeant d'état à chaque caractère, sans jamais revenir en arrière : le temps est proportionnel à la longueur du texte, quel que soit le motif. C'est l'approche de GNU grep, qui construit son automate à la demande (module dfa.c de gnulib, partagé avec GNU sed et gawk). Quand le motif contient une référence arrière, que l'automate ne sait pas représenter (« le même texte qu'avant » n'est pas un langage régulier au sens mathématique), grep utilise l'automate pour écarter vite les lignes impossibles, puis confie les candidates à un moteur à retour arrière.

La différence se mesure. Sur un journal de 86 Mo (657 396 lignes, construit avec preparer-donnees --volume 10 puis concaténé douze fois), sur notre poste :

MotifLignes sélectionnéesDurée
grep -cE '(..)(..)'657 3960,05 s
grep -cE '(..)\1'654 2642,0 s

Deux motifs de même allure, quarante fois d'écart : le second contient une référence arrière.

Pourquoi grep est rapide sur un texte fixe

Mike Haertel, auteur de GNU grep, a résumé en 2010 sur une liste de diffusion de FreeBSD les raisons de sa vitesse. La principale : ne pas regarder chaque octet. Pour un texte fixe, ou pour la partie fixe d'un motif (grep en extrait une, quand elle existe, comme " 503 - dans '" 503 -$'), grep utilise un algorithme de la famille de Boyer-Moore : il compare le dernier caractère du texte cherché à la position courante et, s'il ne figure pas dans le texte cherché, saute d'un coup de toute la longueur du motif. Plus le texte cherché est long, plus les sauts sont grands. Ensuite, grep ne découpe pas son entrée en lignes : il cherche le motif dans un grand tampon, et ne remonte au début et à la fin de la ligne qu'en cas de correspondance. Les mesures, sur le même fichier :

CommandeDurée
grep -cF '" 503 -'0,09 s
grep -cE '" 503 -$'0,08 s
grep -cE 'python3\[[0-9]+\]: [0-9.]+ - - .* 503 -$'0,17 s
grep -cP 'python3\[\d+\]: [\d.]+ - - .* 503 -$'0,14 s

Les deux premières sont équivalentes : grep a reconnu la chaîne fixe dans l'expression. La troisième, plus précise, coûte deux fois plus, ce qui reste négligeable devant la lecture du disque. PCRE2, avec son compilateur à la volée (JIT), n'est pas lent sur un motif sage ; c'est sur un motif pathologique qu'il s'effondre.

La locale, et le coût de -i

Dans une locale UTF-8, un caractère peut occuper de un à quatre octets, et grep doit en tenir compte : . doit correspondre à é (deux octets) comme à un seul caractère. Pendant longtemps, cela rendait grep beaucoup plus lent sous fr_FR.UTF-8 que sous C, d'où le conseil, encore très répandu, de préfixer les recherches de LC_ALL=C. Les versions récentes ont largement comblé l'écart. Sur notre fichier de 86 Mo :

MotifLC_ALL=CC.UTF-8fr_FR.UTF-8
grep -c 'Invalid user'0,034 s0,035 s0,035 s
grep -ic 'invalid user'0,044 s0,092 s0,086 s
grep -cE '[[:alpha:]]+ user [a-z]+ from'0,087 s0,085 s0,081 s

Il reste un surcoût sur -i, parce que la correspondance sans casse en Unicode est plus complexe qu'en ASCII. Au passage, -i sélectionne 2 952 lignes au lieu de 1 476 : il trouve aussi Connection closed by invalid user, en minuscules. Ignorer la casse change le résultat, pas seulement la vitesse.

LC_ALL=C reste utile pour une autre raison : le comportement. Sous C, un octet est un caractère, les classes ne contiennent que de l'ASCII, et aucune séquence d'octets n'est invalide. Sous une locale UTF-8, un octet qui ne forme pas un caractère valide change la façon dont grep traite le fichier, comme on va le voir.

Les fichiers « binaires »

Après un arrêt brutal de la machine, il arrive qu'un journal contienne un bloc d'octets nuls : le système de fichiers avait réservé la place, les données n'ont jamais été écrites. Simulons-le :

$ { head -n 200 "$j1"; head -c 4096 /dev/zero; tail -n 200 "$j1"; } > syslog.log.1
$ grep CRON syslog.log.1; echo "code $?"
grep: syslog.log.1: binary file matches
code 0
$ grep -c CRON syslog.log.1
6
$ grep -a CRON syslog.log.1 | tail -n 2
2026-10-08T22:17:01.000000+00:00 sig-app-1 CRON[6906]: (root) CMD (cd / && run-parts --report /etc/cron.hourly)
2026-10-08T23:17:01.000000+00:00 sig-app-1 CRON[2621]: (root) CMD (cd / && run-parts --report /etc/cron.hourly)

grep a trouvé six lignes, mais refuse de les afficher. Son manuel décrit la règle : un fichier est considéré comme binaire s'il contient des octets nuls ou des séquences invalides dans l'encodage de la locale. Par défaut (--binary-files=binary), grep supprime la sortie après avoir détecté ces données, et termine par un message qui dit qu'un fichier binaire correspond. Depuis grep 3.5, ce message part sur la sortie d'erreur (il était auparavant mêlé aux résultats, sur la sortie standard, sous la forme Binary file syslog.log.1 matches). Le code de sortie, lui, vaut bien 0.

Les remèdes :

  • -a (--binary-files=text) traite le fichier comme du texte : les lignes sont affichées, octets nuls compris. Attention en terminal : un vrai fichier binaire affiché ainsi peut contenir des séquences de contrôle.
  • --binary-files=without-match (-I) ignore les fichiers binaires : utile avec -r dans une arborescence qui contient des images ou des exécutables.
  • Nettoyer à la source : tr -d '\0' < syslog.log.1 | grep CRON.

Le même phénomène se produit sans aucun octet nul, avec un fichier qui n'est pas en UTF-8 alors que la locale l'est. Un export ancien, en Latin-1, où é est l'octet 0xE9 :

$ printf '2026-10-07 commune=Bourg-T\xe9moin\n' > latin1.log
$ grep Bourg latin1.log
grep: latin1.log: binary file matches
$ LC_ALL=C grep Bourg latin1.log | cat -v
2026-10-07 commune=Bourg-TM-imoin

Sous LC_ALL=C, tout octet est un caractère valide : la ligne s'affiche (cat -v montre l'octet 0xE9 sous la forme M-i). La vraie solution est de convertir le fichier (iconv -f LATIN1 -t UTF-8), comme le montre la leçon 5 de Premiers pas.

Warning

Un script qui teste grep motif fichier | wc -l sur un fichier devenu « binaire » compte zéro ligne (la sortie standard est vide) alors que le motif est présent, et le message part sur la sortie d'erreur, souvent jetée. Pour compter, -c reste juste dans tous les cas ; pour tester, -q aussi.

Pièges courants

Un motif trop court. grep 503 trouve des microsecondes, des identifiants, des ports. Décrivez le contexte du champ ('" 503 -$'), ou -w pour un mot entier.

Le motif non protégé, développé par le shell. Dans un répertoire qui contient un fichier nommé 500 :

$ grep -c 5[0-9][0-9] syslog.log
34
$ grep -c '5[0-9][0-9]' syslog.log
2958

Sans guillemets, 5[0-9][0-9] est un motif de chemins pour le shell : il correspond au fichier 500, et grep reçoit le motif 500. Aucun message d'erreur, un résultat faux. set -x le révèle (+ grep -c 500 syslog.log). Mettez toujours le motif entre guillemets simples.

Un motif qui commence par un tiret. grep '-w' fichier : grep lit -w comme une option, puis prend fichier pour le motif et attend des données sur son entrée standard. Au terminal, il semble figé ; dans un script, il lit ce qui traîne sur l'entrée. Écrivez grep -e '-w' fichier ou grep -- '-w' fichier.

\d hors de -P. grep -E '\d+' cherche des d ; sur Debian et Ubuntu, sans le moindre avertissement. [0-9] ou [[:digit:]].

La syntaxe BRE oubliée. grep 'x+y' cherche le texte littéral x+y ; grep '(admin|root)' cherche des parenthèses et une barre verticale. Passez à -E.

-c qui compte des lignes. Deux correspondances sur une ligne comptent pour un. -o ... | wc -l pour des occurrences.

[:digit:] sans les doubles crochets. GNU grep le refuse, d'autres implémentations le prennent pour une classe de cinq caractères.

L'alternance sans groupe. ^Invalid user admin|root : l'ancre ne s'applique qu'à la première branche. ^Invalid user (admin|root).

-i qui en trouve plus. Ignorer la casse change le résultat (Invalid user et invalid user). Décidez-le, ne l'ajoutez pas par habitude.

grep -r et les liens symboliques. -r ne suit pas les liens rencontrés en chemin, -R les suit (et peut alors parcourir deux fois la même arborescence, ou sortir de celle que vous visiez).

set -e et le code 1. n=$(grep -c ...) arrête le script quand le compte vaut zéro. || (( $? == 1 )), pas || true.

« binary file matches ». Octets nuls ou encodage invalide : -a, LC_ALL=C, ou conversion du fichier.

grep pour du JSON. Fragile au moindre changement de mise en forme. jq (leçon 7).

Un motif d'adresse IP qui valide n'importe quoi. ([0-9]{1,3}\.){3}[0-9]{1,3} accepte 999.1.1.1 et extrait 1.2.3.4 de 1.2.3.4.5. Une expression régulière décrit une forme ; vérifier qu'une valeur est valide est un autre travail (exercice 5).

Sécurité

  • Le motif est du code. Un motif reçu d'un tiers (un argument de script appelé par une interface web, un paramètre de CI) ne doit jamais être passé tel quel comme expression régulière. Il peut changer le sens de la recherche (.*), provoquer une erreur ([), ou, s'il commence par un tiret, devenir une option. Avec -F -e "$motif", le texte est cherché littéralement et ne peut pas être pris pour une option.

  • Le déni de service par expression régulière (ReDoS). Un moteur à retour arrière peut mettre un temps exponentiel sur un motif comme ^(a+)+$ face à un texte comme aaaa...a!. GNU grep sans -P n'y est pas exposé (automate), mais -P l'est, comme les bibliothèques d'expressions de la plupart des langages. PCRE2 a des limites de calcul, et grep les applique :

    $ s=$(printf 'a%.0s' $(seq 24))!
    $ echo "$s" | grep -cP '^(a+)+$'; echo "code ${PIPESTATUS[1]}"
    grep: (standard input): exceeded PCRE's backtracking limit
    code 2
    $ echo "$s" | grep -cE '^(a+)+$'
    0
    

    La limite protège la machine, mais transforme la recherche en erreur (code 2). Un script de supervision qui lit un tel code comme « rien trouvé » se trompe ; un attaquant capable d'injecter une ligne bien choisie dans un journal peut ainsi aveugler une recherche -P. Préférez -E dans les scripts, et réservez -P à l'exploration au terminal.

  • Les journaux contiennent des secrets et des données personnelles. Un grep -r 'password' /etc ou grep -r 'Authorization' journaux/ affiche ce qu'il trouve : au terminal, dans l'historique d'un tmux partagé, dans la sortie d'une tâche de CI publique. Avant de coller le résultat d'un grep dans un ticket ou un canal de discussion, relisez-le ; les adresses IP clientes du journal JSON sont des données personnelles au sens du RGPD. La leçon 3 montre comment les anonymiser.

  • Ce que l'on ne voit pas. Les sondes de vulnérabilités du journal texte apparaissent toutes avec l'adresse du répartiteur, 172.16.8.20 : le journal texte ne contient pas l'adresse du client réel. Bloquer « l'adresse qui sonde » d'après ce journal bloquerait le répartiteur, donc tout le service. La vraie source est dans le journal JSON, champ client.ip.

  • Afficher des données hostiles. Une ligne de journal peut contenir des séquences d'échappement de terminal (dans un chemin de requête, un agent utilisateur). grep --color=never et cat -v les neutralisent à l'affichage ; ne faites pas grep -a sur un fichier inconnu dans un terminal.

  • Surveiller, pas seulement chercher. L'enquête a révélé un port SSH ouvert sur internet depuis on ne sait quand. grep a répondu à la question posée ; la vraie correction est un groupe de sécurité qui ferme le port, et une alerte qui aurait signalé ces tentatives sans attendre qu'un incident fasse ouvrir les journaux.

En production

  • grep dans la supervision. Une vérification de type « au moins une ligne d'erreur dans les cinq dernières minutes » s'écrit avec grep -q et une gestion explicite des trois codes : 0 (alerte), 1 (tout va bien), 2 (la vérification elle-même est cassée, ce qui doit aussi alerter). Un script qui confond 1 et 2 est une supervision qui dit « tout va bien » quand le journal a disparu.
  • Le bon outil pour le bon journal. Sur une machine qui journalise par journald, journalctl -u signalements --since '2026-10-07 14:00' --until '2026-10-07 14:20' --grep 'Traceback' filtre directement dans le journal, avec des expressions PCRE2 ; les dates sont gérées par journald, pas par une expression régulière sur l'horodatage. Les journaux reçus par rsyslog sur sig-outils, eux, sont des fichiers texte : grep.
  • Les journaux tournés. Une enquête sur plusieurs jours traverse les fichiers compressés : zgrep, ou zcat -f syslog.log* | grep, qui lit indifféremment fichiers compressés et non compressés. Ordonnez les fichiers du plus ancien au plus récent si l'ordre compte.
  • Des outils plus rapides. ripgrep (rg, 14.1 dans Debian 13) et ugrep reprennent l'interface de grep, parcourent une arborescence en parallèle, ignorent par défaut les fichiers listés dans .gitignore et les fichiers binaires. Ils sont précieux pour chercher dans du code source ; pour un script installé sur des serveurs, GNU grep a l'avantage d'être partout, puisqu'il fait partie des paquets essentiels de Debian et d'Ubuntu, et d'avoir un comportement normalisé.
  • Locale fixée. Comme pour tous les outils de ce cours, fixez LC_ALL=C.UTF-8 dans les tâches planifiées : les classes, la détection des fichiers binaires et la correspondance sans casse dépendent de la locale.
  • Volume. grep lit des centaines de mégaoctets par seconde ; au-delà de quelques gigaoctets par jour et de recherches fréquentes, ce n'est plus grep qui manque, c'est un système de journaux centralisés indexé (le cours Journaux centralisés avec Loki du chapitre Exploiter, à venir).

Exercices

1. Prévoir les correspondances (niveau 100). Pour chaque commande, dites quelles lignes du fichier ex1 sont sélectionnées, puis vérifiez. Le fichier :

$ printf 'code 503\ncode ddd\nx+y\nxxy\na|b\nb\n' > ex1

Les commandes, chacune suivie de ex1 : (a) grep 'x+y' ; (b) grep -E 'x+y' ; (c) grep 'x\+y' ; (d) grep -E 'code \d+' ; (e) grep 'a|b' ; (f) grep 'a\|b' ; (g) grep -E '^(a|b)$' ; (h) grep -x b.

Solution

(a) x+y seulement : en BRE, + est littéral. (b) xxy seulement : en ERE, x+y signifie « un ou plusieurs x puis y » ; la ligne x+y ne contient aucun x immédiatement suivi d'un y, elle n'est donc pas sélectionnée. (c) xxy : \+ est l'extension GNU qui donne à + son sens en BRE. (d) code ddd : \d vaut la lettre d pour grep sans -P. (e) a|b seulement : barre littérale en BRE. (f) a|b et b : \| est l'alternance GNU en BRE ; a|b contient un a, b contient un b. (g) b seulement : la ligne entière doit être a ou b, et a|b a trois caractères. (h) b : -x impose la ligne entière.

Les cas (a) et (b) se lisent souvent à l'envers : vérifiez toujours un motif en le lançant sur des lignes choisies, c'est la seule façon de savoir ce qu'il fait.

2. Recouper l'export avec les journaux (niveau 100). Chaque création de signalement réussie apparaît dans le journal texte sous la forme "POST /signalements HTTP/1.1" 201 -. Comptez-les par jour sur les deux serveurs, puis comparez avec le nombre d'enregistrements des exports exports/signalements-*.csv (une ligne d'en-tête par fichier).

Solution
$ grep -hE '"POST /signalements HTTP/1\.1" 201 -$' "$j1" "$j2" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}' | sort | uniq -c
     96 2026-10-05
    101 2026-10-06
     86 2026-10-07
     68 2026-10-08
$ grep -c '' exports/*.csv
exports/signalements-2026-10-05.csv:97
exports/signalements-2026-10-06.csv:102
exports/signalements-2026-10-07.csv:87
exports/signalements-2026-10-08.csv:69

grep -c '' compte les lignes de chaque fichier (le motif vide correspond à toutes les lignes) ; en retirant l'en-tête, on retrouve exactement 96, 101, 86 et 68. Ici, sort est nécessaire avant uniq -c : les lignes viennent de deux fichiers concaténés, donc les dates ne sont pas consécutives. Le . de HTTP/1.1 est échappé : sans cela il correspondrait à n'importe quel caractère, sans conséquence ici, mais c'est une bonne habitude. Le recoupement entre deux sources indépendantes est une vérification précieuse avant d'envoyer des chiffres à la mairie : un export incomplet se verrait immédiatement.

3. La supervision qui meurt (niveau 200). Le script compter-oom de la leçon s'arrête sans rien dire quand il n'y a aucun événement. (a) Corrigez-le pour qu'il affiche 0 dans ce cas, mais échoue toujours si le journal est absent. (b) Pourquoi || true est-il une mauvaise correction ? (c) Écrivez une variante qui renvoie le code 0 s'il n'y a aucun événement, 1 s'il y en a au moins un, et 3 si la vérification n'a pas pu se faire.

Solution

(a) n=$(grep -c 'Out of memory' "$journal") || (( $? == 1 )). Avec le code 1, la comparaison réussit et n vaut 0 ; avec le code 2, elle échoue et set -e arrête le script.

(b) || true transforme aussi le code 2 en succès. Le journal absent ou illisible donne n vide (grep n'écrit rien sur sa sortie standard en cas d'erreur sur son seul fichier), et le script affiche événements OOM : avec un code 0 : la supervision est aveugle et ne le sait pas.

(c) Avec un case sur le code, sans set -e sur la commande testée :

#!/usr/bin/env bash
set -uo pipefail
journal=${1:-/srv/donnees/journaux/sig-app-2.pn-signalements.internal/syslog.log}
grep -q 'Out of memory' "$journal"
case $? in
    0) echo "ALERTE : OOM dans $journal"; exit 1 ;;
    1) echo "OK : aucun OOM"; exit 0 ;;
    *) echo "INCONNU : lecture impossible de $journal" >&2; exit 3 ;;
esac

Les codes 0, 1 et 3 suivent ici la convention de supervision de l'équipe ; les sondes de type Nagios utilisent 0 (OK), 1 (avertissement), 2 (critique), 3 (inconnu).

4. Les sondes, jour par jour (niveau 200). (a) Avec lib/sondes.motifs, comptez les sondes par jour sur les deux serveurs. (b) Expliquez pourquoi grep -f lib/sondes.motifs sans -F pourrait compter une requête légitime, en donnant un exemple de chemin. (c) L'équipe voudrait bloquer au pare-feu les adresses qui sondent. Pourquoi les journaux texte ne permettent-ils pas de les trouver, et où chercher ?

Solution

(a)

$ grep -hF -f lib/sondes.motifs "$j1" "$j2" | grep -oE '^[0-9-]{10}' | sort | uniq -c
     24 2026-10-05
     24 2026-10-06
     24 2026-10-07
     24 2026-10-08

Un rythme parfaitement régulier, typique d'un balayage automatisé d'internet.

(b) Sans -F, /.env est une expression régulière où . correspond à tout caractère : /xenv, /venv, /_env seraient comptés. De même, config.php correspondrait à configXphp. -F traite chaque ligne du fichier comme un texte exact.

(c) Le journal texte montre l'adresse du répartiteur, 172.16.8.20, sur toutes les requêtes : l'API voit la connexion du répartiteur, pas celle du client. La vraie adresse figure dans le journal JSON (client.ip), que le répartiteur transmet par un en-tête. La leçon 7 l'extrait avec jq. Et bloquer au pare-feu de l'hôte ne servirait à rien : c'est le répartiteur qui reçoit les connexions des clients.

5. Une adresse IPv4 valide (niveau 200). Écrivez une expression ERE qui sélectionne, avec grep -xE, les lignes qui sont une adresse IPv4 valide (chaque octet de 0 à 255, sans zéro en tête), et testez-la sur 203.0.113.77, 999.1.1.1, 10.0.0.256, 1.2.3.4.5, 172.16.8.020 et 0.0.0.0. Puis expliquez pourquoi, dans un script réel, on ne validerait pas forcément une adresse ainsi.

Solution

Un octet valide se découpe en cas : 250 à 255, 200 à 249, 100 à 199, 0 à 99 sans zéro en tête.

$ o='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
$ printf '%s\n' 203.0.113.77 999.1.1.1 10.0.0.256 1.2.3.4.5 172.16.8.020 0.0.0.0 | grep -xE "($o\.){3}$o"
203.0.113.77
0.0.0.0

Les guillemets doubles autour du motif laissent Bash remplacer $o ; les guillemets simples autour de la définition de o protègent ses caractères spéciaux. -x est indispensable : sans lui, 1.2.3.4.5 contiendrait une adresse valide (1.2.3.4 ou 2.3.4.5) et serait sélectionnée. 172.16.8.020 est refusée à cause du zéro en tête, que certains programmes lisent en octal.

Dans un script réel, une expression de cette taille est difficile à relire et ne couvre ni IPv6 ni les cas métier (adresse privée, de documentation, de diffusion). Pour extraire des adresses d'un journal dont on connaît le format, une expression qui décrit la forme et s'appuie sur le contexte (from ) suffit ; pour valider une saisie, on confie le travail à un programme qui analyse réellement l'adresse, comme le module ipaddress de Python.

Récapitulatif

  • grep sélectionne les lignes où le motif apparaît n'importe où ; un bon motif décrit le contexte de la valeur ('" 503 -$'), pas seulement la valeur.
  • Quatre syntaxes : BRE par défaut (opérateurs précédés de \), ERE avec -E (à privilégier), chaînes fixes avec -F (pour tout texte qui n'est pas une expression régulière), PCRE avec -P (extension GNU, \d, regards, mais retour arrière).
  • \d n'existe qu'avec -P ; Debian et Ubuntu ne préviennent pas. egrep et fgrep sont obsolètes : grep -E, grep -F.
  • POSIX retient la correspondance la plus longue à gauche, PCRE la première trouvée.
  • Les options choisissent le résultat : lignes, -v, -c (des lignes), -o (des correspondances), -l/-L, -q, contexte -A/-B/-C, -m.
  • Codes de sortie : 0 trouvé, 1 rien trouvé, 2 erreur. Sous set -e, || (( $? == 1 )), jamais || true.
  • Motifs toujours entre guillemets simples ; -e ou -- si un motif peut commencer par un tiret.
  • « binary file matches » : octets nuls ou encodage invalide ; -a, LC_ALL=C, ou conversion.
  • Sous le capot, un automate : temps linéaire, sauf références arrière et -P. Les chaînes fixes sont cherchées par sauts.
  • Un motif fourni par un tiers : -F -e "$motif". -P est exposé au ReDoS, et sa limite produit le code 2.

Pour aller plus loin

  • Le manuel de GNU grep, court et précis, en particulier les sections sur les codes de sortie, les fichiers binaires et les performances.
  • Le chapitre 9 de la norme POSIX, qui définit BRE et ERE et la règle du plus long à gauche.
  • L'article de Russ Cox, Regular Expression Matching Can Be Simple And Fast, qui explique automates et retour arrière avec des mesures, et le message de Mike Haertel, why GNU grep is fast.
  • Mastering Regular Expressions de Jeffrey Friedl, la référence sur les moteurs à retour arrière et l'écriture de motifs efficaces ; le cours Expressions régulières de ce chapitre, à venir, en reprendra l'essentiel.
  • La page ReDoS de l'OWASP pour les motifs dangereux et les moyens de s'en protéger.
  • La leçon suivante, sed : le cycle et la substitution, réutilise ces expressions régulières pour transformer les lignes au lieu de les sélectionner.
+10 XP Carte du ciel →Mon cosmonaute →

Sources