Aller au contenu
awk : un langage pour les lignes et les champs

awk : un langage pour les lignes et les champs

200 Compagnon ⏱ 1 h 50 awkbashlinuxdebianubuntu

À la fin, vous saurez

  • Lire un programme awk comme une suite de règles motif { action } appliquées à chaque enregistrement, avec BEGIN et END
  • Choisir le séparateur de champs adapté (suites de blancs, caractère unique, expression régulière) et prévoir le contenu de $1, $NF et NF
  • Prévoir si awk compare deux valeurs comme des nombres ou comme des chaînes, et écrire des conditions qui ne laissent pas passer de lignes parasites
  • Extraire et reformater des champs avec substr, index, split, sub, gsub, match et printf
  • Passer des valeurs du shell à un programme awk sans l'exposer à une injection de code
  • Écrire un programme awk portable entre mawk et gawk, rangé dans un fichier, testé sur des données réelles et mesuré

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) gawk 5.2.1 (Ubuntu 24.04 et Debian 13) mawk 1.3.4-20240123 (Ubuntu 24.04), 1.3.4-20250131 (Debian 13) , vérifié le 9 octobre 2026

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.log

avec 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 == 503 seul affiche les lignes dont le douzième champ vaut 503, comme un grep qui 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ègles BEGIN ne 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 :

VariableContenu
$0l'enregistrement entier, sans son séparateur final
$1, $2...les champs ; $n accepte une expression : $NF, $(NF - 1), $i
NFle nombre de champs de l'enregistrement courant
NRle nombre d'enregistrements lus depuis le début, tous fichiers confondus
FNRle numéro de l'enregistrement dans le fichier courant
FILENAMEle nom du fichier en cours de lecture
FSle séparateur de champs en lecture (field separator), une espace par défaut
OFSle séparateur de champs en sortie (output field separator), une espace par défaut
RSle séparateur d'enregistrements en lecture, le saut de ligne par défaut
ORSle 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 :

  1. FS vaut 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.
  2. FS vaut 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.
  3. FS a plus d'un caractère : c'est une expression régulière étendue (ERE, celles de grep -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 $3

En 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ù debut est vrai, jusqu'à et y compris le prochain où fin est vrai ; puis la recherche de debut reprend. 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

  • next abandonne l'enregistrement courant : les règles suivantes ne sont pas essayées, awk lit l'enregistrement suivant. C'est le continue des 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 actions END s'exécutent quand même, comme l'impose POSIX ; un exit placé dans END termine immédiatement. Le code donné devient le code de sortie d'awk, sauf si un autre exit le remplace dans END.

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" + 0 vaut 42, "abc" + 0 vaut 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 (%.6g par défaut) s'il n'est pas entier, et comme un entier s'il l'est. print utilise un autre format, OFMT, également %.6g par défaut. 0.1 + 0.2 s'affiche donc 0.3, bien que la valeur stockée soit 0.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.

ExpressionComparaisonRésultat
champs 10 et 9, $1 > $2numé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 > $2textuelle : 9x n'a pas l'allure d'un nombrefaux
champ [preauth], $12 > 499textuelle : le champ n'est pas numériquevrai : [ vient après 4
champs 1e1 et 10, $1 == $2numériquevrai
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 :

FonctionRô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=valeur avant le programme : l'affectation a lieu avant BEGIN. La valeur subit le traitement des séquences d'échappement d'une chaîne awk : \t devient 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ès BEGIN et entre deux fichiers ; elle sert à changer FS ou 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.log

Le 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émentationOù on la trouveCaractère
mawk (Mike Brennan, maintenu par Thomas Dickey)awk par défaut sur Debian 13, Ubuntu minimal et les images de conteneur Debian et Ubunturapide, compile le programme en code interne, proche de POSIX
gawk (GNU awk)awk sur les installations serveur et les images cloud d'Ubuntu, Fedora, Archnombreuses extensions, mode --posix
The One True Awk (BWK awk)awk de macOS et des BSD, paquet original-awk sur Debianl'implémentation de référence de Kernighan, mise à jour pour la 2e édition du livre (UTF-8, option --csv)
BusyBox awkimages Alpine et systèmes embarquéscompacte, 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 == 2 restreint le programme à la deuxième ligne.
  • La boucle for reprend la syntaxe du C. $i est le champ dont le numéro est la valeur de i.
  • 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 $3 dans une variable p avant de la modifier : sub sur $3 lui-même reconstruirait $0, inutilement.
  • Le premier sub supprime tout ce qui suit le premier crochet (python3[790]: devient python3) ; le second retire le deux-points final de kernel:, 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
  • n vaut 0 (non initialisée) à la première erreur : on retient son horodatage.
  • dernier est é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 %)
  • if dans une action s'écrit comme en C ; la condition est une expression awk.
  • %-10s aligne le nom à gauche sur dix colonnes, %8d et %5d alignent les nombres à droite, %.2f arrondit à deux décimales, %% affiche le signe.
  • La division 100 * e / n est décimale : awk n'a pas d'entiers. Si n pouvait valoir 0, il faudrait le tester avant : gawk s'arrête alors sur une erreur fatale, tandis que mawk affiche inf ou nan sans 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 sans DPT= 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 de SRC= ou DPT=. Pour une clé de longueur variable, on écrirait substr($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'argument hote=sig-app-2 et crée la variable.
  • Dans le programme, hote est une variable, pas un texte recopié : quelle que soit sa valeur, elle ne peut être que comparée.
  • $12 + 0 > seuil + 0 rend la comparaison numérique explicite. Le filtre sur $3 la 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" } : print sé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 exactement python3[, des chiffres, ]:. Un message d'un autre programme qui contiendrait python3[ plus loin n'est pas retenu. next passe à 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) donne 2026-10-07, substr($1, 12, 8) donne 14:02:16 (le onzième caractère est le T), $2 est 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éthodeDurée
grep 'python3\[' | cut -d' ' -f12 | sort | uniq -c0,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 compte3,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
    0
    

    Le 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 de echo, 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 affiche 0. Les données entrent par -v ou ENVIRON, 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ésactive system(), 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.awk les recopie fidèlement. C'est voulu (on ne réécrit pas les preuves), mais affichez ce TSV avec cat -v ou less plutôt que cat si 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.awk modifiable par d'autres comptes est un programme qu'ils peuvent changer, avec system() compris. Le dépôt est installé par install -m 0644 dans /opt/signalements/, propriété de root, 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, awk désigne deux programmes. Un script destiné à tourner partout s'en tient à POSIX. gawk aide à le vérifier : gawk --posix -f lib/requetes.awk refuse les extensions de gawk, et gawk --lint signale 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 disent gawk, et le paquet gawk figure dans les dépendances de l'installation. Un script qui marche sur sig-app-1 parce que awk y est gawk, et casse sur sig-outils, est un piège pour la personne d'astreinte.
  • Fixer la locale. LC_ALL=C.UTF-8 dans 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é avec grep -c de cette leçon en est un bon premier cas.
  • Ne pas écarter en silence sans compter. lib/requetes.awk ignore 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 actions END ne 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 b

Premiè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	oracle
Solution
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.log

La 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.log
Solution
  1. Les lignes qui ne sont pas de l'API. $12 vaut [preauth] sur une ligne sshd, TOS=0x00 sur une ligne du noyau : ce ne sont pas des chaînes numériques, la comparaison est textuelle, et [ comme T viennent après 4. Démonstration : awk "\$2 == \"sig-app-2\" && \$12 > 499" "$j2" | awk '{ print $3 }' | sed 's/\[.*//' | sort | uniq -c affiche 24 lignes kernel:, 36 lignes sshd et seulement 107 lignes python3.
  2. L'injection. hote et seuil sont collés dans le programme. erreurs-hote 'x" || 1 { system("id") } "' 0 exécute id. Même chose par seuil, qui n'est même pas entre guillemets dans le programme : erreurs-hote sig-app-2 '0 { system("id") }'.
  3. La locale et le type du seuil. Le seuil n'est pas validé : abc donne une comparaison textuelle, 4.99e2 passe. 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.log

Le 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 ; BEGIN et END encadrent la lecture. Motif absent : toutes les lignes ; action absente : print.
  • Chaque ligne est découpée en champs $1... $NF selon FS : 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, FILENAME décrivent la lecture.
  • Modifier un champ reconstruit $0 avec OFS ; modifier $0 redécoupe les champs. print a, b sépare par OFS, print a b concatène.
  • next passe à la ligne suivante ; exit arrête la lecture mais exécute END.
  • 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, printf couvrent 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) ou ENVIRON (valeur exacte), jamais collées dans le programme : c'est une injection de code.
  • awk est mawk sur Debian et gawk sur les images cloud d'Ubuntu ; écrivez en POSIX, appelez gawk par son nom pour ses extensions, fixez LC_ALL=C.UTF-8 (mawk lit 2.5 comme 2 sous une locale française).
  • lib/requetes.awk transforme les lignes de l'API en TSV date heure hôte méthode chemin code, vérifié contre grep -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.
+20 XP Carte du ciel →Mon cosmonaute →

Sources