Arkendia Classrooms
Leçon 7 sur 28 · 18 min

Parser des journaux et des résultats d'outils

Un serveur produit chaque jour des milliers de lignes de journal. Un outil d'analyse en produit autant. Personne ne les lit à la main, et pourtant la réponse à « que s'est-il passé ? » s'y trouve presque toujours. Savoir extraire l'information utile de ces textes, c'est ce qu'on appelle le parsing. Dans cette leçon, vous écrivez un analyseur de journaux d'authentification et un convertisseur de sortie d'outil vers un format structuré.

Données sensibles : prudence

Les journaux contiennent des identifiants, des adresses IP, parfois des données personnelles. En entreprise, leur traitement est soumis au RGPD et à la politique de l'organisation. Dans cette leçon, les exemples sont des données fictives (les adresses appartiennent à une plage réservée à la documentation). Entraînez-vous uniquement sur vos propres journaux de laboratoire, ou sur des échantillons fournis par une plateforme d'entraînement légale.

Ce qu'on cherche dans un journal

Un journal est une suite d'événements datés. Pour un analyste, quelques questions reviennent sans cesse :

  • Combien d'échecs de connexion, et depuis quelles adresses ?
  • Quels comptes sont visés ?
  • Un échec est-il suivi d'une réussite (ce qui peut indiquer qu'un mot de passe a été deviné) ?
  • À quelles heures cela se produit-il ?

Ce sont des questions de comptage et de regroupement. Python les traite très bien, à condition de transformer chaque ligne de texte libre en données structurées.

Les expressions régulières, en deux minutes

Une expression régulière (regex) est un motif qui décrit la forme d'un texte. Quelques symboles suffisent pour commencer :

  • \d+ : un ou plusieurs chiffres ;
  • \S+ : une suite de caractères qui ne sont pas des espaces ;
  • (?P<nom>...) : un « groupe nommé » qui capture la partie correspondante sous un nom ;
  • (?:...) : un groupe facultatif ou non capturant.

La regex ressemble à un hiéroglyphe lorsqu'on la découvre. L'astuce est de la construire par morceaux, en la découpant sur plusieurs lignes, comme dans l'exemple qui suit.

Un journal d'authentification fictif

Voici quelques lignes dans le style d'un service d'accès à distance. Les adresses sont des adresses de documentation, sans lien avec un système réel :

2026-03-02 09:14:01 srv-lab sshd[812]: Failed password for invalid user admin from 192.0.2.10 port 50122 ssh2
2026-03-02 09:14:03 srv-lab sshd[812]: Failed password for invalid user admin from 192.0.2.10 port 50124 ssh2
2026-03-02 09:14:06 srv-lab sshd[813]: Failed password for root from 192.0.2.10 port 50130 ssh2
2026-03-02 09:15:40 srv-lab sshd[820]: Accepted password for camille from 192.0.2.44 port 40022 ssh2
2026-03-02 09:16:12 srv-lab sshd[825]: Failed password for camille from 192.0.2.44 port 40100 ssh2
2026-03-02 09:17:02 srv-lab sshd[830]: Failed password for invalid user test from 192.0.2.10 port 50140 ssh2
2026-03-02 09:17:05 srv-lab sshd[830]: Failed password for invalid user test from 192.0.2.10 port 50142 ssh2

Enregistrez ces sept lignes dans un fichier auth.log. Remarquez le schéma : une date, une heure, un nom d'hôte, le service, puis un message de la forme « Failed password for [invalid user] NOM from ADRESSE ».

Le script d'analyse

import re
from collections import Counter

MOTIF_ECHEC = re.compile(
    r"^(?P<date>\S+ \S+) (?P<hote>\S+) sshd\[\d+\]: "
    r"Failed password for (?:invalid user )?(?P<utilisateur>\S+) "
    r"from (?P<ip>\d+\.\d+\.\d+\.\d+)"
)
SEUIL = 3


def lire_echecs(chemin):
    echecs = []
    with open(chemin, encoding="utf-8") as fichier:
        for ligne in fichier:
            correspondance = MOTIF_ECHEC.match(ligne)
            if correspondance:
                echecs.append(correspondance.groupdict())
    return echecs


def main():
    echecs = lire_echecs("auth.log")
    par_ip = Counter(e["ip"] for e in echecs)
    par_utilisateur = Counter(e["utilisateur"] for e in echecs)

    print(f"{len(echecs)} échec(s) de connexion analysé(s)\n")
    print("Par adresse source :")
    for ip, nombre in par_ip.most_common():
        alerte = "  <-- à examiner" if nombre >= SEUIL else ""
        print(f"  {ip:<15} {nombre}{alerte}")
    print("\nComptes les plus visés :")
    for utilisateur, nombre in par_utilisateur.most_common(3):
        print(f"  {utilisateur:<10} {nombre}")


if __name__ == "__main__":
    main()

Détaillons les idées importantes :

  • re.compile prépare le motif une fois pour toutes. Les lignes qui ne correspondent pas (comme la connexion réussie) sont simplement ignorées.
  • groupdict() transforme les groupes nommés en dictionnaire : chaque échec devient une petite fiche {"ip": ..., "utilisateur": ...}.
  • Counter compte les occurrences. most_common() les renvoie triées du plus fréquent au moins fréquent.
  • SEUIL est une constante en majuscules, facile à ajuster. Un seuil n'est jamais une vérité : c'est un paramètre qu'on règle selon le contexte.

Le résultat

6 échec(s) de connexion analysé(s)

Par adresse source :
  192.0.2.10      5  <-- à examiner
  192.0.2.44      1

Comptes les plus visés :
  admin      2
  test       2
  root       1

Un même poste essaie cinq fois en quelques minutes plusieurs noms de compte génériques (admin, root, test), dont certains n'existent pas : cela ressemble à une tentative de devinette de comptes. Un échec isolé pour camille est probablement une simple faute de frappe. Le script ne tranche pas ; il met en évidence. L'interprétation reste humaine, et c'est ce que vous écrirez dans un rapport.

Parser la sortie d'un outil

Les outils de sécurité produisent des résultats lisibles par un humain mais peu pratiques pour un autre programme. Un exemple de sortie simplifiée d'un scanner de ports, dans votre laboratoire :

PORT     STATE  SERVICE
22/tcp   open   ssh
80/tcp   open   http
443/tcp  closed https
3306/tcp open   mysql

On peut la transformer en données structurées, puis filtrer et exporter au format JSON :

import json

SORTIE_OUTIL = """\
PORT     STATE  SERVICE
22/tcp   open   ssh
80/tcp   open   http
443/tcp  closed https
3306/tcp open   mysql
"""


def parser_ports(texte):
    ports = []
    for ligne in texte.splitlines()[1:]:
        morceaux = ligne.split()
        if len(morceaux) != 3:
            continue
        port_protocole, etat, service = morceaux
        port, _, protocole = port_protocole.partition("/")
        ports.append({"port": int(port), "protocole": protocole,
                      "etat": etat, "service": service})
    return ports


ouverts = [p for p in parser_ports(SORTIE_OUTIL) if p["etat"] == "open"]
print(json.dumps(ouverts, indent=2))

La liste en compréhension [p for p in ... if ...] est une écriture compacte de « garde les éléments qui vérifient la condition ». Le format JSON est l'espéranto des outils : presque tous les programmes savent le lire et l'écrire, ce qui permet d'enchaîner vos scripts avec d'autres outils ou de les alimenter dans un SIEM (chapitre 4).

Beaucoup d'outils proposent d'ailleurs directement une sortie JSON ou XML : lorsque c'est possible, préférez-la au découpage de texte, plus fragile. Mais savoir parser du texte reste précieux, car les journaux, eux, sont rarement bien structurés.

À vous de jouer

Modifiez le script d'analyse pour qu'il détecte le motif suivant : un échec suivi d'une réussite pour le même compte depuis la même adresse. Ajoutez pour cela une seconde expression régulière qui capture les lignes « Accepted password », puis comparez.

Voir une solution

Dans notre échantillon, le compte camille a une réussite à 09:15:40 puis un échec à 09:16:12 : l'ordre est inverse (réussite puis échec), donc il ne s'agit pas du motif recherché. Pour détecter le bon motif, parcourez les lignes dans l'ordre chronologique en conservant, dans un dictionnaire, la liste des couples (compte, adresse) ayant déjà échoué ; lorsqu'une ligne « Accepted » apparaît pour un couple déjà présent, signalez-la.

MOTIF_REUSSITE = re.compile(
    r"Accepted password for (?P<utilisateur>\S+) from (?P<ip>\d+\.\d+\.\d+\.\d+)"
)

deja_echoue = set()
for ligne in lignes:
    echec = MOTIF_ECHEC.match(ligne)
    if echec:
        deja_echoue.add((echec["utilisateur"], echec["ip"]))
        continue
    reussite = MOTIF_REUSSITE.search(ligne)
    if reussite and (reussite["utilisateur"], reussite["ip"]) in deja_echoue:
        print("Echec(s) puis réussite :", reussite["utilisateur"], reussite["ip"])

Un tel résultat n'est pas une preuve d'intrusion, mais un indice à vérifier : l'utilisateur a peut-être simplement fini par se souvenir de son mot de passe.

Tester une regex

Commencez toujours par un petit échantillon de lignes et vérifiez ce que votre motif capture, avant de l'appliquer à un énorme fichier. Un motif trop permissif ou trop strict fausse toute l'analyse sans prévenir.

À retenir

  • Parser, c'est transformer du texte libre en données structurées exploitables.
  • Les expressions régulières décrivent la forme d'une ligne ; construisez-les par morceaux et testez-les sur un échantillon.
  • Counter répond à la plupart des questions de comptage ; JSON facilite l'échange entre outils.
  • Un script met en évidence, l'analyste interprète : un seuil n'est pas une preuve.
  • Les journaux contiennent des données personnelles : traitez-les avec prudence et uniquement dans votre cadre autorisé.
Publicité
Connectez-vous pour enregistrer votre progression. Se connecter