Aller au contenu
sed : le cycle et la substitution

sed : le cycle et la substitution

200 Compagnon ⏱ 1 h 40 sedbashlinuxdebianubuntu

À la fin, vous saurez

  • Décrire le cycle d'exécution de sed (lecture dans l'espace de travail, application du script, affichage) et prévoir l'effet de -n, p et d sur la sortie
  • Écrire des substitutions justes avec s, ses drapeaux (g, N, p, I, w), des délimiteurs adaptés, & et les références arrière, en BRE comme en ERE
  • Sélectionner des lignes par numéro, par expression régulière et par intervalle, et expliquer pourquoi un intervalle dont la borne de fin n'apparaît jamais court jusqu'à la fin du fichier
  • Extraire un champ, normaliser une date et anonymiser des adresses IP dans un journal avec un script sed rangé dans un fichier et testé
  • Insérer sans risque une valeur venue d'une variable dans la partie remplacement d'une commande s
  • Désactiver les commandes dangereuses de sed avec --sandbox et situer la troncature d'adresses IP par rapport à l'anonymisation au sens du RGPD

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) sed GNU sed 4.9 (Ubuntu 24.04 et Debian 13) , vérifié le 9 octobre 2026

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 :

  1. Lire une ligne, retirer son saut de ligne final, et la placer dans l'espace de travail.
  2. 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.
  3. À 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 -n est active.
  4. 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, sed recopie. sed '' fichier affiche le fichier tel quel : l'affichage automatique de l'étape 3 suffit.
  • p affiche en plus. La commande p imprime l'espace de travail au moment où elle s'exécute ; l'affichage automatique a lieu quand même à la fin du cycle. sed p affiche donc chaque ligne deux fois. Pour n'afficher que ce que l'on demande, on coupe l'affichage automatique par -n et l'on demande explicitement par p : sed -n '/motif/p' est l'équivalent de grep motif.
  • d interrompt le cycle. La commande d vide 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 un d qui 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/drapeaux

sed 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 commande

Dans 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

DrapeauEffetNorme
(aucun)remplace la première correspondance seulementPOSIX
gremplace toutes les correspondances de la ligne, sans chevauchementPOSIX
N (un nombre)remplace seulement la N-ième correspondancePOSIX
Ngremplace la N-ième et toutes les suivantesGNU
paffiche l'espace de travail si une substitution a eu lieuPOSIX
w fichierécrit l'espace de travail dans un fichier si une substitution a eu lieuPOSIX
I ou iignore la casseGNU
eexécute l'espace de travail comme une commande et le remplace par sa sortieGNU
m ou Mmode 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 à \9 sont 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 \10 signifie « 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 :

ConstructionBRE (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érauxtels 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 :

AdresseLignes désignéesNorme
5la cinquième ligne de l'entréePOSIX
$la dernière lignePOSIX
/re/les lignes où l'expression re correspondPOSIX
\%re%idem, avec % (ou un autre caractère) comme délimiteurPOSIX
/re/Iidem, sans tenir compte de la casseGNU
premier~pasune 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 :

  1. Tant que l'intervalle est inactif, sed teste la première adresse sur chaque ligne. Quand elle correspond, l'intervalle devient actif, et cette ligne en fait partie.
  2. Tant que l'intervalle est actif, chaque ligne en fait partie, et sed teste 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.
  3. 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 propose 0,/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.conf

Ce 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

CommandeEffet
paffiche l'espace de travail
dsupprime l'espace de travail et passe à la ligne suivante, sans affichage
qaffiche l'espace de travail (sauf -n) puis quitte sans lire la suite
Qquitte 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
Faffiche le nom du fichier en cours (GNU)
a, i, cajoute du texte après, avant, ou à la place de la ligne (leçon 4)
r fichierinsè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:$PATH

Premiers 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 par p, 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/ sans g ne remplace que le premier a. Oublier le g est probablement l'erreur de sed la 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 d interrompt 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 un 404 qui 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 lettre T et 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/./g

Chaque é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 (de 172.16 à 172.31) et 192.168.0.0/16 ; on y ajoute le bouclage 127.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 comme 172\n16.8.20 ne 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. Le g traite toutes les adresses de la ligne, ce qui compte pour les lignes du pare-feu (SRC= et DST=).
  • Étape 3. Les sauts de ligne redeviennent des points. GNU sed accepte \n dans 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 localiser lib/ à côté de bin/. La variable ANONYMISER_SED permet de pointer un autre script, utile pour les tests.
  • LC_ALL=C.UTF-8 fixe le comportement des classes et du ., comme on le verra dans Sous le capot.
  • exec remplace le processus Bash par sed : le code de sortie du script est celui de sed, et aucun processus inutile ne reste en mémoire.
  • -- sépare les options des noms de fichiers : un fichier nommé -n ne serait pas pris pour une option. Sans aucun nom de fichier, sed lit 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.jsonl

Dans 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 drapeau w de s) écrit dans un fichier, r lit un fichier, et e (commande ou drapeau de s) exécute une commande shell. Le drapeau e exécute le contenu de l'espace de travail :

    $ printf 'uname -s\n' | sed 's/.*/&/e'
    Linux
    

    Si 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 /e ou w /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 lance sed. Depuis la version 4.3, l'option --sandbox refuse 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 : 1
    

    anonymiser-journal n'a besoin d'aucune d'elles : il les interdit. Mettez --sandbox dans 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 /24 dé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 par anonymiser-journal restent 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 --sandbox partout 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.sed dans 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 sed de BusyBox, macOS celui de FreeBSD. -E, s///g, les adresses et -n sont portables ; \b, \n dans le remplacement, I, Q, 0,/re/, first~step, --sandbox, --debug et -z ne le sont pas. Si un script doit tourner hors de Debian et d'Ubuntu, testez-le avec sed --posix (qui désactive une partie des extensions) et sur la cible réelle, ou installez GNU sed (gsed sous macOS avec Homebrew).
  • Fixer la locale. LC_ALL=C.UTF-8 dans le script, ou dans l'unité systemd qui le lance : le comportement de ., des classes et de y ne doit pas dépendre de qui lance la tâche.
  • Mesurer avant de choisir. sed est rapide : l'anonymisation des deux journaux de sig-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. sed est 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 à jq pour 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 sha256sum de 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 -rn

Pour 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.1

tests/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

  • sed exécute un cycle par ligne : lire dans l'espace de travail (sans le saut de ligne), appliquer le script, afficher (sauf -n), vider. p affiche en plus, d interrompt le cycle sans afficher.
  • Extraire : sed -n '/re/p' ou sed -n 's/re/remplacement/p'.
  • s/re/rempl/drapeaux : g pour toutes les correspondances, N pour la N-ième, I pour 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, \L sont des extensions GNU.
  • -E pour 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 ; intervalles a,b dont 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. --debug montre le cycle.
  • q arrête la lecture ; -s sépare les fichiers ; -z lit 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.
  • --sandbox interdit e, r et w. 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 sed aux fichiers de configuration : -i et ce qu'il fait vraiment, les modifications idempotentes, l'espace de réserve et le travail sur plusieurs lignes.
+20 XP Carte du ciel →Mon cosmonaute →

Sources