awk : un langage pour les lignes et les champs
Pourquoi
À la leçon 1, le premier tri de l'incident de mercredi s'est fait avec des tubes : grep pour garder les lignes de l'API, cut pour isoler un champ, sort | uniq -c pour compter. La leçon 2 a affiné la sélection, et les leçons 3 et 4 ont appris à transformer le texte avec sed. Ces outils ont une limite commune : ils voient des lignes et des caractères, pas des enregistrements faits de champs. Dès qu'une question porte sur une colonne précise (« les lignes dont le code HTTP est supérieur à 499 », « l'adresse qui précède le mot port », « l'heure de la première et de la dernière erreur »), les tubes s'allongent, chaque étape relance un processus, et chaque étape repose sur une hypothèse fragile sur la position des espaces.
Voici une commande trouvée dans l'historique du shell de sig-outils, tapée par un collègue le soir de l'incident pour lister les erreurs serveur de sig-app-2 :
awk "\$2 == \"$hote\" && \$12 > $seuil" /srv/donnees/journaux/*/syslog.logavec hote=sig-app-2 et seuil=499. Elle semble juste : le douzième champ d'une ligne de l'API est le code HTTP. Elle renvoie pourtant 167 lignes, dont 107 seulement sont des réponses de l'API. Les 60 autres sont des connexions SSH refusées et des paquets bloqués par le pare-feu, dont le douzième champ vaut [preauth] ou TOS=0x00. Pour awk, ce ne sont pas des nombres : il les compare comme des chaînes, et [ comme T viennent après 4 dans l'ordre des caractères. Et la même ligne, avec une valeur de hote choisie par quelqu'un d'autre, exécute la commande de son choix.
awk est un langage de programmation complet, normalisé par POSIX, conçu en 1977 par Alfred Aho, Peter Weinberger et Brian Kernighan pour exactement ce travail : parcourir des lignes, les découper en champs, tester, calculer, reformater. Il est installé partout, il est rapide, et un programme de dix lignes remplace un tube de six commandes. Mais c'est un langage, avec ses règles de typage, ses conversions implicites et ses implémentations qui divergent : celle de sig-outils n'est pas celle de sig-app-1. Cette leçon en donne les règles, assez précisément pour prévoir ce que fait un programme avant de le lancer, et produit le premier élément du rapport hebdomadaire : lib/requetes.awk, qui transforme les lignes de l'API en un fichier tabulé propre, prêt pour l'agrégation de la leçon 6.
Les concepts
Un programme, des règles
Un programme awk est une suite de règles, chacune de la forme :
motif { action }awk lit son entrée enregistrement par enregistrement (par défaut, un enregistrement est une ligne). Pour chaque enregistrement, il essaie toutes les règles dans l'ordre du programme : si le motif est vrai, il exécute l'action. Puis il passe à l'enregistrement suivant. C'est la boucle while read de la leçon 5 du cours Bash, mais écrite une fois pour toutes, en C, à l'intérieur de l'interpréteur.
Deux simplifications rendent les programmes courts :
- Motif absent : l'action s'applique à tous les enregistrements.
{ print $2 }affiche le deuxième champ de chaque ligne. - Action absente : l'action par défaut est
{ print }, qui affiche l'enregistrement entier.$12 == 503seul affiche les lignes dont le douzième champ vaut 503, comme ungrepqui saurait compter les colonnes.
Deux motifs spéciaux encadrent la lecture :
BEGIN: son action s'exécute avant la lecture du premier enregistrement. On y fixe des séparateurs, on y initialise des variables, on y affiche un en-tête. Un programme qui ne contient que des règlesBEGINne lit aucune entrée.END: son action s'exécute après le dernier enregistrement. On y affiche les totaux.
Le motif peut être une expression régulière entre barres obliques (/python3\[/, vraie si la ligne contient une correspondance), une expression quelconque ($12 >= 500, NR > 1, length($0) > 200), une combinaison avec &&, || et !, ou un intervalle motif1, motif2, vu plus bas.
Enregistrements et champs
Une fois l'enregistrement lu, awk le range dans $0 et le découpe en champs : $1, $2, et ainsi de suite. Quelques variables prédéfinies décrivent ce découpage :
| Variable | Contenu |
|---|---|
$0 | l'enregistrement entier, sans son séparateur final |
$1, $2... | les champs ; $n accepte une expression : $NF, $(NF - 1), $i |
NF | le nombre de champs de l'enregistrement courant |
NR | le nombre d'enregistrements lus depuis le début, tous fichiers confondus |
FNR | le numéro de l'enregistrement dans le fichier courant |
FILENAME | le nom du fichier en cours de lecture |
FS | le séparateur de champs en lecture (field separator), une espace par défaut |
OFS | le séparateur de champs en sortie (output field separator), une espace par défaut |
RS | le séparateur d'enregistrements en lecture, le saut de ligne par défaut |
ORS | le séparateur d'enregistrements en sortie, le saut de ligne par défaut |
$NF est donc le dernier champ, quelle que soit la longueur de la ligne, et $(NF - 2) l'antépénultième : c'est la façon robuste de viser la fin d'une ligne dont le début varie. Les parenthèses comptent : $NF - 2 vaut le dernier champ moins 2.
Un champ au-delà de NF existe et vaut la chaîne vide. Le lire ne change rien ; lui affecter une valeur, en revanche, allonge l'enregistrement (voir plus bas).
Trois façons de découper
La valeur de FS décide du découpage, selon trois règles fixées par POSIX :
FSvaut une espace (le défaut) : les champs sont séparés par des suites de blancs (espaces, tabulations), et les blancs en début et en fin de ligne sont ignorés." a b\tc "donne trois champs. C'est le comportement que la leçon 1 opposait àcut -d' ', qui voit un champ vide entre deux espaces consécutives.FSvaut un autre caractère unique : chaque occurrence de ce caractère sépare deux champs, y compris quand deux occurrences se suivent.-F:sur/etc/passwd,-F,sur un CSV simple,-F'\t'sur un TSV. Le caractère est pris littéralement, même|ou., qui seraient des métacaractères dans une expression régulière.FSa plus d'un caractère : c'est une expression régulière étendue (ERE, celles degrep -E, leçon 2).-F'[][]'coupe sur chaque crochet ouvrant ou fermant,-F' *; *'sur un point-virgule entouré d'espaces facultatives.
L'option -F de la ligne de commande fixe FS ; on peut aussi l'affecter dans BEGIN, ou par -v FS=.... Un piège classique : -F' ' ne veut pas dire « exactement une espace » mais « le comportement par défaut », puisque la valeur est une espace. Pour couper sur chaque espace, il faut l'écrire comme une classe : -F'[ ]'.
Changer FS au milieu d'une action ne touche pas l'enregistrement courant, déjà découpé : la nouvelle valeur vaut à partir du suivant. C'est pourquoi on fixe FS dans BEGIN ou par -F, jamais dans une règle ordinaire.
Modifier un champ reconstruit la ligne
Affecter une valeur à un champ, par exemple $1 = "x", oblige awk à reconstruire $0 en joignant tous les champs avec OFS. Les séparateurs d'origine sont perdus : une ligne aux colonnes alignées par plusieurs espaces ressort avec des espaces simples, ou avec le contenu d'OFS. D'où un idiome étrange mais courant :
{ $1 = $1; print }qui ne change aucune valeur mais force la reconstruction : avec OFS = "\t", il convertit une sortie alignée par des espaces en TSV. Affecter un champ au-delà de NF allonge l'enregistrement et crée des champs vides intermédiaires. Inversement, mawk et gawk reconstruisent $0 quand on diminue NF (NF = 2 tronque la ligne à ses deux premiers champs) ; POSIX ne décrit pas cette affectation, et une implémentation ancienne peut l'ignorer.
À l'inverse, modifier $0 (par sub, gsub ou une affectation) redécoupe la ligne : NF et tous les champs sont recalculés.
print et printf
print affiche ses arguments séparés par OFS, suivis d'ORS. La virgule est essentielle :
print $1, $3 # deux arguments : $1, OFS, $3
print $1 $3 # un seul argument : la concaténation de $1 et de $3En awk, juxtaposer deux expressions les concatène. Il n'y a pas d'opérateur de concaténation visible, ce qui rend l'oubli d'une virgule silencieux.
printf reprend le printf du C et du shell (leçon 3 du cours Bash) : un format, puis les valeurs. Il n'ajoute ni OFS ni saut de ligne : \n doit figurer dans le format. Les conversions utiles : %s (chaîne), %d (entier, partie décimale tronquée), %f, %.2f, %e, %g (nombre), %-12s (chaîne alignée à gauche sur douze colonnes), %5d (entier aligné à droite sur cinq), %% (le signe pour cent).
Les motifs
Au-delà des expressions régulières sur la ligne entière, les motifs les plus utiles :
- Comparaison sur un champ :
$12 == 503,$2 != "sig-app-1",NF > 13. - Correspondance sur un champ :
$3 ~ /^sshd\[/est vrai si le troisième champ contient une correspondance de l'expression ;!~est la négation.$3 ~ /sshd/est plus précis que/sshd/seul, qui accepterait le mot n'importe où dans la ligne, y compris dans une URL demandée par un client. - Combinaisons :
$3 ~ /^python3\[/ && $12 >= 500. - Intervalle
debut, fin: vrai à partir d'un enregistrement oùdebutest vrai, jusqu'à et y compris le prochain oùfinest vrai ; puis la recherche dedebutreprend. C'est l'équivalent des adresses/a/,/b/de sed (leçon 3), avec les mêmes pièges.
L'expression régulière dans un motif est une constante : /.../ ne passe pas par le shell si le programme est entre guillemets simples, et les barres obliques internes s'échappent en \/. On peut aussi comparer à une chaîne construite dynamiquement : $3 ~ motif, où motif est une variable contenant le texte d'une expression régulière.
next et exit
nextabandonne l'enregistrement courant : les règles suivantes ne sont pas essayées, awk lit l'enregistrement suivant. C'est lecontinuedes boucles de lecture, et la façon idiomatique d'écarter tôt ce qui ne nous intéresse pas.exit [code]arrête la lecture. Les actionsENDs'exécutent quand même, comme l'impose POSIX ; unexitplacé dansENDtermine immédiatement. Le code donné devient le code de sortie d'awk, sauf si un autreexitle remplace dansEND.
Variables, nombres et chaînes
Les variables d'awk ne se déclarent pas. Une variable jamais affectée a, selon POSIX, à la fois la valeur numérique 0 et la valeur textuelle vide. C'est ce qui permet d'écrire n++ sans initialisation, mais aussi ce qui fait afficher une ligne vide par END { print n } quand aucune ligne n'a été comptée. print n + 0 force la valeur numérique et affiche 0.
Il n'y a que deux types de valeurs scalaires : les nombres, tous en virgule flottante double précision, et les chaînes. awk convertit l'un dans l'autre selon le contexte :
- Une chaîne utilisée dans un calcul est lue comme un nombre à partir de son début :
"42 requêtes" + 0vaut 42,"abc" + 0vaut 0. Aucune erreur n'est signalée. - Un nombre utilisé comme chaîne (dans une concaténation, comme indice de tableau) est converti avec le format
CONVFMT(%.6gpar défaut) s'il n'est pas entier, et comme un entier s'il l'est.printutilise un autre format,OFMT, également%.6gpar défaut.0.1 + 0.2s'affiche donc0.3, bien que la valeur stockée soit0.30000000000000004.
Comparer : nombres ou chaînes ?
C'est la règle qui explique le bogue de l'introduction. Une comparaison (<, <=, ==, !=, >=, >) est numérique si les deux opérandes sont des nombres, ou si l'un est un nombre et l'autre une chaîne numérique. Elle est textuelle dans tous les autres cas : les deux chaînes sont alors comparées caractère par caractère, selon l'ordre de la locale (== et != vérifient, eux, que les chaînes sont identiques, comme le précise POSIX.1-2024).
Une chaîne numérique (strnum dans le manuel de gawk) est une valeur qui vient de l'extérieur du programme (un champ, une valeur lue par getline, FILENAME, un élément d'ARGV ou d'ENVIRON, une affectation de la ligne de commande ou de -v) et qui a l'allure d'un nombre : espaces facultatives, signe, chiffres, point décimal, exposant. Une constante entre guillemets dans le programme n'est jamais une chaîne numérique.
| Expression | Comparaison | Résultat |
|---|---|---|
champs 10 et 9, $1 > $2 | numérique (deux chaînes numériques) | vrai |
"10" > "9" | textuelle (deux constantes de chaîne) | faux : 1 vient avant 9 |
champs 10 et 9x, $1 > $2 | textuelle : 9x n'a pas l'allure d'un nombre | faux |
champ [preauth], $12 > 499 | textuelle : le champ n'est pas numérique | vrai : [ vient après 4 |
champs 1e1 et 10, $1 == $2 | numérique | vrai |
champ 503, $12 == "503" | textuelle (la constante est une chaîne) | vrai, mais $12 == "0503" serait faux |
Pour imposer un type, on convertit explicitement : $12 + 0 > seuil + 0 force une comparaison numérique (une valeur non numérique devient 0), et $1 "" == $2 "" force une comparaison textuelle. Le plus sûr reste de filtrer d'abord les lignes dont on connaît le format, pour que les champs comparés aient bien la nature attendue.
Les fonctions de chaînes
POSIX fournit un jeu réduit mais suffisant de fonctions, communes à toutes les implémentations :
| Fonction | Rôle |
|---|---|
length(s) | longueur de s ; sans argument, celle de $0 |
substr(s, debut, n) | sous-chaîne de n caractères à partir de debut ; le premier caractère a l'indice 1 |
index(s, t) | position de la première occurrence de la chaîne t dans s, 0 si absente (recherche littérale, pas d'expression régulière) |
split(s, t, sep) | découpe s dans le tableau t[1]... t[n] selon sep (mêmes règles que FS) et renvoie n |
sub(re, rempl, cible) | remplace la première correspondance de re dans cible ($0 par défaut) ; renvoie 0 ou 1 |
gsub(re, rempl, cible) | remplace toutes les correspondances ; renvoie leur nombre |
match(s, re) | position de la première correspondance, et fixe RSTART (position) et RLENGTH (longueur, -1 si absente) |
sprintf(fmt, ...) | comme printf, mais renvoie la chaîne au lieu de l'afficher |
tolower(s), toupper(s) | changent la casse |
Dans le texte de remplacement de sub et gsub, & représente la correspondance entière, comme dans le s de sed ; \\& produit un & littéral. Il n'y a pas de groupes numérotés \1 en awk POSIX : c'est une extension de gawk (gensub, leçon 6). La troisième variable de sub et gsub doit être modifiable (une variable, un champ, un élément de tableau) : modifier un champ reconstruit $0, modifier $0 redécoupe les champs.
Le tableau rempli par split est un tableau associatif, la structure centrale d'awk, dont la leçon 6 fera son outil principal. Ici, retenez seulement que t[1]... t[n] contiennent les morceaux.
Faire entrer des valeurs dans le programme
Un programme a souvent besoin d'une valeur venue du shell : un nom d'hôte, un seuil, une date. Trois mécanismes, tous définis par POSIX :
-v nom=valeuravant le programme : l'affectation a lieu avantBEGIN. La valeur subit le traitement des séquences d'échappement d'une chaîne awk :\tdevient une tabulation,\\une barre oblique inverse.- Une affectation parmi les noms de fichiers :
awk '...' seuil=5 fichier. Elle a lieu au moment où awk atteint cet argument, donc aprèsBEGINet entre deux fichiers ; elle sert à changerFSou une variable d'un fichier à l'autre. Conséquence piégeuse : un fichier dont le nom contient un=et commence comme un nom de variable est pris pour une affectation. ENVIRON["NOM"]: le tableau des variables d'environnement, sans aucun traitement des barres obliques inverses. C'est la seule voie qui transmet une valeur exactement.
La quatrième voie, coller la valeur dans le texte du programme entre guillemets doubles du shell, est celle de la commande de l'introduction. Elle est à proscrire, pour des raisons détaillées dans la partie Sécurité.
Un programme dans un fichier
Au-delà d'une ligne, le programme se range dans un fichier lu par -f :
awk -f lib/requetes.awk journal.logLe fichier accepte les commentaires (# jusqu'à la fin de la ligne), les sauts de ligne et l'indentation. On évite ainsi deux niveaux de guillemets (le shell, puis awk), on le relit dans un éditeur avec la coloration syntaxique, on le versionne et on le teste. Il peut aussi devenir exécutable avec une ligne #! : #!/usr/bin/awk -f. Le noyau lance alors /usr/bin/awk -f chemin-du-script arguments... ; le -f final est indispensable. On ne peut pas y ajouter d'autre option de façon portable, puisque Linux passe tout ce qui suit l'interpréteur comme un seul argument.
Plusieurs awk
« awk » désigne un langage et plusieurs programmes :
| Implémentation | Où on la trouve | Caractère |
|---|---|---|
| mawk (Mike Brennan, maintenu par Thomas Dickey) | awk par défaut sur Debian 13, Ubuntu minimal et les images de conteneur Debian et Ubuntu | rapide, compile le programme en code interne, proche de POSIX |
| gawk (GNU awk) | awk sur les installations serveur et les images cloud d'Ubuntu, Fedora, Arch | nombreuses extensions, mode --posix |
| The One True Awk (BWK awk) | awk de macOS et des BSD, paquet original-awk sur Debian | l'implémentation de référence de Kernighan, mise à jour pour la 2e édition du livre (UTF-8, option --csv) |
| BusyBox awk | images Alpine et systèmes embarqués | compacte, plus limitée |
Sur Debian et Ubuntu, /usr/bin/awk est un lien géré par les alternatives : le paquet mawk, marqué required et donc toujours installé, déclare l'alternative avec une priorité de 5 ; gawk, s'il est installé, la déclare avec une priorité de 10 et l'emporte. Or le paquet gawk fait partie des tâches d'installation server et cloud-image d'Ubuntu. Résultat, pour Signalements : awk est gawk 5.2.1 sur sig-app-1 et sig-app-2, images cloud Ubuntu 24.04, et mawk 1.3.4 sur sig-outils, Debian 13 installée au plus juste. Le même script peut s'y comporter différemment. La règle de ce cours : écrire en awk POSIX, tester avec mawk, et réserver les extensions de gawk aux cas où on l'appelle explicitement par son nom (gawk, jamais awk).
En pratique
Les sorties ont été produites sur le bac à sable de la leçon 1, avec mawk 1.3.4-20240123 et LC_ALL=C.UTF-8. Placez-vous à la racine du bac à sable et définissez deux raccourcis :
$ cd ~/essais-texte
$ j1=journaux/sig-app-1.pn-signalements.internal/syslog.log
$ j2=journaux/sig-app-2.pn-signalements.internal/syslog.log
Vérifiez l'implémentation que vous utilisez, la suite en dépend :
$ awk -W version 2>&1 | head -1
mawk 1.3.4 20240123
-W version est l'option de mawk ; gawk répond aussi à --version. Si vous obtenez GNU Awk 5.2.1, vos résultats seront identiques à quelques détails près, signalés au fil de la leçon.
Regarder les champs
Avant d'écrire un motif, on regarde comment awk découpe une ligne typique. Le plus sûr est d'afficher chaque champ avec son numéro :
$ awk 'NR == 2 { for (i = 1; i <= NF; i++) printf "%2d <%s>\n", i, $i }' "$j2"
1 <2026-10-05T00:01:07.744806+00:00>
2 <sig-app-2>
3 <python3[790]:>
4 <172.16.8.20>
5 <->
6 <->
7 <[05/Oct/2026>
8 <00:01:07]>
9 <"GET>
10 </sante>
11 <HTTP/1.1">
12 <200>
13 <->
NR == 2restreint le programme à la deuxième ligne.- La boucle
forreprend la syntaxe du C.$iest le champ dont le numéro est la valeur dei. - Les chevrons rendent visibles les champs vides et les espaces parasites.
On y lit tout ce qui servira : l'horodatage en $1, l'hôte en $2, le processus en $3, la méthode collée à un guillemet en $9, le chemin en $10, le code en $12. Et un avertissement : la date entre crochets est coupée en deux, et les guillemets restent collés aux mots. Le découpage par blancs ne connaît ni crochets ni guillemets.
Les autres lignes n'ont pas la même forme. Combien de champs, pour combien de lignes ?
$ awk '{ print NF }' "$j2" | sort -n | uniq -c
1 6
3 8
23 10
96 11
8032 13
4 14
24 23
Les 8 032 lignes à treize champs sont presque toutes des requêtes de l'API ; les autres sont des lignes de sshd, du noyau, de cron ou de systemd. Une règle qui se fierait au seul nombre de champs serait fragile. Mieux vaut reconnaître le processus.
Qui écrit dans ce journal ?
Le troisième champ porte le nom du processus, suivi de son PID entre crochets et d'un deux-points. Retirons ce suffixe et comptons :
$ awk '{ p = $3; sub(/\[.*/, "", p); sub(/:$/, "", p); print p }' "$j2" | sort | uniq -c | sort -rn
8000 python3
96 CRON
59 sshd
24 kernel
4 systemd
- On copie
$3dans une variablepavant de la modifier :subsur$3lui-même reconstruirait$0, inutilement. - Le premier
subsupprime tout ce qui suit le premier crochet (python3[790]:devientpython3) ; le second retire le deux-points final dekernel:, qui n'a pas de PID. - Le comptage reste confié à
sort | uniq -c: les tableaux d'awk sauront le faire en une passe à la leçon 6.
8 000 lignes de l'API, dont 5 760 vérifications de santé (une par minute pendant quatre jours), et une soixantaine de lignes sshd sur un serveur qui ne devrait recevoir de connexions SSH que depuis le réseau privé : on y reviendra.
Sélectionner par champ
La version corrigée de la commande de l'introduction commence par reconnaître les lignes de l'API, puis compare le code :
$ awk '$3 ~ /^python3\[/ && $12 >= 500' "$j2" | head -3
2026-10-05T11:54:19.334749+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 11:54:19] "GET /pieces-jointes/446a238f7a67e5b3.jpg HTTP/1.1" 500 -
2026-10-05T17:23:51.737502+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 17:23:51] "POST /signalements HTTP/1.1" 500 -
2026-10-05T19:59:30.734199+00:00 sig-app-2 python3[790]: 172.16.8.20 - - [05/Oct/2026 19:59:30] "GET /signalements HTTP/1.1" 500 -
Les premières erreurs de la semaine ne sont pas celles de l'incident : ce sont des 500 isolés, lundi.
$3 ~ /^python3\[/ garantit que $12 est un code HTTP, donc une chaîne numérique, donc que >= 500 est une comparaison numérique. Les lignes de l'API sont le seul endroit où ce douzième champ a un sens.
Pour la fenêtre de l'incident, les horodatages RFC 3339 ont une propriété précieuse : leur ordre alphabétique est l'ordre chronologique, tant qu'ils ont le même format et le même fuseau. Une comparaison textuelle suffit donc, et elle est voulue ici :
$ awk '$1 >= "2026-10-07T14:02" && $1 < "2026-10-07T14:20"' "$j2" | wc -l
150
$ awk '$1 >= "2026-10-07T14:02" && $1 < "2026-10-07T14:20" && $12 == 503' "$j2" | wc -l
101
Les constantes entre guillemets imposent la comparaison de chaînes (un champ comparé à une constante de chaîne). La borne supérieure stricte < "2026-10-07T14:20" inclut toutes les lignes de 14:19:59.999999 et exclut 14:20:00. 150 lignes dans la fenêtre, dont 101 réponses 503.
Le piège de l'intervalle
L'intuition pousse à écrire la même fenêtre avec un motif d'intervalle :
$ awk '/T14:02:/, /T14:20:/' "$j2" | wc -l
245
95 lignes de trop. Regardons les dates :
$ awk '/T14:02:/, /T14:20:/' "$j2" | awk '{ print substr($1, 1, 10) }' | uniq -c
29 2026-10-05
37 2026-10-06
151 2026-10-07
28 2026-10-08
L'intervalle s'est ouvert chaque jour à 14:02 et refermé à 14:20, puisque rien dans les expressions ne parle de la date. Et le 7, il compte 151 lignes et non 150 : la ligne qui ferme l'intervalle (la première de 14:20) est incluse. Un intervalle s'arrête sur un événement, pas sur une valeur ; si l'événement de fin n'apparaît jamais (aucune ligne à 14:20 un jour de faible trafic), il court jusqu'à la fin du fichier. Pour une plage de temps, comparez des valeurs.
Premier et dernier, en une passe
Quand la première erreur a-t-elle eu lieu, et la dernière ? Avec BEGIN et END, en une seule lecture :
$ awk '$3 ~ /^python3\[/ && $12 == 503 {
if (n == 0) premier = $1
dernier = $1
n++
}
END { print n, premier, dernier }' "$j2"
101 2026-10-07T14:02:16.408158+00:00 2026-10-07T14:19:06.296426+00:00
nvaut 0 (non initialisée) à la première erreur : on retient son horodatage.dernierest écrasé à chaque erreur : à la fin, il contient la dernière.- Le programme tient sur plusieurs lignes : à l'intérieur des guillemets simples, les sauts de ligne sont permis, et une instruction par ligne se passe de point-virgule.
Une règle qui n'est vraie pour aucune ligne laisse ses variables vides. Le piège se voit avec un code qui n'existe pas :
$ awk '$12 == 999 { n++ } END { print n; print n + 0 }' "$j2"
0
La première ligne affichée est vide. Dans un rapport, on écrira toujours n + 0, ou printf "%d", qui convertit en nombre.
Mettre en forme avec printf
Un résumé d'une ligne pour le rapport de l'équipe, avec un pourcentage :
$ awk '$3 ~ /^python3\[/ { n++; if ($12 >= 500) e++ }
END { printf "%-10s %8d requêtes, %5d erreurs 5xx (%.2f %%)\n", "sig-app-2", n, e, 100 * e / n }' "$j2"
sig-app-2 8000 requêtes, 107 erreurs 5xx (1.34 %)
ifdans une action s'écrit comme en C ; la condition est une expression awk.%-10saligne le nom à gauche sur dix colonnes,%8det%5dalignent les nombres à droite,%.2farrondit à deux décimales,%%affiche le signe.- La division
100 * e / nest décimale : awk n'a pas d'entiers. Sinpouvait valoir 0, il faudrait le tester avant : gawk s'arrête alors sur une erreur fatale, tandis que mawk afficheinfounansans rien signaler (leçon 6, Pièges courants).
Les 107 erreurs comprennent les 101 réponses 503 de l'incident et six erreurs 500 réparties sur la semaine.
Découper autrement : crochets et guillemets
Le découpage par blancs a coupé la date entre crochets. Avec -F'[][]', chaque crochet devient un séparateur (l'expression [][] est une classe qui contient ] puis [ ; le ] doit venir en premier pour être pris littéralement) :
$ awk -F'[][]' 'NR == 2 { for (i = 1; i <= NF; i++) printf "%d <%s>\n", i, $i }' "$j2"
1 <2026-10-05T00:01:07.744806+00:00 sig-app-2 python3>
2 <790>
3 <: 172.16.8.20 - - >
4 <05/Oct/2026 00:01:07>
5 < "GET /sante HTTP/1.1" 200 ->
Surprise : les premiers crochets de la ligne sont ceux du PID, pas ceux de la date. La date est en $4. Changer de séparateur, c'est changer toute la numérotation : on vérifie toujours avec un affichage champ par champ.
Les guillemets découpent la requête plus proprement encore, puisqu'elle est le seul texte entre guillemets d'une ligne de l'API :
$ awk -F'"' '$1 ~ /python3\[/ { print $2 }' "$j2" | head -2
GET /sante HTTP/1.1
GET /sante HTTP/1.1
-F'"' : le shell transmet un guillemet double, caractère unique, donc séparateur littéral. $1 est tout ce qui précède la requête, $2 la requête, $3 le code et la taille.
split : découper un champ en morceaux
On n'est pas obligé de choisir un seul découpage pour toute la ligne : split redécoupe n'importe quelle chaîne. C'est la méthode retenue pour lib/requetes.awk : découpage par blancs pour les champs du début, split sur les guillemets pour la requête.
Les lignes du pare-feu du noyau, elles, sont faites de paires CLÉ=valeur à des positions variables : OUT= est parfois vide, des drapeaux comme DF ou SYN apparaissent ou non. On ne peut pas compter sur $12. On parcourt donc les champs et on reconnaît la clé :
$ awk '/\[UFW BLOCK\]/ {
src = dpt = ""
for (i = 1; i <= NF; i++) {
if ($i ~ /^SRC=/) src = substr($i, 5)
else if ($i ~ /^DPT=/) dpt = substr($i, 5)
}
print src, dpt
}' journaux/*/syslog.log | sort | uniq -c | sort -rn | head -5
5 192.0.2.5 3306
5 192.0.2.3 6379
5 192.0.2.3 5432
3 192.0.2.5 6379
3 192.0.2.5 5432
src = dpt = ""remet les deux variables à zéro à chaque ligne : sans cela, une ligne sansDPT=hériterait de la valeur de la précédente. C'est la contrepartie des variables globales et sans déclaration.substr($i, 5)garde ce qui suit les quatre caractères deSRC=ouDPT=. Pour une clé de longueur variable, on écriraitsubstr($i, index($i, "=") + 1).- Le motif
/\[UFW BLOCK\]/protège les crochets, qui seraient sinon une classe de caractères.
Deux adresses du réseau de documentation 192.0.2.0/24 tentent de joindre PostgreSQL (5432), Redis (6379) et MySQL (3306) directement sur l'adresse publique des serveurs. UFW les bloque, c'est son rôle ; mais cela confirme que les serveurs de l'API sont exposés à internet, ce que le volet sécurité du rapport devra dire.
match : extraire ce qui suit un mot
Les tentatives SSH ont la forme Invalid user ubuntu from 203.0.113.77 port 54603. Deux façons de récupérer l'adresse. Par position, en partant de la fin, puisque le début de la ligne varie mais pas sa fin :
$ awk '$3 ~ /^sshd\[/ && $4 == "Invalid" { print $(NF - 2) }' journaux/*/syslog.log | sort | uniq -c | sort -rn
51 203.0.113.77
38 203.0.113.150
32 198.51.100.23
Ou par motif, avec match, qui ne dépend pas du nombre de mots :
$ grep -m1 'Invalid user' "$j2" | awk '{ if (match($0, /from [0-9.]+/)) print RSTART, RLENGTH, substr($0, RSTART + 5, RLENGTH - 5) }'
75 18 203.0.113.150
match renvoie la position de la correspondance (75) et fixe RSTART et RLENGTH ; substr en retire les cinq caractères de from . Sans match, il faudrait deux sub successifs ; avec gawk, match accepte un troisième argument qui capture les groupes, une extension non portable.
121 tentatives en quatre jours contre des comptes inexistants (admin, ubuntu, oracle, test), auxquelles s'ajoutent 31 tentatives sur root, de forme différente (Connection closed by authenticating user root ...) : 152 en tout, depuis les trois mêmes adresses. C'est du balayage automatique. Le correctif n'est pas dans awk mais dans le groupe de sécurité des instances, qui ne devrait pas ouvrir le port 22 à internet.
Valeurs venues du shell
Le tableau de bord de l'équipe veut les erreurs d'un hôte au-dessus d'un seuil, deux valeurs choisies au moment de l'appel. La forme correcte :
$ hote=sig-app-2 seuil=499
$ awk -v hote="$hote" -v seuil="$seuil" '$2 == hote && $3 ~ /^python3\[/ && $12 + 0 > seuil + 0' "$j2" | wc -l
107
-v hote="$hote": les guillemets doubles protègent la valeur du découpage en mots du shell ; awk reçoit l'argumenthote=sig-app-2et crée la variable.- Dans le programme,
hoteest une variable, pas un texte recopié : quelle que soit sa valeur, elle ne peut être que comparée. $12 + 0 > seuil + 0rend la comparaison numérique explicite. Le filtre sur$3la rend sûre de toute façon ; les deux précautions se complètent.
Pour comprendre la différence entre -v et ENVIRON, comparons le traitement d'une barre oblique inverse :
$ awk -v m='a\tb' 'BEGIN { print m }'
a b
$ m='a\tb' awk 'BEGIN { print ENVIRON["m"] }'
a\tb
Avec -v, la séquence \t est devenue une tabulation, comme dans une constante de chaîne. Pour un séparateur, c'est pratique (-v OFS='\t'). Pour un chemin Windows, une expression régulière ou un mot de passe, c'est une altération : passez la valeur par l'environnement, et lisez-la dans ENVIRON.
Le troisième mécanisme, l'affectation placée parmi les fichiers, a lieu à son tour :
$ printf 'a 1\nb 7\n' > t.txt
$ awk '$2 > seuil' seuil=5 t.txt
b 7
$ awk '$2 > seuil' t.txt seuil=5
a 1
b 7
Dans le second appel, seuil n'est affectée qu'après la lecture de t.txt : pendant la lecture, elle vaut la chaîne vide, et la comparaison de 1 avec une chaîne vide est textuelle et vraie. Gardez -v pour les paramètres, et réservez ces affectations au changement de séparateur entre deux fichiers (awk '...' FS=: /etc/passwd FS=, export.csv).
Le programme lib/requetes.awk
L'agrégation de la leçon 6 et le rapport de la leçon 8 ont besoin d'une représentation simple des requêtes : une ligne par requête, six colonnes séparées par des tabulations, sans crochets ni guillemets. Un fichier TSV, que sort, cut -f, awk -F'\t' ou un tableur relisent sans ambiguïté, puisque les valeurs ne contiennent pas de tabulation. Voici lib/requetes.awk, dans le dépôt signalements-outils :
# requetes.awk : extrait les requêtes de l'API des journaux syslog de Signalements.
# Entrée : lignes de syslog.log, au format RFC 3339 de rsyslog.
# Sortie : date, heure, hôte, méthode, chemin, code, séparés par des tabulations.
# Les autres lignes (sshd, systemd, noyau, cron) sont ignorées sans message.
# Awk POSIX : fonctionne avec mawk (Debian) et gawk (Ubuntu).
BEGIN { OFS = "\t" }
# Seules les lignes du processus de l'API nous intéressent.
$3 !~ /^python3\[[0-9]+\]:$/ { next }
# L'horodatage doit commencer par une date et une heure complètes.
$1 !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ { next }
{
# La requête est le seul texte entre guillemets : "GET /chemin HTTP/1.1".
if (split($0, partie, "\"") != 3)
next
if (split(partie[2], requete, " ") != 3)
next
if (split(partie[3], suite, " ") < 1 || suite[1] !~ /^[0-9][0-9][0-9]$/)
next
print substr($1, 1, 10), substr($1, 12, 8), $2, requete[1], requete[2], suite[1]
}Le programme est une suite de filtres qui écartent tôt, puis une règle qui produit :
BEGIN { OFS = "\t" }:printséparera ses arguments par des tabulations.- Reconnaître le processus.
$3 !~ /^python3\[[0-9]+\]:$/est plus strict que les essais précédents : le champ doit être exactementpython3[, des chiffres,]:. Un message d'un autre programme qui contiendraitpython3[plus loin n'est pas retenu.nextpasse à la ligne suivante. - Valider l'horodatage. On n'extrait la date et l'heure par position (
substr) qu'après avoir vérifié la forme du champ. Les classes sont répétées plutôt qu'écrites[0-9]{4}: les intervalles{m,n}ne sont activés par défaut dans mawk que depuis la version du 24 juillet 2020, d'après son historique, et Debian 11, Debian 12 et Ubuntu 22.04 fournissent encore mawk 1.3.4-20200120, qui les traite comme du texte littéral. - Découper la requête.
split($0, partie, "\"")coupe la ligne sur les guillemets doubles (le séparateur, un caractère unique, est pris littéralement ;\"est la façon d'écrire un guillemet dans une chaîne awk). Une ligne de requête valide donne exactement trois morceaux : avant, la requête, après. Une ligne de l'API sans requête entre guillemets (un message d'erreur de Python, par exemple) n'en donne qu'un, et elle est écartée. - Découper la requête elle-même en trois mots : méthode, chemin, protocole. Le chemin,
requete[2], garde sa chaîne de requête (?commune=...) intacte, avec son encodage%2C%20. - Vérifier le code : le premier mot après la requête doit compter trois chiffres.
- Produire :
substr($1, 1, 10)donne2026-10-07,substr($1, 12, 8)donne14:02:16(le onzième caractère est leT),$2est l'hôte.
On ne prend pas la date entre crochets, qui est au format de Python (07/Oct/2026) : l'horodatage de rsyslog est plus précis, normalisé, et déjà trié.
Enregistrez le programme dans le dépôt, sous ~/signalements-outils/lib/requetes.awk. Pour que les commandes de cette leçon et des suivantes restent courtes, rendez le répertoire lib du dépôt visible depuis le bac à sable par un lien symbolique :
$ mkdir -p ~/signalements-outils/lib
$ ln -s ~/signalements-outils/lib ~/essais-texte/lib
Depuis ~/essais-texte, lib/requetes.awk désigne alors le fichier du dépôt : c'est lui que l'on modifie, versionne et teste.
Tester le programme
Un programme qui écarte des lignes sans message doit être vérifié : il ne faut perdre aucune requête. D'abord, la sortie a-t-elle la forme promise ?
$ awk -f lib/requetes.awk "$j2" | head -3
2026-10-05 00:00:07 sig-app-2 GET /sante 200
2026-10-05 00:01:07 sig-app-2 GET /sante 200
2026-10-05 00:02:07 sig-app-2 GET /sante 200
$ awk -f lib/requetes.awk "$j2" | cat -A | head -1
2026-10-05^I00:00:07^Isig-app-2^IGET^I/sante^I200$
cat -A affiche les tabulations sous la forme ^I et la fin de ligne sous la forme $ : six colonnes, cinq tabulations, aucune espace parasite. Ensuite, chaque ligne a-t-elle six champs, et en a-t-on autant que de lignes de l'API ?
$ awk -f lib/requetes.awk journaux/*/syslog.log | awk -F'\t' '{ print NF }' | sort | uniq -c
15880 6
$ grep -c 'python3\[' journaux/*/syslog.log
journaux/sig-app-1.pn-signalements.internal/syslog.log:7880
journaux/sig-app-2.pn-signalements.internal/syslog.log:8000
$ awk -f lib/requetes.awk journaux/*/syslog.log | wc -l
15880
7 880 plus 8 000 font 15 880 : aucune ligne de l'API n'a été écartée sur ces données. La vérification croisée par un autre outil (grep -c) est l'essentiel du test : si le format change un jour, l'écart apparaîtra. Enfin, un chemin qui contient une virgule encodée ressort intact :
$ awk -f lib/requetes.awk "$j2" | grep -m1 'Saint'
2026-10-05 02:21:52 sig-app-2 GET /signalements?commune=Saint-Exemple%2C%20centre 200
Utiliser le TSV
Le programme ne filtre pas les vérifications de santé : c'est l'appelant qui décide. Les requêtes des usagers, par hôte :
$ awk -f lib/requetes.awk journaux/*/syslog.log | awk -F'\t' '$5 != "/sante" { print $3 }' | sort | uniq -c
2120 sig-app-1
2240 sig-app-2
Les codes HTTP, hors santé :
$ awk -f lib/requetes.awk journaux/*/syslog.log | awk -F'\t' '$5 != "/sante" { print $6 }' | sort | uniq -c | sort -rn
3495 200
351 201
207 404
193 304
101 503
13 500
Les chemins, une fois les parties variables remplacées par un nom générique (identifiant, nom de fichier, chaîne de requête) :
$ awk -f lib/requetes.awk journaux/*/syslog.log |
awk -F'\t' '$5 != "/sante" {
c = $5
sub(/\?.*/, "", c)
sub(/\/[0-9]+$/, "/ID", c)
sub(/\/[0-9a-f]+\.jpg$/, "/FICHIER.jpg", c)
print $4, c
}' | sort | uniq -c | sort -rn
2974 GET /signalements
634 GET /signalements/ID
372 POST /signalements
284 GET /pieces-jointes/FICHIER.jpg
23 GET /admin/config.php
21 GET /wp-login.php
20 GET /phpmyadmin/
19 GET /.git/config
13 GET /.env
Les cinq dernières lignes sont les sondes de vulnérabilités : des robots qui cherchent un WordPress, un phpMyAdmin, un dépôt Git ou un fichier de secrets laissés en ligne par erreur. Toutes ont reçu un 404 ; leur volume, une centaine en quatre jours, est le bruit de fond normal d'un service exposé.
Et l'incident, minute par minute, que la leçon 1 obtenait par grep | cut | uniq -c :
$ awk -f lib/requetes.awk "$j2" | awk -F'\t' '$6 == 503 { print substr($2, 1, 5) }' | uniq -c | head -5
5 14:02
10 14:03
3 14:04
3 14:05
6 14:06
uniq -c sans sort suffit ici parce que le journal est déjà dans l'ordre chronologique : les minutes identiques sont consécutives.
Mesurer
Est-ce que cela vaut la peine ? Pour le savoir, on mesure sur un volume réaliste. Fabriquez un journal plus gros avec le générateur de la leçon 1, puis concaténez-le vingt fois :
$ python3 preparer-donnees ~/essais-texte-gros --volume 10
$ for i in $(seq 20); do cat ~/essais-texte-gros/journaux/sig-app-1.pn-signalements.internal/syslog.log; done > tres-gros.log
$ wc -l < tres-gros.log
537300
Comptons les codes HTTP de trois façons (mesures prises avec le mot-clé time de Bash, sur un poste de travail récent ; refaites-les chez vous, seuls les ordres de grandeur comptent) :
| Méthode | Durée |
|---|---|
grep 'python3\[' | cut -d' ' -f12 | sort | uniq -c | 0,21 à 0,24 s |
awk '$3 ~ /^python3\[/ { n[$12]++ } END { for (c in n) print n[c], c }' | 0,11 à 0,12 s |
awk -f lib/requetes.awk (toute l'extraction) | 0,27 s |
boucle Bash while read -r _ _ p _ _ _ _ _ _ _ _ code _ qui compte | 3,1 s |
Le tube de quatre processus n'est pas lent : grep et cut sont très efficaces, et les quatre étapes tournent en parallèle sur plusieurs cœurs. awk gagne d'un facteur deux en faisant tout dans un seul processus, et d'un facteur vingt-cinq sur la boucle Bash. La vraie différence n'est pas là, elle est dans ce que chaque méthode sait exprimer : dès qu'il faut tester un champ, en convertir un autre et en combiner un troisième, le tube devient illisible et awk reste une ligne.
Une dernière mesure, instructive :
$ time awk '/python3\[/ { n++ } END { print n }' tres-gros.log
530560
real 0m0.039s
user 0m0.028s
sys 0m0.007s
$ time awk '$3 ~ /^python3\[/ { n++ } END { print n }' tres-gros.log
530560
real 0m0.117s
user 0m0.101s
sys 0m0.007s
$ time awk 'END { print NR }' tres-gros.log
537300
real 0m0.026s
user 0m0.008s
sys 0m0.012s
Une expression régulière sur la ligne entière coûte à peine plus qu'une simple lecture ; dès qu'on touche un champ, le temps est multiplié par deux ou trois. C'est le coût du découpage, qu'awk ne fait que si le programme demande un champ (voir Sous le capot). Pour un premier tri sur un très gros fichier, un motif sur $0 est le plus rapide ; le motif sur $3 reste préférable dès que la précision compte.
Sous le capot
Compiler, puis boucler
awk n'interprète pas le texte du programme ligne après ligne. Au démarrage, il analyse tout le programme, signale les erreurs de syntaxe avant d'avoir lu la moindre donnée, et le traduit dans une représentation interne. mawk, dont c'est la marque de fabrique, le compile en un code pour une petite machine à pile, que l'option -W dump affiche :
$ awk -W dump '$12 == 503 { n++ } END { print n }'
END
000 . pushi n
002 . pushint 1
004 . print
006 . exit0
MAIN
000 . omain
001 . f_pushi $12
004 . pushd 503
006 . eq
007 . jz 013
009 . pusha n
011 . post_inc
012 . pop
013 . ol_gl
On y lit la boucle principale, entre omain et ol_gl, qui marquent le début et la fin de la boucle de lecture : f_pushi $12 empile le douzième champ, pushd 503 la constante, eq les compare, jz 013 saute à la fin de la règle si le résultat est faux, sinon n est incrémentée, puis l'enregistrement suivant est lu. L'action END empile n et l'affiche. C'est ce code, exécuté par une boucle écrite en C, qui explique la vitesse de mawk : pour chaque ligne, une dizaine d'instructions simples, aucune analyse de texte. gawk procède de façon comparable, avec un arbre syntaxique puis un code interne ; son option --dump-variables et son débogueur (gawk -D) montrent une partie de cette mécanique.
Une conséquence pratique : une erreur de syntaxe n'a jamais d'effet partiel.
$ awk '{ print $1 ' "$j2"; echo "code $?"
awk: line 2: missing } near end of file
code 2
Le message de mawk parle de « line 2 » : la ligne où l'analyseur a constaté le problème, pas forcément celle de l'erreur. Le code de sortie est 2, comme pour un fichier introuvable (awk: cannot open "inexistant").
Le découpage à la demande
Les mesures de la partie pratique le montrent : un programme qui ne consulte que $0 va deux fois plus vite qu'un programme qui lit $3. Les implémentations modernes découpent l'enregistrement paresseusement : elles ne calculent les champs qu'au premier accès à un champ ou à NF, et parfois seulement jusqu'au champ demandé. Lire la ligne et y chercher une expression régulière se fait sur un tampon d'octets, sans aucune copie.
Les moteurs d'expressions régulières diffèrent aussi : mawk a son propre moteur, compilé en automate ; gawk utilise celui de gnulib, le même que grep et sed (leçon 2, Sous le capot), avec un automate déterministe quand c'est possible. Dans les deux cas, une expression constante (/.../) est compilée une fois au démarrage, alors qu'une expression dynamique ($3 ~ motif, où motif est une chaîne) peut devoir être recompilée quand la chaîne change. Préférez les constantes quand l'expression ne dépend pas des données.
Pourquoi « 10 » est parfois inférieur à « 9 »
Chaque valeur, en interne, porte des drapeaux : « a une valeur numérique valide », « a une valeur textuelle valide », et, pour les valeurs venues de l'extérieur, « a l'allure d'un nombre ». C'est ce troisième drapeau, posé à la lecture d'un champ, qui fait une chaîne numérique. Une comparaison consulte les drapeaux des deux opérandes et choisit le mode : numérique si les deux le permettent, textuel sinon. Le mode textuel compare selon la locale (strcoll) ; sous LC_ALL=C ou C.UTF-8, c'est l'ordre des octets.
La conversion d'un nombre en chaîne passe par sprintf avec CONVFMT, sauf pour les entiers, toujours convertis en %d. Cela se voit :
$ awk 'BEGIN { x = 0.1 + 0.2; print x; OFMT = "%.17g"; print x; CONVFMT = "%.3f"; z = x ""; print z; print 17 "" }'
0.3
0.30000000000000004
0.300
17
print x applique OFMT ; la concaténation x "" applique CONVFMT ; l'entier 17 échappe aux deux. Les nombres sont des doubles IEEE 754 : les entiers sont exacts jusqu'à 2^53 (environ 9 × 10^15), au-delà ils perdent des chiffres. Un identifiant de 19 chiffres doit rester une chaîne : ne lui appliquez aucun calcul, et comparez-le comme texte.
La locale et la virgule décimale
POSIX demande qu'awk lise son programme avec le point décimal en toute locale, mais que la catégorie LC_NUMERIC de la locale gouverne la lecture des données, les conversions et l'affichage des nombres. mawk applique cette règle à la lettre :
$ echo "2.5 2,5" | LC_ALL=fr_FR.UTF-8 awk '{ print $1 + 1, $2 + 1 }'
3 3,5
$ echo "2.5 2,5" | LC_ALL=C.UTF-8 awk '{ print $1 + 1, $2 + 1 }'
3.5 3
Sous une locale française, 2.5 lu dans les données vaut 2 (la lecture s'arrête au point, qui n'est pas le séparateur décimal), et le résultat s'affiche avec une virgule. Une durée de 0.042 seconde devient 0, sans aucun message. gawk a fait le choix inverse : d'après son manuel, il utilise par défaut le point en entrée comme en sortie, parce que trop d'utilisateurs se plaignaient de scripts cassés ; il n'applique la locale qu'avec --posix, --use-lc-numeric ou la variable POSIXLY_CORRECT, et toujours pour le format %'g. Le même script, sur sig-outils et sur sig-app-1, peut donc donner deux résultats différents selon la locale de la session qui le lance. Un script qui calcule fixe LC_ALL=C.UTF-8.
Pièges courants
Les guillemets doubles autour du programme. awk "{ print $2 }" : le shell remplace $2 par son deuxième paramètre positionnel, probablement vide, et awk reçoit { print }. Le programme s'écrit entre guillemets simples, toujours ; les valeurs entrent par -v.
-F' ' pour une espace unique. Une espace comme séparateur, c'est le comportement par défaut (suites de blancs). Pour couper sur chaque espace : -F'[ ]'.
La virgule oubliée dans print. print $1 $2 colle les deux champs. print $1, $2 les sépare par OFS.
Comparer un champ dont on ne connaît pas la nature. $12 > 499 sur un fichier mélangé compare [preauth] comme une chaîne, et la ligne passe. Filtrez d'abord les lignes de format connu, et forcez le type avec + 0 si le doute subsiste.
Comparer à une constante de chaîne. $12 == "503" compare textuellement et rate 0503 ou 503.0. $12 == 503 compare numériquement si le champ a l'allure d'un nombre.
== au lieu de ~, ou l'inverse. $3 == "sshd" n'est jamais vrai (le champ vaut sshd[24240]:) ; $3 ~ "sshd" l'est, mais aussi pour mysshd. Ancrez : $3 ~ /^sshd\[/.
La variable qui garde la valeur de la ligne précédente. Une variable affectée dans une condition n'est pas remise à zéro d'elle-même. Réinitialisez au début de l'action (src = dpt = "").
NR sur plusieurs fichiers. NR == 1 n'est vrai que pour la première ligne du premier fichier ; pour sauter l'en-tête de chaque CSV, c'est FNR == 1.
Changer FS dans une règle ordinaire. L'enregistrement courant est déjà découpé ; le changement ne vaut qu'à partir du suivant. FS se fixe dans BEGIN ou par -F.
Le motif d'intervalle comme plage de valeurs. /T14:02:/, /T14:20:/ s'ouvre chaque jour et peut ne jamais se refermer. Pour une plage de temps, comparez l'horodatage.
L'intervalle {m,n} sur une vieille mawk. Avant juillet 2020, mawk ne reconnaissait pas [0-9]{4} par défaut : l'expression cherchait le texte {4}. C'est encore le cas de la mawk de Debian 11, de Debian 12 et d'Ubuntu 22.04. Répétez la classe, ou vérifiez la version.
Les nombres sous une locale française. Avec mawk, 0.042 lu sous fr_FR.UTF-8 vaut 0, et printf "%.2f" écrit une virgule. LC_ALL=C.UTF-8 dans tout script qui calcule.
Afficher une variable jamais affectée. END { print n } affiche une ligne vide quand rien n'a été compté. print n + 0, ou printf "%d\n", n.
Un fichier nommé comme une affectation. awk '...' seuil=5 cherche à affecter seuil, pas à lire un fichier nommé seuil=5. Préfixez les noms de fichiers douteux par ./.
Le tampon de sortie dans un tube. Quand awk écrit dans un tube (tail -F journal | awk ... | grep ...), sa sortie est mise en tampon par blocs : rien n'apparaît pendant de longues secondes. fflush() après print, désormais normalisé par POSIX.1-2024, ou mawk -W interactive.
Sécurité
L'injection de code dans le programme. C'est le défaut majeur de la commande de l'introduction. Quand une valeur est collée dans le texte du programme, elle devient du programme. Avec une valeur choisie par un tiers, par exemple un nom d'hôte lu dans un fichier de configuration modifiable, ou passé par un formulaire web à un script de supervision :
$ hote='x" || 1 { system("echo injection réussie"); exit } "' $ awk "\$2 == \"$hote\" { n++ } END { print n + 0 }" "$j2" injection réussie 0Le programme réellement exécuté est
$2 == "x" || 1 { system("echo injection réussie"); exit } "" { n++ } END { print n + 0 }: la valeur a fermé la chaîne, ajouté une condition toujours vraie et une action qui lance une commande du shell. À la place deecho, n'importe quelle commande, avec les droits du compte qui exécute le script. Avec-v hote="$hote", la même valeur reste une chaîne que l'on compare, et le programme affiche0. Les données entrent par-vouENVIRON, jamais par le texte du programme.system()et les tubes d'awk. awk sait lancer des commandes :system("commande"),print ... | "commande","commande" | getline. Une chaîne construite à partir des données et passée à l'un d'eux recrée l'injection, cette fois dans le shell. Si un programme doit appeler une commande par ligne, c'est souvent le signe qu'il faudrait faire l'inverse : qu'awk produise des données, et qu'une boucle Bash sûre (cours Bash, leçon 5) les consomme. gawk propose l'option--sandbox, qui désactivesystem(), les redirections et les tubes : utile pour exécuter un programme awk qui n'est pas de vous.Les données hostiles dans la sortie. Les journaux contiennent des chemins demandés par n'importe quel client, y compris les sondes. Un chemin peut contenir des séquences d'échappement de terminal (leçon 1) ;
lib/requetes.awkles recopie fidèlement. C'est voulu (on ne réécrit pas les preuves), mais affichez ce TSV aveccat -voulessplutôt quecatsi vous ne maîtrisez pas son origine, et ne passez jamais un chemin à un shell.Les programmes exécutables depuis un répertoire partagé. Un
lib/requetes.awkmodifiable par d'autres comptes est un programme qu'ils peuvent changer, avecsystem()compris. Le dépôt est installé parinstall -m 0644dans/opt/signalements/, propriété deroot, comme les scripts du cours Bash.Les données personnelles. Le TSV produit contient des chemins qui peuvent porter des identifiants de signalements, et les journaux JSON contiennent des adresses IP de clients (leçon 7). Un fichier d'extraction est une copie de données personnelles : même durée de conservation que les journaux, pas de copie sur un poste ou dans un ticket sans anonymisation (leçon 3).
En production
- Écrire pour mawk, vérifier avec gawk. Sur un parc Debian et Ubuntu comme celui de Signalements,
awkdésigne deux programmes. Un script destiné à tourner partout s'en tient à POSIX. gawk aide à le vérifier :gawk --posix -f lib/requetes.awkrefuse les extensions de gawk, etgawk --lintsignale les constructions douteuses (variables jamais affectées, comparaisons ambiguës). Ajoutez ces deux contrôles à l'intégration continue du dépôt, à côté de ShellCheck. - Appeler gawk par son nom quand on en a besoin. Si un programme utilise une extension (tri des tableaux, fonctions de date,
FPAT, leçon 6), sa ligne#!et ses appels disentgawk, et le paquetgawkfigure dans les dépendances de l'installation. Un script qui marche sursig-app-1parce queawky est gawk, et casse sursig-outils, est un piège pour la personne d'astreinte. - Fixer la locale.
LC_ALL=C.UTF-8dans l'unité systemd ou en tête du script appelant : ordre des comparaisons textuelles, classes de caractères et séparateur décimal deviennent prévisibles, et identiques d'une machine à l'autre. - Des programmes dans des fichiers, testés. Au-delà de deux lignes, un programme awk va dans
lib/, avec un commentaire d'en-tête qui décrit l'entrée et la sortie. Les tests Bats du cours Bash (leçon 12) s'y appliquent : un petit journal d'entrée versionné, la sortie attendue, et une comparaison. Le test croisé avecgrep -cde cette leçon en est un bon premier cas. - Ne pas écarter en silence sans compter.
lib/requetes.awkignore les lignes qu'il ne comprend pas ; c'est son contrat, mais c'est aussi la façon dont un changement de format de journal passe inaperçu pendant des semaines. En production, l'appelant compare le nombre de lignes produites à un ordre de grandeur attendu, ou une variante du programme compte les lignes écartées et le signale sur la sortie d'erreur (exercice 5). - Suivre un journal en continu. Pour un tableau de bord en direct,
tail -F journal | awk '...'fonctionne, à condition de vider le tampon de sortie (fflush()) et de se souvenir que les actionsENDne s'exécutent qu'à la fin du flux, donc jamais. Les agrégats en continu sont le métier d'un vrai système de journaux centralisés (cours Journaux centralisés avec Loki, à venir) ; awk reste l'outil de l'enquête et du rapport ponctuel.
Exercices
1. Prévoir les champs (niveau 100). Sans exécuter, donnez la sortie de chaque commande, puis vérifiez.
echo 'root:x:0:0:root:/root:/bin/bash' | awk -F: '{ print $1 $7 }'
echo 'root:x:0:0:root:/root:/bin/bash' | awk -F: '{ print NF; print $NF }'
printf 'a b\tc\n' | awk '{ print NF ": " $2 }'
printf 'a b\tc\n' | awk -F'[ ]' '{ print NF ": [" $2 "]" }'
echo 'a b' | awk '{ print $1 $2; print $1, $2 }'Solution
root/bin/bash
7
/bin/bash
3: b
3: []
ab
a bPremière commande : pas de virgule, les deux champs sont concaténés. Deuxième : sept champs, le dernier est le shell. Troisième : par défaut, les suites de blancs (deux espaces, une tabulation) séparent, il y a trois champs et le deuxième est b. Quatrième : avec la classe [ ], chaque espace sépare ; entre les deux espaces, un champ vide ; la tabulation n'est plus un séparateur, et le troisième champ est b, une tabulation, c. Cinquième : concaténation, puis séparation par OFS.
2. Les comptes du système (niveau 100). Avec un seul awk sur /etc/passwd : (a) affichez le nom et le répertoire personnel des comptes dont le shell est /bin/bash ; (b) comptez les comptes dont le shell se termine par nologin, en affichant 0 s'il n'y en a aucun. Comparez avec la boucle while IFS=: read de l'exercice 2 de la leçon 5 du cours Bash.
Solution
awk -F: '$7 == "/bin/bash" { print $1, $6 }' /etc/passwd
awk -F: '$7 ~ /nologin$/ { n++ } END { print n + 0 }' /etc/passwd-F: : un caractère unique, séparateur strict. Le commentaire (cinquième champ) peut contenir des espaces sans gêner. n + 0 affiche 0 quand aucune ligne ne correspond. Par rapport à la boucle Bash, le découpage, le test et le comptage tiennent en une ligne, et le programme reste rapide sur un annuaire de cent mille comptes (getent passwd | awk ...).
3. Les tentatives SSH (niveau 200). Produisez, à partir des deux syslog.log, un TSV des tentatives sur des comptes inexistants : date, heure et minute (HH:MM), adresse source, compte visé. Les quatre premières lignes de sig-app-2 doivent être :
2026-10-05 01:51 203.0.113.150 test
2026-10-05 06:44 203.0.113.150 ubuntu
2026-10-05 10:21 198.51.100.23 admin
2026-10-05 13:52 203.0.113.77 oracleSolution
awk '$3 ~ /^sshd\[/ && $4 == "Invalid" {
printf "%s\t%s\t%s\t%s\n", substr($1, 1, 10), substr($1, 12, 5), $(NF - 2), $6
}' journaux/*/syslog.logLa ligne est ... sshd[N]: Invalid user COMPTE from ADRESSE port PORT : le compte est le sixième champ, l'adresse l'antépénultième. On aurait pu écrire $8 pour l'adresse, mais $(NF - 2) s'appuie sur la fin de la ligne, dont la forme (from ADRESSE port PORT) est la plus stable du message. printf avec \t évite d'avoir à régler OFS. Les lignes Connection closed by authenticating user root ne commencent pas par Invalid et sont écartées : il faudrait une seconde règle pour les tentatives sur root.
4. Le script du tableau de bord (niveau 200). Ce script, erreurs-hote, renvoie trop de lignes, et pire. Trouvez les trois défauts, démontrez au moins deux d'entre eux sur le bac à sable, et corrigez.
#!/usr/bin/env bash
# erreurs-hote HÔTE SEUIL : lignes de l'API de HÔTE dont le code dépasse SEUIL
hote=$1 seuil=$2
awk "\$2 == \"$hote\" && \$12 > $seuil" /srv/donnees/journaux/*/syslog.logSolution
- Les lignes qui ne sont pas de l'API.
$12vaut[preauth]sur une lignesshd,TOS=0x00sur une ligne du noyau : ce ne sont pas des chaînes numériques, la comparaison est textuelle, et[commeTviennent après4. Démonstration :awk "\$2 == \"sig-app-2\" && \$12 > 499" "$j2" | awk '{ print $3 }' | sed 's/\[.*//' | sort | uniq -caffiche 24 ligneskernel:, 36 lignessshdet seulement 107 lignespython3. - L'injection.
hoteetseuilsont collés dans le programme.erreurs-hote 'x" || 1 { system("id") } "' 0exécuteid. Même chose parseuil, qui n'est même pas entre guillemets dans le programme :erreurs-hote sig-app-2 '0 { system("id") }'. - La locale et le type du seuil. Le seuil n'est pas validé :
abcdonne une comparaison textuelle,4.99e2passe. Et sous une locale française, mawk lirait mal un seuil décimal.
Correction :
#!/usr/bin/env bash
# erreurs-hote HÔTE SEUIL : lignes de l'API de HÔTE dont le code dépasse SEUIL
set -euo pipefail
export LC_ALL=C.UTF-8
(( $# == 2 )) || { printf 'Usage : erreurs-hote HÔTE SEUIL\n' >&2; exit 2; }
[[ $2 =~ ^[0-9]{3}$ ]] || { printf 'erreurs-hote : seuil invalide : %s\n' "$2" >&2; exit 2; }
awk -v hote="$1" -v seuil="$2" \
'$2 == hote && $3 ~ /^python3\[[0-9]+\]:$/ && $12 + 0 > seuil + 0' \
/srv/donnees/journaux/*/syslog.logLe programme est entre guillemets simples, les valeurs entrent par -v, le seuil est validé côté shell, les lignes de l'API sont reconnues avant la comparaison, et + 0 rend la comparaison numérique explicite. Sur le bac à sable, hote=sig-app-2 seuil=499 donne 107 lignes.
5. Compter ce qu'on écarte (niveau 200). Modifiez une copie de lib/requetes.awk pour qu'elle compte les lignes du processus python3 qu'elle écarte (horodatage invalide, requête introuvable ou mal formée, code invalide) et, s'il y en a, l'annonce sur la sortie d'erreur à la fin, sans rien changer à la sortie standard. Testez avec le journal de sig-app-2 auquel vous ajoutez cette ligne, qu'écrit le serveur HTTP de Python quand il reçoit une connexion TLS sur un port HTTP :
2026-10-07T15:00:00.000000+00:00 sig-app-2 python3[2817]: 172.16.8.20 - - [07/Oct/2026 15:00:00] code 400, message Bad request syntax ('\x16\x03\x01')Solution
BEGIN { OFS = "\t" }
$3 !~ /^python3\[[0-9]+\]:$/ { next }
$1 !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ { ecartees++; next }
{
if (split($0, partie, "\"") != 3 || split(partie[2], requete, " ") != 3 ||
split(partie[3], suite, " ") < 1 || suite[1] !~ /^[0-9][0-9][0-9]$/) {
ecartees++
next
}
print substr($1, 1, 10), substr($1, 12, 8), $2, requete[1], requete[2], suite[1]
}
END {
if (ecartees > 0)
printf "requetes.awk : %d ligne(s) de l'API écartée(s)\n", ecartees > "/dev/stderr"
}$ { cat "$j2"; cat ligne-400.txt; } | awk -f requetes-compte.awk | wc -l
requetes.awk : 1 ligne(s) de l'API écartée(s)
8000
Les lignes d'autres processus ne sont pas comptées : elles ne sont pas censées produire de requête. Les trois split sont combinés par ||, qui s'arrête au premier vrai : si le premier découpage échoue, les suivants ne sont pas évalués. "/dev/stderr" est un fichier spécial reconnu par gawk et mawk, et qui existe de toute façon sous Linux ; la leçon 6 détaille les redirections de sortie d'awk. Faut-il compter ces lignes dans le rapport ? Elles signalent un client qui parle TLS à un port HTTP : un robot, ou une erreur de configuration d'un répartiteur. C'est une information, pas une requête.
6. Durée et locale (niveau 200). (a) Sous LC_ALL=fr_FR.UTF-8 (installez la locale si besoin), calculez avec awk la somme de 0.042 et 0.958 lus sur l'entrée standard, avec mawk puis avec gawk s'il est installé ; expliquez la différence. (b) Proposez deux façons de rendre un script indépendant de ce comportement.
Solution
(a) Avec mawk : printf '0.042\n0.958\n' | LC_ALL=fr_FR.UTF-8 mawk '{ s += $1 } END { print s }' affiche 0. mawk suit POSIX : sous cette locale, le séparateur décimal des données est la virgule, 0.042 est lu comme 0. gawk, par défaut, ignore la locale pour lire les nombres et affiche 1. Avec gawk --posix, il se comporte comme mawk.
(b) Fixer la locale : LC_ALL=C.UTF-8 en tête du script ou dans l'unité systemd, ce qui règle aussi l'affichage et les comparaisons. Ou, si les données sont réellement à virgule (un export de tableur français), les normaliser avant le calcul (gsub(/,/, ".", $1) sous une locale C). Dans tous les cas, ne jamais laisser un script de calcul hériter de la locale de la personne qui le lance.
Récapitulatif
- Un programme awk est une suite de règles
motif { action }essayées sur chaque enregistrement ;BEGINetENDencadrent la lecture. Motif absent : toutes les lignes ; action absente :print. - Chaque ligne est découpée en champs
$1...$NFselonFS: suites de blancs par défaut, caractère unique littéral, ou expression régulière s'il fait plus d'un caractère.NR,FNR,NF,FILENAMEdécrivent la lecture. - Modifier un champ reconstruit
$0avecOFS; modifier$0redécoupe les champs.print a, bsépare parOFS,print a bconcatène. nextpasse à la ligne suivante ;exitarrête la lecture mais exécuteEND.- Une comparaison est numérique seulement si les deux opérandes sont des nombres ou des chaînes numériques venues de l'extérieur ; sinon elle est textuelle. Filtrez les lignes de format connu, forcez avec
+ 0. - Les horodatages RFC 3339 se comparent comme des chaînes ; un intervalle
/a/, /b/s'ouvre sur un événement, pas sur une valeur. substr(indices à partir de 1),index,split,sub,gsub,match,sprintf,printfcouvrent l'essentiel de l'extraction et de la mise en forme.- Les valeurs du shell entrent par
-v(séquences d'échappement interprétées) ouENVIRON(valeur exacte), jamais collées dans le programme : c'est une injection de code. awkest mawk sur Debian et gawk sur les images cloud d'Ubuntu ; écrivez en POSIX, appelezgawkpar son nom pour ses extensions, fixezLC_ALL=C.UTF-8(mawk lit2.5comme 2 sous une locale française).lib/requetes.awktransforme les lignes de l'API en TSVdate heure hôte méthode chemin code, vérifié contregrep -c.
Pour aller plus loin
- La spécification POSIX d'awk, courte et précise, en particulier les sections sur les expressions, les chaînes numériques et les séparateurs. C'est la référence de ce que l'on peut écrire sans se soucier de l'implémentation.
- The AWK Programming Language, deuxième édition (2023), par les trois auteurs du langage : un livre court, plein d'exemples, qui couvre aussi les ajouts récents (UTF-8,
--csv) de l'implémentation de référence. - GAWK: Effective AWK Programming, le manuel de gawk, qui est aussi le meilleur cours d'awk disponible ; ses encadrés signalent systématiquement ce qui est propre à gawk.
- La page de manuel de mawk et son historique des versions, pour savoir ce que fait l'awk de votre Debian.
- La leçon suivante, awk : agréger, joindre et produire un rapport, fait des tableaux associatifs l'outil principal, joint les exports au référentiel des communes et produit le rapport de la mairie à partir du TSV de
lib/requetes.awk.
Sources
- POSIX.1-2024, utilitaire awk
- GAWK: Effective AWK Programming (manuel de gawk)
- GAWK, section Locale influences conversions (point décimal et locale)
- mawk, page de manuel
- mawk, historique des versions (intervalles {m,n} activés par défaut le 2020-07-24, LC_NUMERIC)
- Aho, Kernighan et Weinberger, The AWK Programming Language, 2e édition (2023)
- The One True Awk, implémentation de référence de Brian Kernighan
- Debian, paquets mawk (Priority: required) et gawk (trixie)
- Ubuntu, paquet gawk (noble), présent dans les tâches server et cloud-image