Chapitre 1119 minDébutant
Expressions régulières et analyse de logs avec re
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire une expression régulière avec la syntaxe essentielle ;
- extraire des champs avec des groupes nommés ;
- analyser un journal SSH et un journal d'accès web ;
- anonymiser un extrait avec re.sub.
3 exercices corrigés
Une expression régulière (regex) est un motif qui décrit une famille de chaînes : « from suivi d'une adresse IP », « PC- suivi de quatre chiffres ». Le module re de Python s'en sert pour chercher, valider, extraire et remplacer du texte. Ce chapitre présente les fonctions du module et la syntaxe essentielle, puis analyse deux journaux : les connexions SSH d'un serveur de test exposé sur Internet, pour repérer une attaque par force brute, et le journal d'accès Nginx du serveur web de Sorbier Logistique, pour repérer un scan ; il termine par l'anonymisation d'un extrait avant de le partager.
Une regex en Python : search, fullmatch, findall
>>> import re
>>> ligne = "Failed password for invalid user admin from 198.51.100.23 port 51234 ssh2"
>>> m = re.search(r"from (\d+\.\d+\.\d+\.\d+)", ligne)
>>> m.group(0)
'from 198.51.100.23'
>>> m.group(1)
'198.51.100.23'
>>> re.search(r"Accepted", ligne) is None
True
>>> re.findall(r"\d+", ligne)
['198', '51', '100', '23', '51234', '2']
>>> re.fullmatch(r"PC-\d{4}", "PC-0042")
<re.Match object; span=(0, 7), match='PC-0042'>
>>> re.fullmatch(r"PC-\d{4}", "PC-42") is None
True
>>> re.fullmatch(r"srv-[a-z0-9]+", "SRV-AD", flags=re.IGNORECASE) is not None
True
>>> re.split(r"[;,]\s*", "srv-ad; srv-ad2,srv-nas")
['srv-ad', 'srv-ad2', 'srv-nas']
| Fonction | Rôle | Renvoie |
|---|---|---|
re.search(motif, texte) | Cherche le motif n'importe où dans le texte | Un objet Match, ou None |
re.fullmatch(motif, texte) | Le texte entier doit correspondre : pour valider une saisie | Un Match, ou None |
re.findall(motif, texte) | Toutes les correspondances | Une liste de chaînes |
re.finditer(motif, texte) | Toutes les correspondances, une par une | Des objets Match |
re.sub(motif, remplacement, texte) | Remplace chaque correspondance | La nouvelle chaîne |
re.split(motif, texte) | Découpe sur chaque correspondance | Une liste |
re.compile(motif) | Prépare le motif une fois, pour s'en servir souvent | Un objet Pattern |
Un Match donne la correspondance entière avec group(0) et chaque groupe entre parenthèses avec group(1), group(2)… Comme search renvoie None quand rien ne correspond, on teste toujours le résultat avant d'appeler group(), sinon : AttributeError: 'NoneType' object has no attribute 'group'.
Écris toujours les motifs dans une chaîne brute, préfixée par r : r"\d+". Sans le r, Python interpréterait lui-même certaines séquences avec antislash avant que re ne les voie.
La syntaxe essentielle
| Élément | Correspond à | Exemple |
|---|---|---|
. | N'importe quel caractère (sauf le retour à la ligne) | a.c : abc, a-c |
\d, \w, \s | Un chiffre, un caractère de mot (lettre, chiffre, _), un espace | \d\d : 42 |
\D, \W, \S | Le contraire : tout sauf un chiffre, sauf un caractère de mot, sauf un espace | \S+ : un mot sans espace |
[abc], [a-z0-9], [^;] | Un caractère parmi, un intervalle, tout sauf | [;,] : ; ou , |
*, +, ? | 0 fois ou plus, 1 fois ou plus, 0 ou 1 fois | \d+ : un nombre |
{4}, {1,3} | Exactement 4 fois, de 1 à 3 fois | \d{1,3} : un octet d'adresse |
^, $ | Début, fin du texte | ^srv- |
\b | Frontière de mot | \broot\b ne trouve pas rootkit |
(…), (?P<nom>…) | Groupe, groupe nommé | (?P<ip>[\d.]+) |
(?:…) | Groupe sans capture | (?:invalid user )? |
a|b | a ou b | (PC|SRV)-\d{4} |
\. | Un vrai point (le . seul veut dire « n'importe quoi ») | \d+\.\d+ |
+ et * sont gourmands : ils prennent le plus de caractères possible. ".*" sur "GET /" 200 "-" va du premier au dernier guillemet ; "[^"]*" s'arrête au premier guillemet fermant, ce qui est presque toujours ce qu'on veut dans un journal.
Les groupes nommés
Pour découper une ligne en champs, nomme les groupes : le code se lit tout seul.
# groupes.py : des groupes nommés pour découper une ligne de journal
import re
ECHEC = re.compile(
r"Failed password for (?:invalid user )?(?P<compte>\S+) "
r"from (?P<ip>[\d.]+) port (?P<port>\d+)"
)
ligne = ("nov. 08 02:14:07 vps-test sshd-session[4410]: "
"Failed password for invalid user admin from 198.51.100.23 port 51234 ssh2")
m = ECHEC.search(ligne)
print(m["compte"], m["ip"], int(m["port"]))
print(m.groupdict())
admin 198.51.100.23 51234
{'compte': 'admin', 'ip': '198.51.100.23', 'port': '51234'}
m["compte"] est un raccourci pour m.group("compte"), et groupdict() renvoie tous les groupes nommés dans un dictionnaire. Le groupe (?:invalid user )? est facultatif et sans capture : il absorbe les mots « invalid user » quand ils sont là, sans créer de champ. Deux chaînes écrites côte à côte sont collées par Python : c'est ainsi qu'un long motif se coupe sur plusieurs lignes.
Analyser les connexions SSH
Sur Debian 13, les connexions SSH sont tracées par le programme sshd-session dans le journal de systemd (les journaux sous Linux). Chez Sorbier, SSH n'est pas joignable depuis Internet (le pare-feu ne publie que les ports 80 et 443 de srv-web) et n'accepte que les clés. Pour s'entraîner, Antoine a exporté le journal d'un serveur de test loué chez un hébergeur, vps-test, resté avec la configuration SSH par défaut, avec journalctl -u ssh --since today > ssh.log :
nov. 08 02:14:07 vps-test sshd-session[4410]: Failed password for invalid user admin from 198.51.100.23 port 51234 ssh2
nov. 08 02:14:09 vps-test sshd-session[4410]: Failed password for invalid user admin from 198.51.100.23 port 51234 ssh2
nov. 08 02:14:12 vps-test sshd-session[4412]: Failed password for invalid user test from 198.51.100.23 port 51240 ssh2
nov. 08 02:14:15 vps-test sshd-session[4414]: Failed password for root from 198.51.100.23 port 51248 ssh2
nov. 08 02:14:18 vps-test sshd-session[4416]: Failed password for invalid user oracle from 198.51.100.23 port 51252 ssh2
nov. 08 03:41:52 vps-test sshd-session[4598]: Failed password for root from 192.0.2.44 port 40112 ssh2
nov. 08 03:41:55 vps-test sshd-session[4598]: Failed password for root from 192.0.2.44 port 40112 ssh2
nov. 08 07:58:31 vps-test sshd-session[5120]: Accepted publickey for clambert from 203.0.113.17 port 50122 ssh2: ED25519 SHA256:q3Vb5o7cX2kP9wLr1mZ8tY4nA6sD0fGhJ2kLzXcVbNm
nov. 08 08:02:10 vps-test sshd-session[5133]: Failed password for mdiallo from 203.0.113.17 port 50140 ssh2
nov. 08 08:02:16 vps-test sshd-session[5133]: Accepted password for mdiallo from 203.0.113.17 port 50140 ssh2
nov. 08 09:17:44 vps-test sshd-session[5302]: Failed password for invalid user ubuntu from 198.51.100.87 port 33908 ssh2
nov. 08 09:17:47 vps-test sshd-session[5302]: Failed password for invalid user ubuntu from 198.51.100.87 port 33908 ssh2
nov. 08 09:17:50 vps-test sshd-session[5302]: Connection closed by invalid user ubuntu 198.51.100.87 port 33908 [preauth]
# echecs_ssh.py : synthèse des connexions SSH d'un journal exporté de journalctl
import re
from collections import Counter
ECHEC = re.compile(r"Failed password for (?P<invalide>invalid user )?(?P<compte>\S+) from (?P<ip>[\d.]+)")
SUCCES = re.compile(r"Accepted (?P<methode>\S+) for (?P<compte>\S+) from (?P<ip>[\d.]+)")
SEUIL = 3
echecs_par_ip = Counter()
comptes_inexistants = Counter()
succes = []
with open("ssh.log", encoding="utf-8") as journal:
for ligne in journal:
if m := ECHEC.search(ligne):
echecs_par_ip[m["ip"]] += 1
if m["invalide"]:
comptes_inexistants[m["compte"]] += 1
elif m := SUCCES.search(ligne):
succes.append(f"{m['compte']} depuis {m['ip']} ({m['methode']})")
print("Échecs par adresse :")
for ip, nombre in echecs_par_ip.most_common():
alerte = " <- à bloquer ?" if nombre >= SEUIL else ""
print(f" {ip:<16}{nombre}{alerte}")
print("Comptes inexistants visés :", ", ".join(sorted(comptes_inexistants)))
print("Connexions réussies :")
for s in succes:
print(" " + s)
Échecs par adresse :
198.51.100.23 5 <- à bloquer ?
192.0.2.44 2
198.51.100.87 2
203.0.113.17 1
Comptes inexistants visés : admin, oracle, test, ubuntu
Connexions réussies :
clambert depuis 203.0.113.17 (publickey)
mdiallo depuis 203.0.113.17 (password)
L'opérateur := (le « morse », Python 3.8 et plus) affecte et teste en une fois : if m := ECHEC.search(ligne): range le résultat dans m et entre dans le bloc s'il n'est pas None. Le script trouve cinq échecs en onze secondes depuis 198.51.100.23 sur des comptes génériques, la signature d'une attaque par force brute automatisée ; les adresses en 198.51.100.x et 192.0.2.x sont réservées à la documentation et remplacent ici les vraies adresses des attaquants. La parade ne se règle pas dans le script : authentification par clé seulement, PermitRootLogin no, blocage automatique des adresses (fail2ban, ou une règle active d'un SIEM comme Wazuh) et filtrage sur le pare-feu (SSH : connexion par clé et durcissement). Le script, lui, sert à constater et à produire le rapport. Les deux connexions réussies viennent de 203.0.113.17, l'adresse publique de Sorbier, derrière laquelle le pare-feu traduit les adresses des postes internes : c'est l'équipe informatique. La connexion de mdiallo par mot de passe montre que ce serveur accepte encore les mots de passe, ce que la configuration durcie de srv-web interdit.
Analyser un journal d'accès web
Le journal d'accès de Nginx (format combined) contient une ligne par requête : adresse du client, date, requête, code de statut, taille, page d'origine et navigateur.
192.0.2.10 - - [08/Nov/2026:09:14:03 +0100] "GET /suivi?colis=SL-48213 HTTP/1.1" 200 1534 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
192.0.2.10 - - [08/Nov/2026:09:14:04 +0100] "GET /static/style.css HTTP/1.1" 200 8210 "https://suivi.sorbier.example/suivi?colis=SL-48213" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
198.51.100.87 - - [08/Nov/2026:09:20:11 +0100] "GET /wp-login.php HTTP/1.1" 404 162 "-" "Mozilla/5.0"
198.51.100.87 - - [08/Nov/2026:09:20:11 +0100] "GET /.env HTTP/1.1" 404 162 "-" "Mozilla/5.0"
198.51.100.87 - - [08/Nov/2026:09:20:12 +0100] "GET /admin/config.php HTTP/1.1" 404 162 "-" "Mozilla/5.0"
198.51.100.87 - - [08/Nov/2026:09:20:12 +0100] "GET /phpmyadmin/ HTTP/1.1" 404 162 "-" "Mozilla/5.0"
203.0.113.200 - - [08/Nov/2026:10:02:45 +0100] "GET /suivi?colis=SL-50117 HTTP/1.1" 200 1498 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X)"
203.0.113.200 - - [08/Nov/2026:10:02:46 +0100] "GET /suivi?colis=SL-5011 HTTP/1.1" 404 512 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X)"
192.0.2.10 - - [08/Nov/2026:10:15:30 +0100] "POST /api/v1/livraisons HTTP/1.1" 500 89 "-" "python-requests/2.34.2"
192.0.2.10 - - [08/Nov/2026:10:15:41 +0100] "POST /api/v1/livraisons HTTP/1.1" 201 214 "-" "python-requests/2.34.2"
198.51.100.23 - - [08/Nov/2026:11:30:02 +0100] "GET /admin/ HTTP/1.1" 403 153 "-" "Mozilla/5.0"
192.0.2.10 - - [08/Nov/2026:11:45:19 +0100] "GET /suivi?colis=SL-48213 HTTP/1.1" 200 1534 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
# acces_web.py : analyser le journal d'accès Nginx du site de suivi des livraisons
import re
from collections import Counter
ACCES = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<date>[^\]]+)\] '
r'"(?P<methode>[A-Z]+) (?P<chemin>\S+) [^"]*" (?P<statut>\d{3}) (?P<taille>\d+|-)'
)
requetes = []
with open("access.log", encoding="utf-8") as journal:
for ligne in journal:
if m := ACCES.search(ligne):
requetes.append(m.groupdict())
print(len(requetes), "requêtes")
statuts = Counter(r["statut"] for r in requetes)
print("Par statut :", dict(sorted(statuts.items())))
erreurs_404 = Counter(r["ip"] for r in requetes if r["statut"] == "404")
for ip, nombre in erreurs_404.items():
if nombre >= 3:
chemins = [r["chemin"] for r in requetes if r["ip"] == ip and r["statut"] == "404"]
print(f"{ip} : {nombre} pages introuvables, sans doute un scan : {chemins}")
for r in requetes:
if r["statut"].startswith("5"):
print("Erreur serveur :", r["date"], r["methode"], r["chemin"])
12 requêtes
Par statut : {'200': 4, '201': 1, '403': 1, '404': 5, '500': 1}
198.51.100.87 : 4 pages introuvables, sans doute un scan : ['/wp-login.php', '/.env', '/admin/config.php', '/phpmyadmin/']
Erreur serveur : 08/Nov/2026:10:15:30 +0100 POST /api/v1/livraisons
Quatre pages introuvables d'affilée, en une seconde, aux noms de fichiers sensibles d'autres applications (/wp-login.php, /.env) : c'est un robot qui scanne le site à la recherche d'une faille connue. Il n'a rien trouvé (404), mais son adresse mérite une ligne dans le rapport. L'erreur 500 sur l'API, en revanche, est un problème de l'application, à transmettre au développeur avec l'heure exacte. Le cours Réponse à incident replace cette analyse dans la démarche complète (lire et analyser des logs).
Remplacer et anonymiser avec re.sub
Avant de joindre un extrait de journal à un ticket ou de le montrer à l'oral, retire les données personnelles : une adresse IP est une donnée personnelle au sens du RGPD. re.sub remplace chaque correspondance ; dans le texte de remplacement, \1 reprend le premier groupe.
# anonymiser.py : masquer le dernier octet des adresses IPv4 avant de partager un extrait
import re
IPV4 = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b")
with open("access.log", encoding="utf-8") as journal:
for ligne in list(journal)[:3]:
print(IPV4.sub(r"\1.x", ligne.rstrip()))
192.0.2.x - - [08/Nov/2026:09:14:03 +0100] "GET /suivi?colis=SL-48213 HTTP/1.1" 200 1534 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
192.0.2.x - - [08/Nov/2026:09:14:04 +0100] "GET /static/style.css HTTP/1.1" 200 8210 "https://suivi.sorbier.example/suivi?colis=SL-48213" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
198.51.100.x - - [08/Nov/2026:09:20:11 +0100] "GET /wp-login.php HTTP/1.1" 404 162 "-" "Mozilla/5.0"
Le groupe capture les trois premiers octets, et le remplacement \1.x les remet suivis de .x. Les \b évitent de couper un nombre plus long au milieu. Masquer le dernier octet est une pseudonymisation légère, suffisante pour illustrer un rapport ; ce n'est pas une anonymisation au sens du RGPD si le reste du journal permet encore de retrouver la personne (droit du numérique et RGPD).
Exercices
Exercice 1 sur 3 Valider des numéros d'inventaire Réussi À revoir
Un numéro d'inventaire valide est PC- ou SRV- suivi d'exactement quatre chiffres. Écris le script qui teste PC-0042, pc-0042, PC-42, PC-00420, SRV-0001 et PC-0O42. Pourquoi fullmatch et pas search ?
Voir la réponseMasquer la réponse de l'exercice 1
# ex_inventaire.py : valider des numéros d'inventaire
import re
FORMAT = re.compile(r"(PC|SRV)-\d{4}")
for numero in ["PC-0042", "pc-0042", "PC-42", "PC-00420", "SRV-0001", "PC-0O42"]:
verdict = "valide" if FORMAT.fullmatch(numero) else "invalide"
print(f"{numero:<10}{verdict}")
PC-0042 valide
pc-0042 invalide
PC-42 invalide
PC-00420 invalide
SRV-0001 valide
PC-0O42 invalide
pc-0042 échoue car la regex est sensible à la casse ; PC-0O42 contient la lettre O au lieu du chiffre 0. Avec search, PC-00420 serait accepté : le motif trouve PC-0042 à l'intérieur du texte. Pour valider une saisie, il faut que le texte entier corresponde : c'est fullmatch (ou search avec ^…$).
Tu avais trouvé ?
Exercice 2 sur 3 Requêtes par heure Réussi À revoir
À partir de access.log, compte les requêtes de chaque heure et affiche un petit histogramme en texte, une ligne par heure, avec autant de # que de requêtes.
Voir la réponseMasquer la réponse de l'exercice 2
# ex_heures.py : nombre de requêtes par heure dans le journal d'accès
import re
from collections import Counter
HEURE = re.compile(r"\[\d{2}/\w{3}/\d{4}:(\d{2}):")
par_heure = Counter()
with open("access.log", encoding="utf-8") as journal:
for ligne in journal:
if m := HEURE.search(ligne):
par_heure[m.group(1)] += 1
for heure, nombre in sorted(par_heure.items()):
print(f"{heure} h : {'#' * nombre} {nombre}")
09 h : ###### 6
10 h : #### 4
11 h : ## 2
La date du journal a la forme [08/Nov/2026:09:14:03 +0100] : le motif repère le crochet, le jour, le mois en trois lettres (\w{3}), l'année, et capture les deux chiffres qui suivent le deux-points. '#' * nombre répète le caractère.
Tu avais trouvé ?
Exercice 3 sur 3 Masquer les adresses électroniques Réussi À revoir
Un ticket doit être partagé avec un prestataire sans les adresses électroniques. Remplace chaque adresse du texte par <courriel> et affiche combien ont été masquées :
ticket = ("Demande de inès.roux@sorbier.example : transférer les factures "
"à compta-fournisseurs@sorbier.example et en copie à j.dupre+archive@cabinet-expert.example.")
Voir la réponseMasquer la réponse de l'exercice 3
# ex_courriels.py : masquer les adresses électroniques d'un ticket avant de le partager
import re
COURRIEL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
ticket = ("Demande de inès.roux@sorbier.example : transférer les factures "
"à compta-fournisseurs@sorbier.example et en copie à j.dupre+archive@cabinet-expert.example.")
print(COURRIEL.sub("<courriel>", ticket))
print(len(COURRIEL.findall(ticket)), "adresses masquées")
Demande de <courriel> : transférer les factures à <courriel> et en copie à <courriel>.
3 adresses masquées
Avant le @, [\w.+-]+ accepte lettres (accentuées comprises, car \w suit Unicode en Python), chiffres, points, + et tirets. Après, [\w-]+(?:\.[\w-]+)+ exige au moins un point : sorbier.example, cabinet-expert.example. Le point final de la phrase n'est pas avalé, car il n'est suivi d'aucun caractère de mot. Valider parfaitement une adresse électronique avec une regex est impossible ; pour repérer des adresses dans un texte, ce motif suffit.
Tu avais trouvé ?