Premier article d’une série que j’appelle « Coder avec un agent IA, pour de vrai ». Pas de liste de prompts magiques ici : juste ce qui se passe quand on confie du vrai travail à un agent de code, y compris quand ça casse.

Le contexte : je voulais des filtres fail2ban pour bannir les IP qui scannent un site web à coups d’injections SQL dans l’URL (%27, %28, union%20select…). J’ai demandé à mon agent d’écrire les filtres et les tests qui vont avec. Les tests passaient, zéro faux positif sur un échantillon de logs légitimes.

J’ai déployé, et là ça ne s’est pas passé comment attendu.

Le service fail2ban a refusé de démarrer. En cause : les %27 et %28 de mes regex. fail2ban lit ses fichiers de configuration avec un parseur pour qui % annonce une variable, et ces apostrophes et parenthèses encodées devenaient des variables invalides. Les tests, eux, n’avaient jamais lu le fichier comme fail2ban le lit.

Puis, une fois ce premier problème corrigé, il a démarré… et n’a banni personne. Deux échecs en production sur le même livrable, avec des tests verts à chaque fois. L’histoire est banale, la leçon l’est beaucoup moins.

Disclaimers Les lignes de log, noms de fichiers et regex ci-dessous sont des exemples illustratifs, simplifiés pour l’article. Ils reproduisent le mécanisme du problème, pas la configuration exacte du serveur concerné.

Ce que fait réellement fail2ban avec un filtre

Avant de regarder les tests, il faut regarder le chemin réel d’un filtre, du fichier .conf jusqu’au bannissement. C’est exactement l’étape que ni l’agent ni moi n’avons faite au départ.

flowchart TD A[filter.d/web-sqli.conf] --> B[Lecture via configparser] B --> C[Ligne de log] C --> D[datepattern : retrait de la date] D --> E[failregex sur le reste] E --> F[Ban]

Deux étapes intermédiaires comptent ici :

  • La lecture du fichier : fail2ban ne lit pas ses .conf en brut, il passe par un parseur dérivé de configparser (Python), avec interpolation. C’est ce qui permet d’écrire %(__prefix_line)s dans un filtre.
  • Le retrait de la date : avant d’appliquer failregex, fail2ban cherche la date dans la ligne (via datepattern ou son détecteur par défaut) et retire la portion trouvée. La regex s’applique à ce qui reste, pas à la ligne entière.

Gardez ces deux étapes en tête, chaque échec correspond à l’une d’elles.

Échec n°1 : le % qui empêche fail2ban de démarrer

Le premier test écrit par l’agent était simple et propre : on prend la regex, on remplace <HOST>, on la compile avec re.compile() et on la confronte à des lignes malveillantes et légitimes.

import re

FAILREGEX = r'^<HOST> "(?:GET|POST) [^"]*(?:%27|%28|union%20select)'
HOST = r'(?P<host>\S+)'

regex = re.compile(FAILREGEX.replace('<HOST>', HOST))

assert regex.search('203.0.113.42 "GET /search?q=1%27%20OR%201=1 HTTP/1.1" 404')
assert not regex.search('198.51.100.7 "GET /blog/ HTTP/1.1" 200')

Tout semble passer, sauf qu’en production, au redémarrage :

'%' must be followed by '%' or '(', found: '%27|%28|union%20select)'

Pour configparser, % est un métacaractère d’interpolation. %27 n’est pas “ une apostrophe encodée en URL “, c’est une interpolation invalide. Dans un fichier lu par fail2ban, un % littéral doit être doublé :

[Definition]
failregex = ^<HOST> "(?:GET|POST) [^"]*(?:%%27|%%28|union%%20select)

Le test n’avait aucune chance de voir ce problème : il compilait la regex telle qu’écrite dans le code Python, alors que fail2ban la lit après passage dans configparser. Il validait l’étape finale en sautant celle qui casse.

Corriger le test, pas seulement le filtre

La correction naturelle : charger le fichier comme fail2ban le fait, puis compiler.

import configparser
import re

cp = configparser.ConfigParser() # BasicInterpolation par défaut, comme fail2ban
cp.read('filter.d/web-sqli.conf')
failregex = cp.get('Definition', 'failregex')

regex = re.compile(failregex.replace('<HOST>', r'(?P<host>\S+)'))

Et surtout, vérifier ce test dans les deux sens : il doit passer avec le filtre corrigé (%%27), mais aussi échouer avec l’ancienne version (%27). Un test qui accepte la bonne version sans rejeter la mauvaise ne prouve rien.

Échec n°2 : actif, mais ne bannit personne

Deuxième déploiement. Le service démarre, aucune erreur. Le lendemain : aucun bannissement, alors que les logs sont pleins de scans.

fail2ban-regex, l’outil livré avec fail2ban pour tester un filtre contre un fichier de log, est formel :

Lines: 1500 lines, 0 ignored, 0 matched, 1500 missed

Les chiffres sont illustratifs, le 0 matched ne l’est pas. Un échec silencieux : pas de crash, pas d’alerte, juste un service qui tourne pour rien.

Le test v2 avait corrigé l’étape configparser… mais appliquait toujours la regex à la ligne entière. Or fail2ban retire d’abord la date. Prenons une ligne de log au format personnalisé, qui commence par la date :

[12/Mar/2026:10:15:32 +0100] 203.0.113.42 "GET /search?q=1%27%20OR%201=1 HTTP/1.1" 404

Le filtre déclarait un datepattern censé capturer la date avec ses crochets :

[Definition]
datepattern = \[({DATE})\]

La réponse était déjà dans la sortie de l’outil

fail2ban-regex affiche, dans sa section Date template hits, le modèle de date réellement utilisé. Ce n’était pas le mien : c’était le détecteur par défaut (un modèle du type Day/MON/ExYear:24hour:Minute:Second Zone offset, sans crochets).

Conséquence : fail2ban retirait la date, mais laissait les crochets. La ligne réellement soumise à failregex ressemblait à ceci :

[] 203.0.113.42 "GET /search?q=1%27%20OR%201=1 HTTP/1.1" 404

Et une regex ancrée sur ^<HOST> ne pouvait pas matcher une ligne qui commence par [].

L’outil me le disait depuis trois itérations. J’ai redéployé trois fois en cherchant la cause ailleurs : dans le fichier de config, dans le format du log, dans l’encodage. Ni l’agent ni moi n’avions lu cette ligne de sortie.

Le correctif : écrire un filtre qui marche dans les deux cas

Plutôt que de parier sur l’application ou non du datepattern (qui peut dépendre de la version de fail2ban ou de l’environnement), le filtre final tolère les deux situations avec un préfixe optionnel :

[Definition]
failregex = ^(?:\[[^\]]*\]\s*)?<HOST> "(?:GET|POST) [^"]*(?:%%27|%%28|union%%20select)

(?:\[[^\]]*\]\s*)? accepte un bloc entre crochets en début de ligne, vide ou non, ou son absence. Que la date soit retirée avec ou sans ses crochets, la regex matche.

Et la validation ne passe plus par un script maison, mais par l’outil qui fait foi :

fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/web-sqli.conf

fail2ban-regex charge le filtre avec le vrai parseur, applique le vrai détecteur de date, puis la vraie regex. C’est la chaîne complète, sans simulation.

Ce que chaque test vérifiait vraiment

Étape réelle Test v1 (re.compile) Test v2 (configparser) fail2ban-regex
Lecture du .conf (interpolation %) non oui oui
Retrait de la date (datepattern) non non oui
Application de failregex oui oui oui

Chaque test était correct pour ce qu’il testait. Le problème, c’est ce qu’il ne testait pas.

Pourquoi c’est un piège typique avec un agent IA

Un agent de code écrit vite des tests plausibles. Il teste ce qu’on lui montre : une regex, une fonction, un fichier. Il ne va pas, de lui-même, se demander par quelles couches cette regex passe avant d’être exécutée en production. Et un test vert généré en quelques secondes donne une confiance disproportionnée.

Ce n’est pas propre à fail2ban. Le même schéma se retrouve partout où un fichier traverse une couche de parsing avant d’être consommé :

  • un .env lu par Docker Compose, qui interpole $ ;
  • un YAML de CI, où certains caractères changent le type d’une valeur ;
  • une config nginx, où les variables et les regex ont leur propre syntaxe ;
  • un template Twig ou Blade, qui échappe à sa façon.

Les règles que j’en ai tirées

J’ai fini par écrire ces règles noir sur blanc dans le fichier d’instructions que mon agent lit à chaque session (ce sera le sujet du prochain article) :

  • Énumérer les étapes du pipeline réel avant d’écrire le test, et pour chacune se demander : « est-ce que je la simule ? »
  • Un fichier de config n’est presque jamais lu brut : reproduire la chaîne de chargement réelle, pas l’étape finale.
  • Vérifier un test dans les deux sens : il doit rejeter la version buggée, pas seulement accepter la version corrigée.
  • Lire la sortie intermédiaire de l’outil quand il l’affiche, avant de chercher la cause ailleurs.
  • Quand un comportement dépend d’un environnement qu’on ne contrôle pas, écrire un livrable qui fonctionne dans tous les cas, et valider chaque cas.
  • Préférer l’outil officiel de validation (fail2ban-regex, nginx -t, docker compose config…) à un script de test maison.

L’IA n’a pas « menti ». Elle a fait exactement ce qu’on lui demandait : écrire un test qui passe. Savoir ce que ce test prouve réellement reste notre travail.