sed : le cycle et la substitution
Pourquoi
Le jeudi 8 octobre, le prestataire qui maintient le code de l'API Signalements demande les journaux de l'incident de la veille : « tout ce que sig-app-2 a écrit entre 14 h et 14 h 20, texte et JSON ». La demande est légitime, et la tentation est de répondre en trente secondes : un scp du fichier entier, ou un copier-coller depuis less.
Les deux réponses posent problème. Le fichier entier couvre quatre jours et plus de huit mille lignes, dont l'immense majorité n'a rien à voir avec l'incident. Et surtout, ces journaux contiennent des adresses IP d'usagers : celles des habitants qui ont envoyé une photo de nid-de-poule depuis leur téléphone (dans le journal JSON), celles des machines qui ont tenté de se connecter en SSH (dans le journal texte). Une adresse IP est une donnée personnelle au sens du RGPD, et le prestataire est un sous-traitant : lui transmettre plus que nécessaire contredit le principe de minimisation des données.
Il faut donc trois opérations, toutes sur du texte et toutes répétables : extraire une fenêtre de temps, normaliser un format de date incohérent entre les deux journaux pour que le prestataire puisse les croiser, et réduire les adresses IP publiques à une forme moins identifiante, sans toucher aux adresses internes qui servent au diagnostic. C'est exactement le travail de sed, l'éditeur de flux d'Unix : une commande qui lit du texte ligne par ligne, lui applique un petit programme et écrit le résultat, sans jamais ouvrir d'éditeur.
sed a mauvaise réputation, parce qu'on le rencontre d'abord sous la forme de lignes cryptiques recopiées depuis un forum. Elles cessent de l'être dès que l'on comprend le cycle que sed exécute pour chaque ligne. Cette leçon part de ce cycle, construit la commande s (substitution) et les adresses, puis les assemble dans un script d'anonymisation rangé dans le dépôt signalements-outils, testé, et protégé contre les commandes dangereuses. L'édition de fichiers sur place (sed -i), l'espace de réserve et le travail sur plusieurs lignes viendront à la leçon 4.
Les concepts
Un éditeur de flux
sed (stream editor) descend de ed, l'éditeur ligne à ligne des premiers Unix : il en reprend les commandes (s, p, d, q...) et la façon de désigner des lignes, mais au lieu d'agir sur un fichier ouvert de façon interactive, il agit sur un flux qui le traverse. Il se range dans la famille des filtres présentés à la leçon 1 : il lit des fichiers nommés, ou à défaut son entrée standard, et écrit sur sa sortie standard. Il ne modifie pas ses fichiers d'entrée (sauf avec -i, à la leçon suivante).
La ligne de commande a toujours la même forme :
sed [options] 'script' [fichier...]
sed [options] -e 'commande' -e 'commande' [fichier...]
sed [options] -f fichier-de-script [fichier...]Le script est un programme : une suite de commandes, chacune précédée éventuellement d'une adresse qui dit à quelles lignes elle s'applique. On le met presque toujours entre guillemets simples, pour que le shell n'y touche pas : $, \, * et ! y ont un sens pour sed, pas pour Bash.
Le cycle : lire, appliquer, afficher
Le manuel de GNU sed décrit le fonctionnement en une phrase dont tout le reste découle : sed maintient deux tampons, l'espace de travail (pattern space) et l'espace de réserve (hold space), tous deux vides au départ, et exécute le cycle suivant pour chaque ligne d'entrée :
- Lire une ligne, retirer son saut de ligne final, et la placer dans l'espace de travail.
- Appliquer les commandes du script, dans l'ordre, à l'espace de travail. Chaque commande n'est exécutée que si son adresse correspond à la ligne courante.
- À la fin du script, afficher l'espace de travail sur la sortie standard, suivi du saut de ligne retiré à l'étape 1, sauf si l'option
-nest active. - Vider l'espace de travail (pas l'espace de réserve) et recommencer avec la ligne suivante.
Trois conséquences, qui expliquent presque tous les comportements surprenants :
- Sans commande,
sedrecopie.sed '' fichieraffiche le fichier tel quel : l'affichage automatique de l'étape 3 suffit. paffiche en plus. La commandepimprime l'espace de travail au moment où elle s'exécute ; l'affichage automatique a lieu quand même à la fin du cycle.sed paffiche donc chaque ligne deux fois. Pour n'afficher que ce que l'on demande, on coupe l'affichage automatique par-net l'on demande explicitement parp:sed -n '/motif/p'est l'équivalent degrep motif.dinterrompt le cycle. La commandedvide l'espace de travail et passe immédiatement à la ligne suivante, sans exécuter le reste du script ni afficher quoi que ce soit. Les commandes placées après undqui s'applique ne sont jamais vues par cette ligne.
L'espace de réserve, lui, survit d'un cycle à l'autre : c'est la mémoire de sed, la seule. Il sert aux traitements sur plusieurs lignes, que la leçon 4 aborde. Cette leçon n'utilise que l'espace de travail.
La commande s
La substitution est la raison d'être de sed :
s/expression/remplacement/drapeauxsed cherche dans l'espace de travail la première portion de texte qui correspond à l'expression régulière, et la remplace par le texte de remplacement. Si rien ne correspond, l'espace de travail est inchangé et la commande ne fait rien (ce n'est pas une erreur).
Le délimiteur n'est pas forcément /. La norme POSIX précise que n'importe quel caractère autre que la barre oblique inverse et le saut de ligne peut le remplacer : le premier caractère qui suit s devient le délimiteur. Quand l'expression ou le remplacement contient des barres obliques (un chemin, une URL, une date au format 07/Oct/2026), on choisit |, # ou , plutôt que d'échapper chaque / :
sed 's/\/srv\/donnees\//\/data\//' # illisible
sed 's|/srv/donnees/|/data/|' # même commandeDans l'expression, le délimiteur choisi peut encore apparaître s'il est précédé d'une barre oblique inverse.
Les drapeaux de s
| Drapeau | Effet | Norme |
|---|---|---|
| (aucun) | remplace la première correspondance seulement | POSIX |
g | remplace toutes les correspondances de la ligne, sans chevauchement | POSIX |
N (un nombre) | remplace seulement la N-ième correspondance | POSIX |
Ng | remplace la N-ième et toutes les suivantes | GNU |
p | affiche l'espace de travail si une substitution a eu lieu | POSIX |
w fichier | écrit l'espace de travail dans un fichier si une substitution a eu lieu | POSIX |
I ou i | ignore la casse | GNU |
e | exécute l'espace de travail comme une commande et le remplace par sa sortie | GNU |
m ou M | mode multiligne pour ^ et $ | GNU |
Le couple -n et s///p est l'idiome d'extraction : « n'affiche que les lignes où la substitution a réussi, sous leur forme transformée ». C'est l'équivalent, en une commande, de grep motif | sed 's/.../.../'.
Le drapeau e mérite une mise en garde immédiate : il transforme une donnée en commande shell. On y revient dans la partie Sécurité.
Ce que le remplacement interprète
La partie remplacement n'est pas une expression régulière : . ou * y sont des caractères ordinaires. Elle n'interprète que quelques séquences, que la norme POSIX énumère :
&est remplacé par tout le texte qui a correspondu à l'expression ;\1à\9sont remplacés par le texte capturé par le premier, ..., neuvième groupe entre parenthèses de l'expression (une référence arrière) ; il n'y en a que neuf, et\10signifie « le groupe 1 suivi du caractère 0 » ;\&,\\et\suivi du délimiteur produisent le caractère littéral ;\suivi d'un vrai saut de ligne insère un saut de ligne.
GNU sed ajoute \n (saut de ligne), \t (tabulation), et des conversions de casse : \U (majuscules jusqu'à \E), \L (minuscules), \u et \l (le caractère suivant seulement). Ces extensions n'existent pas dans le sed de macOS, issu de FreeBSD : la norme dit que le sens de \ suivi d'un autre caractère que ceux de la liste est « non spécifié ».
Si un groupe n'a pas participé à la correspondance (une alternative non retenue), sa référence est remplacée par la chaîne vide.
BRE et ERE dans sed
Par défaut, sed lit les expressions en BRE (Basic Regular Expressions), comme grep sans option ; avec -E, il les lit en ERE (Extended Regular Expressions), comme grep -E. Les deux familles ont été présentées à la leçon 2. Le rappel utile ici :
| Construction | BRE (défaut) | ERE (-E) |
|---|---|---|
| groupe capturant | \(...\) | (...) |
| une ou plusieurs fois | \+ (GNU) | + |
| zéro ou une fois | \? (GNU) | ? |
| répétition bornée | \{2,4\} | {2,4} |
| alternative | | (GNU) | | |
parenthèse, +, ?, {, | littéraux | tels quels | \(, \+, \?, \{, | |
Dès qu'un script utilise des groupes, l'écriture ERE est nettement plus lisible : comparez s/\([0-9]*\)-\([0-9]*\)/\2-\1/ et s/([0-9]*)-([0-9]*)/\2-\1/. L'option -E est entrée dans la norme POSIX en 2024 ; GNU sed l'accepte depuis longtemps, sous ce nom et sous son ancien nom -r. Dans les scripts de ce cours, on écrit -E, que comprennent aussi les sed de FreeBSD et de macOS.
GNU sed comprend encore quelques classes abrégées hors norme, empruntées à Perl mais au sens plus restreint : \w (caractère de mot : lettre, chiffre ou _), \W, \s (blanc), \S, et les limites \b (frontière de mot), \< et \> (début et fin de mot). Elles rendent de grands services ; elles sont désactivées par l'option --posix, et absentes ailleurs. \d, en revanche, n'existe pas dans sed : écrivez [0-9].
Les adresses
Une commande sans adresse s'applique à toutes les lignes. Avec une adresse, seulement aux lignes qu'elle désigne :
| Adresse | Lignes désignées | Norme |
|---|---|---|
5 | la cinquième ligne de l'entrée | POSIX |
$ | la dernière ligne | POSIX |
/re/ | les lignes où l'expression re correspond | POSIX |
\%re% | idem, avec % (ou un autre caractère) comme délimiteur | POSIX |
/re/I | idem, sans tenir compte de la casse | GNU |
premier~pas | une ligne sur pas, à partir de premier (0~3 : 3, 6, 9...) | GNU |
Un ! placé après l'adresse inverse la sélection : /^#/!p affiche les lignes qui ne commencent pas par #.
Les numéros de ligne sont cumulés sur tous les fichiers d'entrée : sed -n '$=' a b compte les lignes des deux fichiers ensemble, et $ désigne la dernière ligne du dernier fichier. L'option GNU -s (separate) traite chaque fichier séparément ; elle est implicite avec -i.
Les intervalles
Deux adresses séparées par une virgule désignent un intervalle : a,b. Sa règle de fonctionnement est simple, et c'est elle qu'il faut avoir en tête :
- Tant que l'intervalle est inactif,
sedteste la première adresse sur chaque ligne. Quand elle correspond, l'intervalle devient actif, et cette ligne en fait partie. - Tant que l'intervalle est actif, chaque ligne en fait partie, et
sedteste la seconde adresse sur chaque ligne à partir de la suivante. Quand elle correspond, cette ligne fait encore partie de l'intervalle, puis il redevient inactif. - Il peut ensuite se rouvrir plus loin si la première adresse correspond de nouveau.
Trois corollaires :
- Si la seconde adresse ne correspond jamais, l'intervalle court jusqu'à la fin de l'entrée. Aucun message ne le signale.
- La seconde adresse n'est pas testée sur la ligne qui ouvre l'intervalle :
/debut/,/debut/couvre au moins deux lignes. GNU sed propose0,/re/pour un intervalle qui commence « avant » la première ligne et peut donc se fermer dès la ligne 1. - Si la seconde adresse est un numéro inférieur ou égal à la ligne d'ouverture, POSIX prévoit qu'une seule ligne est sélectionnée.
GNU sed ajoute deux formes relatives : a,+N (la ligne qui ouvre et les N suivantes) et a,~N (jusqu'à la prochaine ligne dont le numéro est un multiple de N).
Les intervalles conviennent aux fichiers structurés en blocs (une section de fichier INI, un paragraphe, l'en-tête d'un courriel) ; ils sont plus délicats sur un journal, où rien ne garantit que la ligne de fin attendue existe. On le verra en pratique.
Plusieurs commandes, blocs et scripts
Un script peut contenir plusieurs commandes, séparées par un saut de ligne ou par ;, ou données par plusieurs options -e (la norme précise qu'un saut de ligne est inséré entre deux -e). Les accolades groupent des commandes sous une même adresse :
sed -n '/^\[api\]/,/^\[/{ /^\[/!p }' app.confCe script affiche le contenu de la section [api] sans ses lignes d'en-tête de section : l'intervalle sélectionne de [api] à la section suivante, et à l'intérieur, /^\[/!p affiche les lignes qui ne commencent pas par un crochet.
Dès qu'un script dépasse une ligne, on le range dans un fichier et on le passe par -f. Le fichier accepte des commentaires (# en début de ligne), une commande par ligne, et n'a plus à se soucier des guillemets du shell. C'est ce que fera le script d'anonymisation.
Les autres commandes de base
| Commande | Effet |
|---|---|
p | affiche l'espace de travail |
d | supprime l'espace de travail et passe à la ligne suivante, sans affichage |
q | affiche l'espace de travail (sauf -n) puis quitte sans lire la suite |
Q | quitte sans rien afficher (GNU) |
= | affiche le numéro de la ligne courante, sur sa propre ligne |
y/abc/xyz/ | translittère caractère par caractère, comme tr |
w fichier | écrit l'espace de travail à la fin d'un fichier |
F | affiche le nom du fichier en cours (GNU) |
a, i, c | ajoute du texte après, avant, ou à la place de la ligne (leçon 4) |
r fichier | insère le contenu d'un fichier après la ligne (leçon 4) |
q a un intérêt pratique souvent négligé : sed arrête de lire. Sur un journal de plusieurs gigaoctets, sed -n '/motif/{p;q}' s'arrête à la première occurrence, comme grep -m1, alors que sed -n '/motif/p' lit le fichier jusqu'au bout.
En pratique
Les sorties ont été produites avec GNU sed 4.9 et LC_ALL=C.UTF-8, dans le bac à sable créé à la leçon 1 avec preparer-donnees ~/essais-texte. Placez-vous à sa racine, définissez deux raccourcis, et ajoutez au PATH le répertoire bin/ du dépôt signalements-outils du cours Bash, où l'on rangera l'outil de cette leçon :
cd ~/essais-texte
j1=journaux/sig-app-1.pn-signalements.internal
j2=journaux/sig-app-2.pn-signalements.internal
PATH=~/signalements-outils/bin:$PATHPremiers essais : recopier, afficher, supprimer
Le cycle en action, sur deux lignes :
$ printf 'un\ndeux\n' | sed p
un
un
deux
deux
$ printf 'un\ndeux\n' | sed -n '/deux/p'
deux
$ printf 'a a a\n' | sed 's/a/b/'
b a a
sed p: chaque ligne est affichée parp, puis par l'affichage automatique.sed -n '/deux/p': l'affichage automatique est coupé, seule la ligne qui correspond est imprimée.s/a/b/sansgne remplace que le premiera. Oublier legest probablement l'erreur desedla plus fréquente.
Compter les lignes avec la commande = sur la dernière ligne :
$ sed -n '$=' $j2/syslog.log
8183
$ sed -n '$=' journaux/*/syslog.log
16405
$ sed -s -n '$=' journaux/*/syslog.log
8222
8183
Sans -s, les deux fichiers forment un seul flux et $ désigne la fin du second. Avec -s, chaque fichier a sa propre dernière ligne. (wc -l ferait la même chose, plus vite : l'intérêt ici est de voir la numérotation cumulée.)
Le numéro d'une ligne précise, celle du redémarrage de l'API :
$ sed -n '/Started signalements/=' $j2/syslog.log
5350
La configuration sans ses commentaires
Le prestataire demande aussi la configuration en vigueur. config/app.conf contient des commentaires, dont une ancienne valeur désactivée (# taille_pool = 5) qui prêterait à confusion. Deux commandes d, une par sorte de ligne à écarter :
$ sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' config/app.conf
[base]
hote = sig-db.pn-signalements.internal
port = 5432
nom = signalements
utilisateur = signalements
taille_pool = 10
[api]
ecoute = 0.0.0.0:8000
travailleurs = 4
delai = 30
journal_json = oui
[stockage]
seau = sig-photos
region = fr-par
^[[:space:]]*#: un#précédé seulement de blancs, donc aussi un commentaire indenté. Un#au milieu d'une ligne (seau = a#b) n'est pas touché.^[[:space:]]*$: une ligne vide ou faite de blancs. Dans le script,$est entre guillemets simples, le shell n'y voit rien.- Chaque
dinterrompt le cycle : une ligne supprimée par la première commande n'est jamais testée par la seconde.
La section [api] seule :
$ sed -n '/^\[api\]/,/^\[/{ /^\[/!p }' config/app.conf
ecoute = 0.0.0.0:8000
travailleurs = 4
delai = 30
journal_json = oui
Les crochets sont échappés (\[), puisque [ ouvre une classe de caractères. La ligne vide qui précède [stockage] est conservée : ajoutez /^$/d dans le bloc si elle gêne.
Extraire la fenêtre de l'incident
L'incident a commencé à 14:02 et l'API a été redémarrée à 14:20. Un intervalle d'adresses semble fait pour cela :
$ sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:20/p' $j2/syslog.log | wc -l
151
$ sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:20/p' $j2/syslog.log | head -3
2026-10-07T14:02:07.370490+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:02:07] "GET /sante HTTP/1.1" 200 -
2026-10-07T14:02:16.408158+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:02:16] "GET /pieces-jointes/a0d723dfb0930aef.jpg HTTP/1.1" 503 -
2026-10-07T14:02:16.953007+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:02:16] "GET /signalements HTTP/1.1" 503 -
$ sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:20/p' $j2/syslog.log | tail -3
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 -
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 -
2026-10-07T14:20:00.000000+00:00 sig-app-2 systemd[1]: Stopping signalements.service - API Signalements...
Remarquez deux choses. D'abord, la vérification de santé /sante répond 200 au milieu des 503 : c'est la raison pour laquelle le répartiteur n'a jamais retiré sig-app-2 du pool, et un point que le rapport d'incident devra traiter. Ensuite, l'intervalle s'est fermé sur la première ligne de 14:20, celle de l'arrêt du service, qu'il inclut. C'est conforme à la règle, et ici c'est même utile. Mais regardez ce qui se passe si la borne de fin est un peu trop précise :
$ sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:61/p' $j2/syslog.log | wc -l
2987
$ sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:61/p' $j2/syslog.log | tail -1
2026-10-08T23:59:07.304267+00:00 sig-app-2 python3[2817]: 172.16.8.20 - - [08/Oct/2026 23:59:07] "GET /sante HTTP/1.1" 200 -
Aucune ligne ne commence par 14:61, évidemment. Mais une faute de frappe plus vraisemblable (14:2O avec la lettre O, ou une minute où le serveur n'a rien écrit) produit le même effet : l'intervalle ne se ferme jamais, et sed envoie silencieusement tout le reste du fichier, jusqu'au lendemain soir. Pour une transmission de données personnelles, c'est exactement l'erreur à ne pas commettre.
Warning
Un intervalle dont la borne de fin est une expression régulière n'est sûr que si l'on est certain qu'une ligne y correspondra. Sur un journal, préférez une expression qui décrit directement les lignes voulues.
Ici, « toutes les lignes dont l'horodatage est entre 14:02 et 14:19 » s'écrit sans intervalle, avec une alternative ERE :
$ sed -n -E '/^2026-10-07T14:(0[2-9]|1[0-9])/p' $j2/syslog.log | wc -l
150
Cent cinquante lignes : les mêmes, moins la ligne d'arrêt de 14:20:00. Ce filtre ne peut pas déborder, quelle que soit la suite du fichier. (La comparaison d'horodatages par expression régulière marche ici parce que le format est de largeur fixe et trié ; awk permettra à la leçon 5 des comparaisons de chaînes $1 >= "2026-10-07T14:02", plus souples.)
Si l'on tient à l'intervalle, on peut au moins arrêter la lecture à la borne de fin, ce qui borne aussi le coût sur un gros fichier :
sed -n '/^2026-10-07T14:02/,/^2026-10-07T14:20/{p; /^2026-10-07T14:20/q}' "$j2/syslog.log"La commande q dans le bloc quitte dès la ligne de fin rencontrée. Mais elle ne protège pas contre une borne absente : dans ce cas, sed lit et affiche quand même tout.
Extraire un champ avec s///p
Quels chemins ont reçu des réponses 404 sur sig-app-1 ? Chaque ligne de requête contient la ligne de requête HTTP entre guillemets, puis le code. L'idiome -n et s///p remplace toute la ligne par la partie capturée, et n'affiche que les lignes transformées :
$ sed -n -E 's/.*"[A-Z]+ ([^ ?]*)[^"]*" 404 -$/\1/p' $j1/syslog.log | sort | uniq -c | sort -rn | head -8
42 /signalements
11 /phpmyadmin/
11 /.git/config
9 /wp-login.php
9 /admin/config.php
8 /.env
1 /signalements/13457
1 /signalements/12842
Lisons l'expression de gauche à droite :
.*": tout ce qui précède le dernier guillemet ouvrant possible. Ici, c'est forcément celui de la ligne de requête, puisque la suite de l'expression exige une méthode en majuscules.[A-Z]+: la méthode (GET,POST) et son espace.([^ ?]*): le groupe 1, tous les caractères jusqu'à une espace ou un point d'interrogation, donc le chemin sans sa chaîne de requête.[^"]*": le reste de la ligne de requête (?commune=...,HTTP/1.1) jusqu'au guillemet fermant.404 -$: le code attendu, en fin de ligne. L'ancre$garantit qu'on ne prend pas un404qui traînerait dans un chemin.
Le résultat est un premier constat de sécurité : /.git/config, /.env, /wp-login.php et /phpmyadmin/ sont des sondes automatiques qui cherchent des fichiers oubliés ou des logiciels vulnérables. Aucune n'a abouti (404), mais elles disent que l'API est visible et balayée par internet. La leçon 2 les a déjà repérées avec grep -F -f ; sed les extrait ici en une passe.
Normaliser la date
Les deux journaux ne datent pas les événements de la même façon. rsyslog écrit en tête de ligne un horodatage RFC 3339 (2026-10-07T14:02:16.408158+00:00), mais le serveur HTTP de Python écrit dans le message sa propre date, au format [07/Oct/2026 14:02:16], et le journal JSON utilise 2026-10-07T14:02:16.408Z. Pour que le prestataire puisse rapprocher les lignes, on convertit la date de Python au format ISO 8601.
Prenons une ligne de 503, la première, avec q pour arrêter la lecture dès qu'on l'a :
$ l=$(sed -n '/ 503 -$/{p;q}' $j2/syslog.log)
$ echo "$l"
2026-10-07T14:02:16.408158+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [07/Oct/2026 14:02:16] "GET /pieces-jointes/a0d723dfb0930aef.jpg HTTP/1.1" 503 -
Première étape : réordonner jour, mois, année avec trois groupes, plus un quatrième pour l'heure.
$ echo "$l" | sed -E 's|\[([0-9]{2})/([A-Z][a-z]{2})/([0-9]{4}) ([0-9:]{8})\]|[\3-\2-\1T\4]|'
2026-10-07T14:02:16.408158+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [2026-Oct-07T14:02:16] "GET /pieces-jointes/a0d723dfb0930aef.jpg HTTP/1.1" 503 -
- Le délimiteur est
|, puisque la date contient des/. - Les crochets sont échappés dans l'expression (ce sont des caractères spéciaux), pas dans le remplacement (où ils sont ordinaires).
\3-\2-\1T\4: année, mois, jour, puis la lettreTet l'heure.
Deuxième étape : le mois en chiffres. sed n'a pas de table de correspondance ; on écrit une substitution par mois. Pour octobre :
$ echo "$l" | sed -E -e 's|\[([0-9]{2})/([A-Z][a-z]{2})/([0-9]{4}) ([0-9:]{8})\]|[\3-\2-\1T\4]|' -e 's/(\[[0-9]{4}-)Oct-/\110-/'
2026-10-07T14:02:16.408158+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [2026-10-07T14:02:16] "GET /pieces-jointes/a0d723dfb0930aef.jpg HTTP/1.1" 503 -
Le remplacement \110- se lit « groupe 1, puis 10- » : les références arrière n'ont qu'un chiffre. C'est commode ici, et piégeux quand on ne le sait pas. Le groupe (\[[0-9]{4}-) ancre la substitution juste après l'année entre crochets : un mot Oct ailleurs dans la ligne (dans un chemin, par exemple) n'est pas touché.
Le script complet, avec les douze mois, est l'objet de l'exercice 3.
Le piège de .* gourmand
Dans le journal JSON, on voudrait extraire le chemin de chaque requête. Première tentative :
$ l=$(head -1 $j1/api.jsonl)
$ echo "$l"
{"horodatage":"2026-10-05T00:05:02.071Z","niveau":"info","hote":"sig-app-1","requete":{"id":"30ba30ab4ba2","methode":"GET","chemin":"/pieces-jointes/3e774f449622bceb.jpg","statut":200,"duree_ms":24},"client":{"ip":"192.0.2.56","agent":"Mairie-Exempleville-Export/1.0"}}
$ echo "$l" | sed -E 's/.*"chemin":"(.*)".*/\1/'
/pieces-jointes/3e774f449622bceb.jpg","statut":200,"duree_ms":24},"client":{"ip":"192.0.2.56","agent":"Mairie-Exempleville-Export/1.0
Le groupe (.*) a capturé jusqu'à l'avant-dernier guillemet de la ligne. Les expressions POSIX cherchent la correspondance la plus à gauche, puis, à partir de là, la plus longue (la règle de la correspondance la plus longue à gauche, vue à la leçon 2) ; chaque .* prend autant qu'il peut tant que le reste de l'expression peut encore correspondre. Il n'existe pas de quantificateur « paresseux » (.*?) dans sed. La solution est de décrire ce que le champ ne peut pas contenir :
$ echo "$l" | sed -E 's/.*"chemin":"([^"]*)".*/\1/'
/pieces-jointes/3e774f449622bceb.jpg
[^"]* s'arrête au premier guillemet. Cela fonctionne tant que la valeur ne contient pas de guillemet échappé (\") : en JSON, c'est permis. Un message d'erreur qui cite une requête SQL en contiendrait. C'est la limite de fond de sed face au JSON, et la raison pour laquelle les leçons 7 et 8 utilisent jq, qui comprend le format au lieu de le deviner. Pour anonymiser une adresse IP, en revanche, on n'a pas besoin de comprendre la structure : une adresse a la même forme partout. C'est ce qui rend sed légitime pour la suite.
Anonymiser : la version naïve
Les adresses à traiter, dans les deux journaux :
$ grep -m1 -F 'Invalid user' $j1/syslog.log
2026-10-05T00:43:09.920500+00:00 sig-app-1 sshd[53753]: Invalid user ubuntu from 203.0.113.77 port 54603
et le "ip":"192.0.2.56" du journal JSON, les SRC= et DST= des lignes du pare-feu. Toutes les adresses de ce cours viennent des plages réservées à la documentation par la RFC 5737 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) ou de l'espace public de Scaleway (51.15.x.y) ; dans la réalité, ce seraient les adresses des box et des téléphones des habitants.
La règle choisie avec la personne déléguée à la protection des données : remplacer le dernier octet des adresses publiques par 0, et garder intactes les adresses privées, qui sont celles de nos propres machines (le répartiteur 172.16.8.20, la machine de déploiement 172.16.8.30) et dont le prestataire a besoin pour comprendre le chemin des requêtes.
Première idée, remplacer « un point suivi de chiffres suivi d'une espace » :
$ l=$(sed -n '/ 503 -$/{p;q}' $j2/syslog.log)
$ echo "$l" | sed -E 's/\.[0-9]+ /.0 /g'
2026-10-07T14:02:16.408158+00:00 sig-app-2 python3[790]: 172.16.8.0 - - [07/Oct/2026 14:02:16] "GET /pieces-jointes/a0d723dfb0930aef.jpg HTTP/1.1" 503 -
L'adresse du répartiteur a été tronquée, ce qu'on ne voulait pas, et l'expression ne trouverait pas une adresse suivie d'un guillemet ou d'une fin de ligne. Il faut décrire une adresse IPv4 entière, quatre nombres séparés par des points, et ne pas la confondre avec un morceau d'un nombre plus long.
Seconde idée : encadrer l'adresse par « début de ligne ou caractère qui n'est ni un chiffre ni un point ». Essayons sur deux adresses séparées par une espace :
$ echo "SRC=1.2.3.4 5.6.7.8" | sed -E 's/(^|[^0-9.])([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\.[0-9]{1,3}([^0-9.]|$)/\1\2.0\3/g'
SRC=1.2.3.0 5.6.7.8
La seconde adresse a échappé au traitement. Avec g, les correspondances successives ne se chevauchent pas : l'espace entre les deux adresses a été consommée par la fin de la première correspondance (le groupe 3), et la seconde ne trouve plus de caractère pour satisfaire son groupe 1. C'est un piège classique de s///g dès que l'expression mange ses délimiteurs.
La frontière de mot \b de GNU sed ne consomme aucun caractère, elle :
$ echo "SRC=1.2.3.4 5.6.7.8 v1.2.3.4.5" | sed -E 's/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\.[0-9]{1,3}\b/\1.0/g'
SRC=1.2.3.0 5.6.7.0 v1.2.3.4.0
Les deux adresses sont traitées. Le troisième mot montre la limite : v1.2.3.4.5 n'est pas une adresse, mais 2.3.4.5 en a la forme, et \b voit une frontière entre 1 et .. Pour un journal d'accès, ce faux positif sur un numéro de version à cinq composantes est sans gravité ; il doit être connu et documenté.
Reste à épargner les adresses privées. Les expressions régulières POSIX n'ont pas d'assertion négative (« une adresse qui ne commence pas par 172.16 »), et l'écrire par une classe complémentaire serait illisible. La technique classique de sed consiste à marquer d'abord ce qu'il faut protéger, à traiter le reste, puis à retirer la marque. Le meilleur marqueur est le saut de ligne : sed le retire de chaque ligne à la lecture, il ne peut donc pas déjà figurer dans l'espace de travail, et aucune expression ne le confondra avec une donnée.
Le script d'anonymisation
Dans le dépôt ~/signalements-outils, créez lib/anonymiser.sed :
# anonymiser.sed : remplace le dernier octet des adresses IPv4 publiques par 0.
# Les adresses privées (RFC 1918) et de bouclage sont conservées : ce sont
# celles de nos propres machines, utiles au prestataire pour lire le journal.
# Script pour GNU sed, à lancer avec -E (expressions étendues).
# 1. Protéger les adresses privées : leur premier point devient un saut de ligne,
# caractère qui ne peut pas figurer dans une ligne lue par sed.
s/\b(10|127)\.([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/\1\n\2/g
s/\b172\.(1[6-9]|2[0-9]|3[01])(\.[0-9]{1,3}\.[0-9]{1,3})\b/172\n\1\2/g
s/\b192\.168(\.[0-9]{1,3}\.[0-9]{1,3})\b/192\n168\1/g
# 2. Tronquer toutes les adresses qui restent, donc publiques.
s/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\.[0-9]{1,3}\b/\1.0/g
# 3. Rendre leurs points aux adresses privées.
s/\n/./gChaque étape, en détail :
- Étape 1. Les trois plages privées de la RFC 1918 sont
10.0.0.0/8,172.16.0.0/12(de172.16à172.31) et192.168.0.0/16; on y ajoute le bouclage127.0.0.0/8. La deuxième ligne est la plus subtile :1[6-9]|2[0-9]|3[01]couvre exactement les valeurs 16 à 31 du deuxième octet. Une fois son premier point remplacé par\n, une adresse comme172\n16.8.20ne contient plus que trois nombres séparés par des points (16.8.20), et l'étape 2 ne peut plus la reconnaître. - Étape 2. Toute adresse à quatre composantes encore présente est publique : on garde les trois premières (groupe 1) et l'on remplace la quatrième par
0. Legtraite toutes les adresses de la ligne, ce qui compte pour les lignes du pare-feu (SRC=etDST=). - Étape 3. Les sauts de ligne redeviennent des points. GNU sed accepte
\ndans l'expression comme dans le remplacement.
Le script ne vérifie pas que chaque nombre est inférieur à 256 : 999.1.2.3 serait tronqué comme une adresse. Pour un journal produit par nos propres logiciels, ce n'est pas un enjeu ; pour une donnée arbitraire, ce serait un faux positif de plus, sans danger.
Puis bin/anonymiser-journal, l'enveloppe Bash qui lance le script avec les bonnes options, dans le style des outils du cours Bash :
#!/usr/bin/env bash
# anonymiser-journal : tronque les adresses IPv4 publiques d'un journal (texte ou JSON Lines).
# Usage : anonymiser-journal [FICHIER...] (lit l'entrée standard sans fichier)
set -Eeuo pipefail
export LC_ALL=C.UTF-8
ici=$(dirname -- "$(readlink -f -- "$0")")
readonly script_sed=${ANONYMISER_SED:-$ici/../lib/anonymiser.sed}
if [[ ! -r $script_sed ]]; then
printf '%s : script sed illisible : %s\n' "${0##*/}" "$script_sed" >&2
exit 1
fi
# --sandbox : le script ne peut ni lancer de commande (e) ni lire ou écrire de fichier (r, w).
exec sed --sandbox -E -f "$script_sed" -- "$@"readlink -f "$0"retrouve le vrai chemin du script même s'il est appelé par un lien symbolique (depuis/usr/local/bin, par exemple), pour localiserlib/à côté debin/. La variableANONYMISER_SEDpermet de pointer un autre script, utile pour les tests.LC_ALL=C.UTF-8fixe le comportement des classes et du., comme on le verra dans Sous le capot.execremplace le processus Bash parsed: le code de sortie du script est celui desed, et aucun processus inutile ne reste en mémoire.--sépare les options des noms de fichiers : un fichier nommé-nne serait pas pris pour une option. Sans aucun nom de fichier,sedlit l'entrée standard, ce qui permet de l'utiliser dans un tube.--sandbox: la partie Sécurité y revient.
Rendez le fichier exécutable (chmod +x bin/anonymiser-journal) : grâce au PATH défini au début de la pratique, il s'appelle ensuite par son nom depuis le bac à sable.
Essayer le script
Sur une ligne de chaque sorte :
$ grep -m1 -F 'Invalid user' $j1/syslog.log | anonymiser-journal
2026-10-05T00:43:09.920500+00:00 sig-app-1 sshd[53753]: Invalid user ubuntu from 203.0.113.0 port 54603
$ grep -m1 -F 'UFW' $j1/syslog.log | anonymiser-journal
2026-10-05T00:41:56.000000+00:00 sig-app-1 kernel: [UFW BLOCK] IN=ens2 OUT= MAC=de:00:00:5a:1b:2c:de:00:00:5a:1b:01:08:00 SRC=192.0.2.0 DST=51.15.121.0 LEN=60 TOS=0x00 PREC=0x00 TTL=58 ID=45216 DF PROTO=TCP SPT=48037 DPT=6379 WINDOW=64240 RES=0x00 SYN URGP=0
$ grep -m1 -F 'Accepted' $j1/syslog.log | anonymiser-journal
2026-10-05T09:12:00.000000+00:00 sig-app-1 sshd[41200]: Accepted publickey for deploiement from 172.16.8.30 port 40112 ssh2: ED25519 SHA256:q3Vb0Exemple0Empreinte0Fictive0Kz8
$ head -1 $j1/api.jsonl | anonymiser-journal
{"horodatage":"2026-10-05T00:05:02.071Z","niveau":"info","hote":"sig-app-1","requete":{"id":"30ba30ab4ba2","methode":"GET","chemin":"/pieces-jointes/3e774f449622bceb.jpg","statut":200,"duree_ms":24},"client":{"ip":"192.0.2.0","agent":"Mairie-Exempleville-Export/1.0"}}
(Ces grep sont ceux de la leçon 2 : -m1 arrête à la première ligne trouvée, -F cherche une chaîne fixe.) L'adresse de l'attaquant SSH, celle de l'usager et celles du paquet bloqué sont tronquées ; celle de la machine de déploiement est intacte. Notez au passage que l'adresse de destination du paquet bloqué, 51.15.121.x, est l'adresse publique de notre propre serveur : la tronquer ne retire rien d'utile au prestataire, et le script n'a pas à en savoir davantage.
Les cas limites, sur les frontières des plages privées :
$ printf '%s\n' "10.1.2.3 172.15.0.9 172.16.0.9 172.31.255.1 172.32.0.1 192.168.1.20 192.169.1.20 8.8.8.8 127.0.0.1" | anonymiser-journal
10.1.2.3 172.15.0.0 172.16.0.9 172.31.255.1 172.32.0.0 192.168.1.20 192.169.1.0 8.8.8.0 127.0.0.1
172.15 et 172.32 sont hors de 172.16.0.0/12 et sont bien traitées comme publiques ; 172.16 et 172.31, ses deux bornes, sont conservées.
Sur les fichiers entiers, deux vérifications. Le nombre de lignes ne doit pas changer (une substitution ne crée ni ne supprime de ligne, sauf si elle insère un saut de ligne) :
$ anonymiser-journal $j1/syslog.log | wc -l
8222
$ wc -l < $j1/syslog.log
8222
Et il ne doit plus rester une seule adresse publique complète. On liste toutes les adresses du résultat, avec leur nombre d'occurrences :
$ anonymiser-journal $j1/syslog.log | grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' | sort | uniq -c | sort -rn | head -6
7880 172.16.8.20
164 203.0.113.0
54 198.51.100.0
24 192.0.2.0
4 172.16.8.30
2 51.15.8.0
Les adresses privées sont intactes, toutes les autres se terminent par .0. Pour le journal JSON, la structure doit rester valide : jq, présenté à la leçon 7, relit chaque ligne et échoue au moindre défaut.
$ anonymiser-journal $j1/api.jsonl | jq -c . > /dev/null && echo "JSON valide"
JSON valide
Enfin, le comportement en cas d'erreur : un fichier absent donne un message de sed et un code non nul, que l'appelant voit.
$ anonymiser-journal /nexistepas; echo "code : $?"
sed: can't read /nexistepas: No such file or directory
code : 2
GNU sed renvoie 2 quand un fichier d'entrée n'a pas pu être lu (il traite quand même les autres), 1 pour une commande invalide, 4 pour une erreur d'entrée-sortie pendant le traitement.
Préparer l'envoi au prestataire
Tout assemblé, l'extraction de la fenêtre, puis l'anonymisation, pour les deux journaux de sig-app-2 :
mkdir -p envoi-prestataire
sed -n -E '/^2026-10-07T14:(0[2-9]|1[0-9])/p' "$j2/syslog.log" |
anonymiser-journal > envoi-prestataire/sig-app-2-syslog-incident.log
sed -n -E '/^\{"horodatage":"2026-10-07T14:(0[2-9]|1[0-9])/p' "$j2/api.jsonl" |
anonymiser-journal > envoi-prestataire/sig-app-2-api-incident.jsonlDans le journal JSON, l'horodatage est le premier champ de chaque objet, d'où l'ancre ^\{"horodatage":". C'est une hypothèse sur l'ordre des clés, que la leçon 7 remplacera par une vraie sélection jq sur le champ, indépendante de l'ordre. On a tenu à faire l'extraction avant l'anonymisation : le second traitement porte sur 150 lignes au lieu de 8 000, et rien de ce qui est hors de la fenêtre ne passe par un fichier intermédiaire.
Une valeur venue d'une variable dans le remplacement
Dernier besoin de la journée : préparer, pour le prestataire, une copie de la configuration où le nom du seau d'Object Storage est remplacé par celui d'un seau de recette. Le nom vient d'une variable. La version évidente :
$ nouveau='sig-photos&archives'
$ sed "s/^seau = .*/seau = $nouveau/" config/app.conf | grep '^seau'
seau = sig-photosseau = sig-photosarchives
Le & du nom a été interprété : il a été remplacé par le texte trouvé, seau = sig-photos. Avec une barre oblique, c'est la commande elle-même qui casse :
$ nouveau='sig/photos'
$ sed "s/^seau = .*/seau = $nouveau/" config/app.conf
sed: -e expression #1, char 26: unknown option to `s'
Le / du nom a terminé le remplacement, et photos/ a été lu comme des drapeaux. Et une valeur contenant \1 produit invalid reference \1 on `s' command's RHS. Une donnée devient une partie du programme : c'est la même famille de défauts que l'injection SQL, en plus modeste.
Toute valeur insérée dans le remplacement doit donc être protégée. La norme POSIX dit quels caractères y sont spéciaux : la barre oblique inverse, &, le délimiteur, et le saut de ligne. On les fait précéder d'une barre oblique inverse, en commençant par la barre oblique inverse elle-même, sinon on doublerait celles que l'on vient d'ajouter :
# Protège une chaîne pour la partie remplacement d'une commande s|...|...| de GNU sed.
echapper_remplacement() {
local v=$1
v=${v//\\/\\\\} # la barre oblique inverse d'abord
v=${v//&/\\&} # & : tout le texte trouvé
v=${v//|/\\|} # le délimiteur choisi
v=${v//$'\n'/\\$'\n'} # un saut de ligne doit être précédé d'une barre oblique inverse
printf '%s' "$v"
}Les substitutions de motifs de Bash (${v//motif/remplacement}, leçon 3 du cours Bash) font le travail sans lancer de processus. Essai sur cinq valeurs hostiles :
$ for nouveau in 'sig-photos&archives' 'sig/photos' 'a\1b' $'deux\nlignes' 'x|y'; do
> sed "s|^seau = .*|seau = $(echapper_remplacement "$nouveau")|" config/app.conf |
> sed -n '/^seau/,/^region/p' | head -n -1
> done
seau = sig-photos&archives
seau = sig/photos
seau = a\1b
seau = deux
lignes
seau = x|y
Chaque valeur arrive intacte. (Le second sed de la boucle affiche les lignes de seau à region, et head -n -1 retire la ligne region : on voit ainsi la valeur sur une ou deux lignes ; le saut de ligne de la quatrième valeur a bien été inséré, comme demandé.) Deux limites à garder en tête : la substitution de commande $(...) retire les sauts de ligne finaux d'une valeur, et cette fonction protège le remplacement, pas l'expression de gauche, où la liste des caractères spéciaux est bien plus longue (., *, [, ^, $, et en ERE + ? ( ) { } |). Pour chercher une chaîne fournie par un tiers, sed est le mauvais outil : grep -F ou une comparaison de chaînes dans awk (index($0, chaine)) ne l'interprètent pas. La leçon 4 en tirera les conséquences pour modifier des fichiers de configuration.
Sous le capot
Le cycle, observé avec --debug
Depuis la version 4.6, GNU sed sait montrer son cycle. L'option --debug affiche le programme sous une forme canonique, puis, pour chaque ligne, l'espace de travail, chaque commande exécutée et le contenu des registres de l'expression :
$ printf 'a1\nb2\n' | sed --debug -n 's/[0-9]/<&>/p'
SED PROGRAM:
s/[0-9]/<&>/p
INPUT: 'STDIN' line 1
PATTERN: a1
COMMAND: s/[0-9]/<&>/p
MATCHED REGEX REGISTERS
regex[0] = 1-2 '1'
a<1>
PATTERN: a<1>
END-OF-CYCLE:
INPUT: 'STDIN' line 2
PATTERN: b2
COMMAND: s/[0-9]/<&>/p
MATCHED REGEX REGISTERS
regex[0] = 1-2 '2'
b<2>
PATTERN: b<2>
END-OF-CYCLE:
On y lit exactement les étapes décrites plus haut : la lecture (INPUT, PATTERN: a1), l'exécution de la commande, la correspondance trouvée entre les positions 1 et 2 (regex[0] désigne la correspondance entière, regex[1] et suivants les groupes), l'affichage demandé par p (a<1>), puis la fin du cycle, où l'affichage automatique n'a pas lieu à cause de -n. Sur un script qui ne fait pas ce que l'on croit, --debug est le premier réflexe, avant d'ajouter des p partout.
La ligne et son saut de ligne
À l'étape 1 du cycle, sed retire le saut de ligne ; à l'étape 3, il le remet. Deux détails de GNU sed en découlent :
$ printf 'a\nb' | sed 's/b/B/' | od -c | head -2
0000000 a \n B
0000003
Si la dernière ligne de l'entrée n'a pas de saut de ligne final, GNU sed n'en ajoute pas : le fichier ressort avec le même défaut. C'est un choix délibéré (ne pas modifier ce qu'on ne demande pas de modifier) ; d'autres implémentations ajoutent le saut de ligne.
Les fins de ligne Windows (CRLF, glossaire) ne sont pas retirées : le \r reste à la fin de l'espace de travail, et une expression ancrée par $ ne trouve plus rien.
$ printf 'cle = 1\r\n' | sed -n '/= 1$/p' | wc -l
0
$ printf 'cle = 1\r\n' | sed 's/\r$//' | sed -n '/= 1$/p' | od -c | head -1
0000000 c l e = 1 \n
s/\r$// est l'idiome de nettoyage, à placer en tête de tout script qui lit des fichiers venus d'ailleurs. (\r dans une expression est une extension GNU ; ailleurs, on écrit le caractère lui-même avec $'\r' dans Bash.)
Avec -z (GNU, depuis 4.2.2), le séparateur d'enregistrements n'est plus le saut de ligne mais l'octet nul, comme pour find -print0 (leçon 5 du cours Bash) :
$ printf 'a\0b\0' | sed -z 's/^/<>/' | tr '\0' '\n'
<>a
<>b
C'est ainsi que l'on traite avec sed une liste de noms de fichiers quelconques. C'est aussi, en passant, une façon de lire un fichier texte entier comme un seul enregistrement (s'il ne contient pas d'octet nul), et donc de faire des substitutions qui traversent les lignes ; la leçon 4 présente la méthode propre, avec N.
Pourquoi le saut de ligne est un marqueur sûr
Le script d'anonymisation repose sur une propriété du cycle : une ligne lue par sed ne contient jamais de saut de ligne, puisque c'est le saut de ligne qui la termine. Tout \n présent dans l'espace de travail y a donc été mis par le script lui-même. Un autre caractère (@, ~, un caractère de contrôle) pourrait déjà figurer dans les données, et l'étape 3 le transformerait à tort en point. Ce raisonnement cesse d'être vrai avec -z, ou après une commande N qui ajoute la ligne suivante à l'espace de travail : dans ces cas, le marqueur doit changer.
Le moteur d'expressions et la locale
GNU sed et GNU grep s'appuient sur le même code d'expressions régulières, celui de gnulib, la bibliothèque de code partagée des projets GNU (gawk en embarque aussi une copie) : les mêmes expressions s'y comportent de la même façon, ce qui permet de mettre au point un motif avec grep -E avant de l'utiliser dans sed -E. Comme pour grep (leçon 2), ce moteur travaille sur des caractères, et ce qu'est un caractère dépend de la locale :
$ echo "Témoin" | sed 's/T.m/X/'
Xoin
$ echo "Témoin" | LC_ALL=C sed 's/T.m/X/'
Témoin
$ echo "Bourg-Témoin" | sed 'y/é/e/'
Bourg-Temoin
$ echo "Bourg-Témoin" | LC_ALL=C sed 'y/é/e/'
sed: -e expression #1, char 7: strings for `y' command are different lengths
Sous C.UTF-8, é est un caractère, que . reconnaît, et y accepte de le translittérer. Sous C, é est deux octets : . n'en reconnaît qu'un, et y refuse deux chaînes de longueurs différentes (deux octets contre un). Le nom de la commune Bourg-Témoin traverse les exports de Signalements : fixer LC_ALL=C.UTF-8 dans les scripts, comme le fait anonymiser-journal, donne un comportement prévisible quelle que soit la locale de la personne ou de la tâche planifiée qui les lance.
Quand sed s'arrête de lire
q et Q ne se contentent pas d'arrêter l'affichage : sed cesse de lire son entrée et se termine. Si sed est alimenté par un tube, le programme qui écrit dedans recevra SIGPIPE à sa prochaine écriture, comme avec head (leçon 1) ; sous pipefail, le tube peut alors se terminer avec le code 141, qui n'est pas une vraie erreur. q accepte aussi un code de sortie (q5, extension GNU) : sed -n '/ 503 -$/{p;q1}' se termine par 1 si une ligne a été trouvée, 0 sinon.
$ seq 5 | sed 3q
1
2
3
$ seq 5 | sed 3Q
1
2
3q affiche la troisième ligne avant de quitter (affichage automatique), 3Q la jette.
Pièges courants
s/a/b/ sans g. Seule la première correspondance de chaque ligne est remplacée. Ajoutez g, ou un numéro pour viser une correspondance précise.
sed -n sans p, ou p sans -n. Le premier n'affiche rien, le second affiche tout en double. Pour extraire : -n et p (ou s///p).
Les guillemets doubles autour du script. Le shell développe alors $, `, \ et ! (dans un terminal interactif) avant que sed ne voie le script : "s/fin$/X/" fonctionne par chance, "$p" est une variable. Guillemets simples par défaut ; doubles seulement pour insérer une variable, protégée comme on l'a vu.
.* gourmand. Il s'étend jusqu'à la dernière correspondance possible. Décrivez ce que le champ ne contient pas : [^"]*, [^ ]*, [^,]*.
Les groupes en BRE. s/(a)/\1/ sans -E cherche des parenthèses littérales, puis échoue sur \1 : invalid reference \1 on `s' command's RHS. Ajoutez -E ou écrivez \(a\).
\d, \w et \b hors de GNU. \d n'existe dans aucun sed. \w, \b, \+, \?, \| en BRE sont des extensions GNU, ignorées par --posix et absentes de macOS.
\10 dans le remplacement. C'est le groupe 1 suivi de 0. Il n'y a pas plus de neuf groupes.
L'intervalle qui ne se ferme pas. Une borne de fin qui n'apparaît jamais envoie tout le reste du fichier. Préférez une expression qui décrit les lignes voulues, ou vérifiez le nombre de lignes produites.
L'intervalle qui se ferme trop tard. La borne de fin n'est pas testée sur la ligne d'ouverture : /BEGIN/,/END/ sur une ligne qui contient les deux continue jusqu'au END suivant.
Le & et le / venus d'une variable. Une valeur insérée dans le remplacement doit être protégée (\, &, délimiteur, saut de ligne), dans cet ordre.
s///g qui mange ses délimiteurs. Une expression qui consomme le caractère qui précède ou suit ce qu'elle cherche empêche la correspondance suivante de le réutiliser. Utilisez des frontières qui ne consomment rien (\b), ou plusieurs passages.
Les \r invisibles. Un fichier aux fins de ligne Windows fait échouer toutes les ancres $. cat -A les montre (^M), s/\r$// les retire.
Les messages d'erreur. unknown option to `s' signale presque toujours un délimiteur présent dans l'expression ou le remplacement ; unterminated `s' command, un délimiteur manquant ou un saut de ligne non protégé ; invalid reference \N, un groupe qui n'existe pas. GNU sed 4.10, sorti en avril 2026, remplace l'accent grave de ces messages par une apostrophe sous la locale C : un script qui analyse les messages de sed (ce qu'il vaut mieux éviter) cassera au changement de version.
Sécurité
Les commandes qui sortent du flux. Trois éléments du langage de GNU sed agissent hors de l'espace de travail :
w(et le drapeauwdes) écrit dans un fichier,rlit un fichier, ete(commande ou drapeau des) exécute une commande shell. Le drapeaueexécute le contenu de l'espace de travail :$ printf 'uname -s\n' | sed 's/.*/&/e' LinuxSi un script sed est construit à partir de données (un remplacement non protégé, un motif reçu en argument), un tiers peut y glisser
/eouw /chemin: ce qui ne devait être qu'une substitution devient une exécution de commande ou une écriture de fichier, avec les droits du compte qui lancesed. Depuis la version 4.3, l'option--sandboxrefuse ces trois commandes à la compilation du script :$ printf 'uname -s\n' | sed --sandbox 's/.*/&/e'; echo "code : $?" sed: -e expression #1, char 8: e/r/w commands disabled in sandbox mode code : 1anonymiser-journaln'a besoin d'aucune d'elles : il les interdit. Mettez--sandboxdans tout script qui n'écrit que sur sa sortie standard ; c'est une ligne de défense qui ne coûte rien.Troncature n'est pas anonymisation. La CNIL définit l'anonymisation comme un traitement qui rend impossible en pratique toute identification de la personne, et de manière irréversible. L'avis 05/2014 du groupe de travail « Article 29 » (prédécesseur du Comité européen de la protection des données) décrit la généralisation, qui réduit la précision d'un attribut (une région plutôt qu'une ville, un mois plutôt qu'une semaine) : tronquer une adresse en relève, et l'avis prévient que la généralisation seule ne suffit pas toujours, en particulier contre le recoupement. Une adresse réduite à son
/24désigne encore 256 adresses, parfois un seul abonné professionnel ; combinée à l'horodatage à la microseconde, à l'agent utilisateur et au chemin demandé, elle peut suffire à isoler une personne. Depuis l'arrêt Breyer de la Cour de justice de l'Union européenne (2016), une adresse IP, même dynamique, est une donnée personnelle pour celui qui dispose de moyens légaux raisonnables d'identifier l'abonné (en s'adressant, en cas d'attaque, à l'autorité qui peut obtenir l'information du fournisseur d'accès). Conséquence pratique : les fichiers produits paranonymiser-journalrestent des données personnelles, moins identifiantes. Ce n'est ni une anonymisation, ni même une pseudonymisation au sens du RGPD (qui remplace un identifiant par un alias), mais une généralisation. Leur envoi au prestataire reste encadré par le contrat de sous-traitance et leur conservation doit être limitée. Le nom du script est un raccourci de langage ; sa documentation doit le dire. La leçon d'agrégation (leçon 6) montre l'étape suivante : ne transmettre que des comptes, plus aucune ligne.Ce que le script ne voit pas. Il ne traite que l'IPv4. Une adresse IPv6, un nom d'hôte résolu (
box-12-34.fournisseur.fr), un identifiant d'appareil dans l'agent utilisateur, un nom de compte saisi par erreur dans le champ utilisateur de SSH (Invalid user marie.dupont) passent tels quels. Relisez un échantillon du résultat avant tout envoi, et listez dans le registre des traitements ce qui est transmis.Minimiser avant de transformer. L'extraction de la fenêtre de l'incident précède l'anonymisation : moins de lignes, donc moins de données transmises et moins de chances qu'un cas non prévu passe. Ne créez pas de fichier intermédiaire non anonymisé hors du serveur.
L'injection dans le remplacement. Une variable non protégée dans un script sed peut casser la commande (
unknown option), produire un résultat faux (&) ou, sans--sandbox, ajouter des commandes. Protégez les valeurs, et préférez--sandboxpartout où c'est possible.Les journaux contiennent des secrets. Un jeton passé en paramètre d'URL (
?jeton=...), un en-tête d'autorisation recopié dans un message d'erreur : la même technique de substitution sert à les masquer (s/(jeton=)[^& "]*/\1***/g), avec la même règle de vérification sur tout le fichier.
En production
- Des scripts sed dans des fichiers, versionnés et testés. Un
sed -E -f lib/anonymiser.seddans un dépôt se relit, se commente et se teste ; une ligne de 300 caractères dans une crontab, non. Le test de l'exercice 5 tourne en intégration continue avec les tests Bats du cours Bash. - GNU sed n'est pas partout. Les images de conteneurs Alpine fournissent le
sedde BusyBox, macOS celui de FreeBSD.-E,s///g, les adresses et-nsont portables ;\b,\ndans le remplacement,I,Q,0,/re/,first~step,--sandbox,--debuget-zne le sont pas. Si un script doit tourner hors de Debian et d'Ubuntu, testez-le avecsed --posix(qui désactive une partie des extensions) et sur la cible réelle, ou installez GNU sed (gsedsous macOS avec Homebrew). - Fixer la locale.
LC_ALL=C.UTF-8dans le script, ou dans l'unité systemd qui le lance : le comportement de., des classes et deyne doit pas dépendre de qui lance la tâche. - Mesurer avant de choisir.
sedest rapide : l'anonymisation des deux journaux desig-app-1(1,6 Mo) prend quelques dizaines de millisecondes sur un poste ordinaire. Sur des dizaines de gigaoctets de journaux, c'est l'écriture et la décompression qui coûtent ; pensez àzcat fichier.gz | sed ...sur les journaux tournés, plutôt qu'à les décompresser sur disque. - Savoir quand passer la main.
sedest excellent pour des transformations locales à une ligne : remplacer, extraire, supprimer, insérer. Dès qu'il faut compter, comparer des nombres, se souvenir de plusieurs valeurs ou comprendre une structure, passez àawk(leçon 5) ou àjqpour le JSON. Un script sed qui empile des étiquettes, des branchements et l'espace de réserve pour faire de l'arithmétique est une curiosité, pas un outil de production. - Tracer ce qui est transmis. Un envoi de journaux à un tiers est un événement à consigner : quels fichiers, quelle fenêtre, quel traitement, à qui, quand. Le plus simple est de produire, à côté des fichiers, un court fichier de description et l'empreinte
sha256sumde chaque fichier envoyé.
Exercices
1. Prévoir la sortie (niveau 100). Sans les exécuter, écrivez ce qu'affichent ces commandes sur l'entrée printf 'x\ny\n' ; puis vérifiez.
(a) sed p ; (b) sed -n p ; (c) sed d ; (d) sed -n '$p' ; (e) sed 's/x/&&/' ; (f) sed -n 's/z/Z/p' ; (g) sed '1d; s/./[&]/' ; (h) sed '1q'.
Solution
(a) x x y y, une par ligne : p plus l'affichage automatique. (b) x, y : l'affichage automatique est coupé, p affiche chaque ligne une fois. (c) Rien : chaque ligne est supprimée. (d) y, la dernière ligne seulement. (e) xx, puis y : & vaut le texte trouvé, écrit deux fois. (f) Rien : aucune substitution n'a lieu, donc p n'affiche rien, et -n coupe le reste. (g) [y] : la ligne 1 est supprimée, et d interrompt son cycle, si bien que la substitution ne s'applique qu'à la ligne 2. (h) x : q affiche la ligne 1 et quitte sans lire y.
2. Les chemins en erreur (niveau 100). Avec une seule commande sed suivie de sort et uniq, listez, pour sig-app-2, les chemins (sans chaîne de requête) qui ont reçu des réponses 404, avec leur nombre, du plus fréquent au moins fréquent. Puis adaptez la commande pour les réponses 503 et comparez.
Solution
sed -n -E 's/.*"[A-Z]+ ([^ ?]*)[^"]*" 404 -$/\1/p' "$j2/syslog.log" | sort | uniq -c | sort -rnPour les 503, remplacez 404 par 503. Les 404 se répartissent entre /signalements (52 sur le bac à sable, des recherches qui n'aboutissent pas), les sondes (/admin/config.php, /wp-login.php, /.env...) et quelques identifiants inexistants ; les 503 (85 sur /signalements, 10 sur des signalements précis, 6 sur des pièces jointes) suivent la répartition du trafic normal, sans aucune sonde (celles-ci reçoivent toujours un 404) : l'incident ne touchait pas une fonction particulière mais tout ce qui consulte la base. Les identifiants numériques des chemins /signalements/12345 éparpillent le décompte ; pour les regrouper, ajoutez une seconde substitution qui les remplace par un symbole : sed 's|/signalements/[0-9]*$|/signalements/ID|'.
3. Les douze mois (niveau 200). Écrivez lib/date-iso.sed, un script pour sed -E -f qui convertit toute date au format [07/Oct/2026 14:02:16] en [2026-10-07T14:02:16], pour les douze mois, sans toucher un mot Oct ailleurs dans la ligne. Les lignes sans date doivent passer inchangées et, si possible, sans exécuter les douze substitutions. Testez-le sur [31/Dec/2025 23:59:59], [01/Feb/2026 00:00:00] et une ligne sans date.
Solution
# date-iso.sed : [07/Oct/2026 14:02:16] devient [2026-10-07T14:02:16]
/\[[0-9]{2}\/[A-Z][a-z]{2}\/[0-9]{4} [0-9:]{8}\]/!b
s/\[([0-9]{2})\/([A-Z][a-z]{2})\/([0-9]{4}) ([0-9:]{8})\]/[\3-\2-\1T\4]/
s/(\[[0-9]{4}-)Jan-/\101-/; s/(\[[0-9]{4}-)Feb-/\102-/; s/(\[[0-9]{4}-)Mar-/\103-/
s/(\[[0-9]{4}-)Apr-/\104-/; s/(\[[0-9]{4}-)May-/\105-/; s/(\[[0-9]{4}-)Jun-/\106-/
s/(\[[0-9]{4}-)Jul-/\107-/; s/(\[[0-9]{4}-)Aug-/\108-/; s/(\[[0-9]{4}-)Sep-/\109-/
s/(\[[0-9]{4}-)Oct-/\110-/; s/(\[[0-9]{4}-)Nov-/\111-/; s/(\[[0-9]{4}-)Dec-/\112-/$ printf '%s\n' "x [07/Oct/2026 14:02:16] y" "x [31/Dec/2025 23:59:59] y" "sans date" "[01/Feb/2026 00:00:00]" | sed -E -f date-iso.sed
x [2026-10-07T14:02:16] y
x [2025-12-31T23:59:59] y
sans date
[2026-02-01T00:00:00]
La première ligne du script utilise b (branch) sans étiquette, qui saute à la fin du script : appliquée par ! aux lignes sans date, elle les envoie directement à l'affichage. Chaque substitution de mois est ancrée sur [AAAA-, produit de la première substitution, ce qui empêche de toucher un Oct ailleurs. Les mois anglais sont ceux qu'écrit le module http.server de Python, indépendamment de la locale du serveur.
4. La fenêtre robuste (niveau 200). Un collègue propose sed -n '/T14:02/,/T14:20/p' pour extraire l'incident de sig-app-2. (a) Donnez deux raisons pour lesquelles cette commande peut produire beaucoup plus que la fenêtre voulue. (b) Écrivez une commande qui extrait exactement les lignes datées du 7 octobre entre 14:02:00 et 14:19:59, et qui ne puisse pas déborder. (c) Comment vérifieriez-vous, sans relire les lignes, que le résultat est plausible ?
Solution
(a) L'expression n'est pas ancrée et ne contient pas la date : T14:02 correspond aussi le 5, le 6 et le 8 octobre, donc l'intervalle s'ouvre dès le lundi, et il se rouvre chaque jour. Et si aucune ligne ne commence par 14:20 (une minute sans activité), l'intervalle ne se ferme qu'au 14:20 du lendemain, ou jamais.
(b)
sed -n -E '/^2026-10-07T14:(0[2-9]|1[0-9]):/p' "$j2/syslog.log"Ancrée en début de ligne, avec la date complète, elle sélectionne les lignes une à une : aucune ne peut entraîner les suivantes.
(c) Compter les lignes (wc -l, 150 ici), et vérifier la première et la dernière (sed -n '1p;$p') : elles doivent être datées de 14:02 et de 14:19. Un résultat de plusieurs milliers de lignes ou une dernière ligne datée d'un autre jour signale un intervalle qui déborde.
5. Tester l'anonymisation (niveau 200). Écrivez tests/tester-anonymiser, un script Bash qui lit un fichier tests/anonymiser.cas dont chaque ligne contient une entrée et la sortie attendue séparées par |, passe chaque entrée dans bin/anonymiser-journal, affiche les écarts et se termine par 1 s'il y en a. Proposez au moins six cas, dont les bornes des plages privées et un numéro de version qui ne doit pas être touché. Vérifiez que votre test échoue si vous supprimez la deuxième ligne de l'étape 1 du script sed.
Solution
tests/anonymiser.cas :
sshd[1]: Invalid user admin from 203.0.113.77 port 51234|sshd[1]: Invalid user admin from 203.0.113.0 port 51234
SRC=192.0.2.5 DST=51.15.121.37|SRC=192.0.2.0 DST=51.15.121.0
"client":{"ip":"198.51.100.23"}|"client":{"ip":"198.51.100.0"}
172.16.8.20 - - 10.0.0.1 192.168.1.20 127.0.0.1|172.16.8.20 - - 10.0.0.1 192.168.1.20 127.0.0.1
172.15.0.9 172.32.0.1 192.169.1.20|172.15.0.0 172.32.0.0 192.169.1.0
Firefox/131.0 Android/3.2.1|Firefox/131.0 Android/3.2.1tests/tester-anonymiser :
#!/usr/bin/env bash
# tester-anonymiser : chaque ligne de anonymiser.cas contient « entrée|sortie attendue ».
set -Eeuo pipefail
ici=$(dirname -- "$(readlink -f -- "$0")")
echecs=0
while IFS='|' read -r entree attendu; do
obtenu=$(printf '%s\n' "$entree" | "$ici/../bin/anonymiser-journal")
if [[ $obtenu != "$attendu" ]]; then
printf 'ÉCHEC\n entrée : %s\n attendu : %s\n obtenu : %s\n' "$entree" "$attendu" "$obtenu"
echecs=$((echecs + 1))
fi
done < "$ici/anonymiser.cas"
printf '%d échec(s)\n' "$echecs"
(( echecs == 0 ))$ ~/signalements-outils/tests/tester-anonymiser; echo "code : $?"
0 échec(s)
code : 0
Sans la ligne qui protège 172.16.0.0/12, la quatrième ligne de cas échoue : 172.16.8.20 devient 172.16.8.0. Le séparateur | convient parce qu'aucun cas n'en contient ; IFS='|' read -r entree attendu découpe sur le premier | et met le reste dans attendu (leçon 5 du cours Bash). Dans un dépôt qui utilise déjà Bats, écrivez plutôt ces cas comme des tests @test, chacun avec son nom.
6. Protéger le remplacement (niveau 200). (a) Dans echapper_remplacement, pourquoi faut-il traiter la barre oblique inverse avant & ? Donnez la valeur produite pour l'entrée a&b si l'on inverse les deux premières lignes. (b) La fonction suffit-elle pour une valeur insérée dans l'expression de gauche, par exemple pour remplacer seau = $ancien ? Proposez une alternative.
Solution
(a) Si l'on traite & d'abord, a&b devient a\&b ; le traitement de la barre oblique inverse double ensuite celle qu'on vient d'ajouter : a\\&b. Dans le remplacement, \\ produit une barre oblique inverse littérale, et le & qui suit redevient spécial : le résultat est a\ suivi du texte trouvé, suivi de b. L'ordre « barres obliques inverses d'abord » garantit que chaque barre oblique inverse ajoutée ne sera plus retouchée.
(b) Non. Dans l'expression, sont spéciaux ., *, [, ], ^, $, \ et le délimiteur, plus + ? ( ) { } | en ERE : un nom de seau contenant un point (sig.photos) correspondrait à sigXphotos. On pourrait écrire une seconde fonction qui préfixe chacun de ces caractères d'une barre oblique inverse, mais c'est fragile. Mieux : ne pas chercher la valeur, mais la clé (^seau = ), qui est connue et sans caractère spécial, et vérifier la valeur actuelle à part avec une comparaison de chaînes (grep -Fx "seau = $ancien"). C'est l'approche que reprend la leçon 4 pour regler-conf.
Récapitulatif
sedexécute un cycle par ligne : lire dans l'espace de travail (sans le saut de ligne), appliquer le script, afficher (sauf-n), vider.paffiche en plus,dinterrompt le cycle sans afficher.- Extraire :
sed -n '/re/p'oused -n 's/re/remplacement/p'. s/re/rempl/drapeaux:gpour toutes les correspondances,Npour la N-ième,Ipour ignorer la casse ; n'importe quel délimiteur (s|...|...|).- Dans le remplacement, seuls
&,\1à\9,\et le délimiteur sont spéciaux ;\n,\t,\U,\Lsont des extensions GNU. -Epour les expressions étendues : groupes( )et+,?,{n,m},|sans barre oblique inverse. Pas de\d, pas de quantificateur paresseux :[^"]*plutôt que.*.- Adresses : numéro,
$,/re/,!pour inverser ; intervallesa,bdont la borne de fin n'est testée qu'à partir de la ligne suivante, et qui courent jusqu'à la fin si elle n'apparaît jamais. - Scripts longs dans un fichier (
-f), commentés et testés.--debugmontre le cycle. qarrête la lecture ;-ssépare les fichiers ;-zlit des enregistrements séparés par l'octet nul ;s/\r$//nettoie les fins de ligne Windows.- Une valeur insérée dans le remplacement se protège :
\d'abord, puis&, le délimiteur et le saut de ligne. --sandboxinterdite,retw. Tronquer une adresse IP réduit le risque, mais n'anonymise pas au sens du RGPD.
Pour aller plus loin
- Le manuel de GNU sed, en particulier les sections Execution Cycle, The "s" Command, Addresses overview et Reporting Bugs, qui recense les comportements que l'on prend à tort pour des bogues.
- La spécification POSIX de sed, courte et précise, pour savoir ce qui est portable.
- The sed FAQ d'Eric Pement, et le site sed $HOME, pour les idiomes classiques et les différences entre implémentations.
- Sur l'anonymisation : la page de la CNIL et l'avis 05/2014 du G29, qui compare les techniques (généralisation, randomisation, pseudonymisation) et leurs risques résiduels.
- La leçon suivante, sed : éditer des fichiers sans les casser, applique
sedaux fichiers de configuration :-iet ce qu'il fait vraiment, les modifications idempotentes, l'espace de réserve et le travail sur plusieurs lignes.
Sources
- GNU sed Manual (sed 4.9) : Execution Cycle, The s Command, Addresses, Regular Expressions
- POSIX.1-2024, utilitaire sed (adresses, fonction s, partie remplacement)
- GNU sed, fichier NEWS (-z en 4.2.2, --sandbox en 4.3, --debug en 4.6, nouveautés de 4.10)
- Eric Pement, The sed FAQ
- sed $HOME, ressources et scripts de la communauté sed
- CNIL, L'anonymisation de données personnelles (2020)
- Groupe de travail « Article 29 », avis 05/2014 sur les techniques d'anonymisation (WP216)
- CJUE, arrêt Breyer (C-582/14, 19 octobre 2016) : adresse IP dynamique et donnée personnelle
- IETF, RFC 1918 : Address Allocation for Private Internets
- IETF, RFC 5737 : IPv4 Address Blocks Reserved for Documentation