API REST avec requests, pilotage CLI avec Netmiko, parsing de logs et IOC, mini-projets complets
Dialoguer avec des API : GET, POST, authentification, codes de statut, pagination, exploitation du JSON.
Une API REST expose des ressources via des URL, manipulées avec les verbes HTTP : GET (lire), POST (créer), PUT/PATCH (modifier), DELETE (supprimer). Le serveur répond avec un code de statut : 2xx succès, 4xx erreur client, 5xx erreur serveur :
# Codes a connaitre par coeur
# 200 OK | 201 Created | 204 No Content
# 400 Bad Request | 401 Unauthorized | 403 Forbidden | 404 Not Found
# 429 Too Many Requests | 500 Internal Server Errorrequests.Les API réseau (NetBox pour l'inventaire, contrôleurs SDN, plateformes SOC) renvoient du JSON et exigent un token d'authentification passé dans l'en-tête Authorization. Les volumes importants se parcourent par pagination (paramètres limit / offset ou page suivante).
GET /devices avec en-tête Authorization. État : demande envoyée.?limit=50&offset=50. État : inventaire complet.Réponse : 201 Created pour une création réussie, 401 Unauthorized = token manquant ou invalide, il faut vérifier ses identifiants.
Explication : 200 = lecture OK, 201 = création OK, 401 = authentification à refaire.
La bibliothèque requests (pip install requests) simplifie le HTTP. requests.get() envoie la requête, .status_code donne le code, .json() convertit la réponse JSON en dictionnaire :
import requests
url = "https://api.example.com/devices"
headers = {"Authorization": "Token MON_TOKEN_SECRET"}
try:
r = requests.get(url, headers=headers, timeout=10)
r.raise_for_status() # leve une erreur si code 4xx/5xx
for eq in r.json()["results"]:
print(eq["hostname"], "-", eq["ip"])
except requests.RequestException as e:
print(f"Erreur API : {e}")get()), le standard vérifie ton badge (raise_for_status()), puis te lit les fiches (.json()["results"]).Imposez toujours un timeout (sinon le script peut attendre indéfiniment) et appelez raise_for_status() pour transformer les erreurs HTTP en exceptions traitables. Les paramètres d'URL passent par params={"limit": 50}.
requests.get(url, headers=..., timeout=10) → réponse 200.r.raise_for_status() → rien levé, on continue.r.json()["results"] → ex. [{« hostname »: « R1 », « ip »: « 10.0.0.1 »}, {« hostname »: « SW1 », ...}].requests.RequestException → bloc except → « Erreur API : ... ».timeout : le script peut attendre indéfiniment un serveur mort. Oublier raise_for_status() : tu parses une page d'erreur 404 comme du JSON et ça explose plus loin. Appeler .json() sur une réponse non-JSON. Coller le token en dur dans le script versionné.lister_equipements(url, token) en cas d'erreur réseau, et pourquoi ?Réponse : une liste vide [].
Explication : l'appelant peut toujours boucler dessus sans tester None, et le except requests.RequestException avec timeout garantit qu'on ne plante pas.
lister_equipements(url, token) qui retourne la liste des hostnames lus dans la clé « results », ou une liste vide en cas d'erreur (try/except + timeout).requests.post() envoie des données avec le paramètre json= (sérialisation automatique + bon Content-Type). On vérifie le code 201 et on exploite la réponse pour récupérer l'objet créé :
import requests
payload = {"hostname": "SW3", "ip": "10.0.0.5", "role": "access"}
r = requests.post("https://api.example.com/devices",
json=payload, headers={"Authorization": "Token X"},
timeout=10)
if r.status_code == 201:
print("Cree :", r.json()["hostname"])
elif r.status_code == 401:
print("Token invalide : verifiez vos identifiants")
else:
print(f"Echec ({r.status_code}) : {r.text}")json=payload), le guichet tamponne 201 (accepté, créé) ou te le rend avec le motif du refus (401, 400).Ne stockez jamais un token en dur dans le code : lisez-le depuis une variable d'environnement (os.environ["API_TOKEN"]) ou un fichier de configuration exclu du versionnement.
requests.post(..., json=payload, timeout=10) → json= sérialise et pose le bon Content-Type.r.json()["hostname"] = « SW3 » → affiche « Cree : SW3 ».data= au lieu de json= : le serveur reçoit du formulaire au lieu du JSON et refuse. Tester == 200 après un POST : une création réussie renvoie 201. Laisser le token en dur dans le code. Ne gérer aucun timeout ni aucun code autre que le succès.creer_alerte(url, token, ip, niveau) si le serveur répond 201 ? Et s'il répond 401 ?Réponse : True si 201, False sinon (donc False pour 401).
Explication : l'appelant teste le booléen pour journaliser « alerte créée » ou « échec d'envoi », sans parser lui-même les codes.
creer_alerte(url, token, ip, niveau) qui POSTE le dictionnaire correspondant et retourne True si le code est 201, False sinon.Piloter des équipements en SSH : connexion, envoi de show, déploiement de configuration, sauvegardes.
Netmiko (pip install netmiko) pilote les CLI réseau en SSH. On décrit l'équipement par un dictionnaire (type, hôte, identifiants) et on utilise ConnectHandler en gestionnaire de contexte pour fermer proprement la session :
from netmiko import ConnectHandler
r1 = {
"device_type": "cisco_ios",
"host": "10.0.0.1",
"username": "admin",
"password": "mot_de_passe_lab",
}
with ConnectHandler(**r1) as ssh:
sortie = ssh.send_command("show ip interface brief")
print(sortie)show ip interface brief sur 50 équipements à la main prend une matinée et trois fautes de frappe. Netmiko fait la connexion SSH, envoie la commande et rend la sortie, équipement par équipement, sans erreur.device_type choisit l'adaptateur (cisco_ios, arista_eos...), le dictionnaire donne l'adresse et les piles (identifiants), ConnectHandler appuie sur les boutons puis éteint proprement.Types courants : cisco_ios, cisco_nxos, arista_eos, juniper_junos, linux. Stockez les identifiants hors du code (variables d'environnement ou coffre) et testez d'abord en lecture seule (show) avant toute écriture.
ConnectHandler(**r1) → session SSH ouverte (le ** déballe le dict en arguments).ssh.send_command("show ip interface brief") → sortie texte de l'équipement.with → session fermée automatiquement, même en cas d'erreur.device_type : les prompts ne sont pas reconnus et la session échoue. Identifiants en dur dans le script versionné. Session ouverte sans with et jamais fermée : le pool SSH de l'équipement sature. Commencer par de l'écriture avant d'avoir validé la lecture seule.collecter(hote, commandes) et à quoi ressemble son contenu pour ["show version"] ?Réponse : un dict {commande: sortie}, ex. {« show version »: « Cisco IOS Software, ... »}.
Explication : la clé est la commande envoyée, la valeur le texte brut renvoyé par l'équipement via send_command().
collecter(hote, commandes) qui se connecte, exécute chaque commande show de la liste et retourne un dict {commande: sortie}.send_config_set() envoie une liste de commandes en mode configuration. En bouclant sur un inventaire CSV, on déploie la même politique (VLAN, banner, NTP) sur tout le parc en quelques secondes :
import csv
from netmiko import ConnectHandler
config_vlan = ["vlan 30", "name INVITES", "exit",
"interface range Fa0/5-8",
"switchport mode access",
"switchport access vlan 30"]
with open("parc.csv", encoding="utf-8", newline="") as f:
for eq in csv.DictReader(f):
dev = {"device_type": "cisco_ios", "host": eq["ip"],
"username": "admin", "password": "mot_de_passe_lab"}
with ConnectHandler(**dev) as ssh:
print(ssh.send_config_set(config_vlan))
ssh.save_config() # = write memorysend_config_set() déploie la même politique partout en quelques secondes, avec journalisation.config_vlan), le script l'imprime et la poste à chaque adresse de l'annuaire (chaque ligne du CSV).Bonnes pratiques : déployez d'abord sur un équipement pilote, journalisez chaque résultat avec logging, et prévoyez un plan de retour arrière (sauvegarde préalable, leçon 10.3).
send_config_set(config_vlan) → 6 commandes appliquées → save_config() → affiche le retour.try/except par hôte : le premier équipement injoignable arrête toute la boucle. Oublier save_config() : la config disparaît au reboot.Réponse : ssh.send_config_set(config_vlan) puis ssh.save_config() (équivalent de write memory).
Explication : la première applique en running-config, la seconde sauvegarde pour survivre au redémarrage.
banner motd #...#) sur les équipements du CSV, avec try/except autour de chaque connexion.Avant tout changement, sauvegardez la running-config avec un nom horodaté. Ensuite, parsez les sorties show avec des regex pour en extraire des données structurées (interfaces down, VLAN, routes) :
import re
from datetime import datetime
from netmiko import ConnectHandler
with ConnectHandler(**r1) as ssh:
conf = ssh.send_command("show running-config")
nom = f"sauvegardes/R1_{datetime.now():%Y%m%d_%H%M}.cfg"
with open(nom, "w", encoding="utf-8") as f:
f.write(conf)
brief = ssh.send_command("show ip interface brief")
down = re.findall(r"^(\S+)\s+\S+\s+\S+\s+\S+\s+down",
brief, re.MULTILINE)
print("Interfaces down :", down)show détecte les interfaces down avant que les utilisateurs appellent.Planifiez ce script (tâche cron ou planificateur Windows) pour une sauvegarde quotidienne, et versionnez les sauvegardes avec Git pour tracer chaque changement de configuration.
send_command("show running-config") → texte conf.nom = "sauvegardes/R1_20260909_1430.cfg" via datetime.now() → écriture du fichier.brief = sortie du show → re.findall(r"^(\S+)\s+\S+\s+\S+\s+\S+\s+down", brief, re.MULTILINE) → ex. [« Gi0/2 »] : chaque ligne dont le statut est down est capturée par le premier groupe.sauvegardes/ n'existe pas → FileNotFoundError à l'écriture : crée-le avant. Regex de show fragile : une version d'OS différente change les colonnes. Tâche planifiée avec chemin relatif : elle s'exécute ailleurs et ne trouve rien, utilise des chemins absolus. Accents sans encoding="utf-8".show ip interface brief contenant deux lignes au statut down, que contient la liste down ?Réponse : les deux noms d'interfaces, ex. ['Gi0/1', 'Gi0/2'].
Explication : findall() avec re.MULTILINE capture le premier groupe de chaque ligne qui se termine par down.
Du log brut au rapport d'incident : parser, compter, croiser avec des IOC et générer des alertes.
Les logs (pare-feu, proxy, IDS) sont des lignes semi-structurées. La recette SOC : lire ligne par ligne (jamais tout en mémoire pour les gros fichiers), extraire les champs par regex, filtrer les événements intéressants :
import re
motif = re.compile(r"SRC=(?P<src>\d+\.\d+\.\d+\.\d+).*DPT=(?P<port>\d+)")
evenements = []
with open("firewall.log", encoding="utf-8") as f:
for ligne in f:
if "DROP" in ligne:
m = motif.search(ligne)
if m:
evenements.append(m.groupdict())
print(f"{len(evenements)} paquets bloques analyses")if "DROP" in ligne jette d'abord les cartons « ALLOW », puis la regex ouvre les cartons restants et en sort les étiquettes src et port.m.groupdict() retourne directement un dictionnaire des groupes nommés — parfait pour alimenter un CSV ou une API. Filtrez tôt (ici « DROP ») pour ne parser que l'utile : c'est 10 fois plus rapide sur des millions de lignes.
"DROP" in ligne → True, on garde.motif.search(ligne) → match, groupes src = 203.0.113.7, port = 443.m.groupdict() → {« src »: « 203.0.113.7 », « port »: « 443 »} ajouté à evenements..read() : 2 Go de logs saturent la RAM, itère ligne par ligne. Parser chaque ligne sans pré-filtre : la regex sur des millions de lignes ALLOW est lente. Groupe nommé mal fermé (?P<src>...) → erreur de syntaxe regex. Oublier l'encodage à l'ouverture.evenements à la fin ?Réponse : 2.
Explication : seules les lignes contenant DROP passent le filtre, et chacune ajoute un dict {src, port} via groupdict().
collections.Counter compte les occurrences en une ligne : top talkers, ports les plus attaqués, codes HTTP anormaux. On croise ensuite avec une liste d'IOC (indicateurs de compromission : IP, domaines, hash malveillants) via des sets :
from collections import Counter
sources = [e["src"] for e in evenements]
top = Counter(sources).most_common(5)
print("Top sources bloquees :", top)
ioc = {"5.6.7.8", "9.9.9.9"} # issus du flux de threat intel
detectees = set(sources) & ioc
for ip in detectees:
print(f"CRITIQUE : IOC connu detecte : {ip}")Counter dépouille les bulletins (les src) et donne le top 5, puis le set compare la liste des votants à la liste des suspects (les IOC) par intersection.Ajoutez un seuil (ex. plus de 10 tentatives) pour distinguer le bruit de fond des vraies attaques, et journalisez chaque détection avec horodatage pour la traçabilité de l'incident.
Counter(sources) → {« 1.1.1.1 »: 3, ...}..most_common(5) → [(« 1.1.1.1 », 3), ...].set(sources) & {"5.6.7.8", "9.9.9.9"} → ex. {« 5.6.7.8 »} si présente → affiche « CRITIQUE : IOC connu detecte : 5.6.7.8 ».Counter ou au set au lieu des chaînes src. Oublier que le set perd l'ordre : trie pour le rapport. most_common() sans argument sur un million de clés affiche tout.from collections import Counter
c = Counter(["1.1.1.1", "2.2.2.2", "1.1.1.1", "3.3.3.3", "1.1.1.1"])
print(c.most_common(1))
print("1.1.1.1" in {"1.1.1.1"})Sortie :[('1.1.1.1', 3)]
True
Explication : « 1.1.1.1 » apparaît 3 fois, c'est le top 1, et elle figure bien dans l'ensemble d'IOC.
Counter, trouvez l'IP la plus fréquente de ["1.1.1.1", "2.2.2.2", "1.1.1.1", "3.3.3.3", "1.1.1.1"] et vérifiez si elle figure dans ioc = {"1.1.1.1"}.Un rapport SOC horodaté, chiffré et actionnable : période couverte, volumes, top 5, IOC détectés, recommandations. On le génère en texte et en CSV pour l'outil de ticketing :
import csv
from datetime import datetime
def generer_rapport(evenements, detectees, seuil=10):
lignes = [f"Rapport SOC - {datetime.now():%Y-%m-%d %H:%M}",
f"Evenements analyses : {len(evenements)}",
f"IOC detectes : {len(detectees)}"]
for ip in sorted(detectees):
lignes.append(f" - {ip} : blocage immediat recommande")
texte = "\n".join(lignes)
with open("rapport_soc.txt", "w", encoding="utf-8") as f:
f.write(texte)
return texteAutomatisez l'envoi (e-mail, webhook, API du SIEM) et archivez chaque rapport : un SOC doit pouvoir prouver ce qui a été détecté, quand, et quelle action a été recommandée.
"\n".join(lignes) → texte final → écrit dans rapport_soc.txt → renvoyé aussi à l'appelant.newline="" : lignes vides sous Windows. Rapport non archivé : aucune traçabilité en cas d'audit.def resume(evenements):
srcs = {e["src"] for e in evenements}
return f"{len(evenements)} evenements, {len(srcs)} sources uniques"
print(resume([{"src": "a", "port": "80"}, {"src": "b", "port": "22"}]))Sortie : 2 evenements, 2 sources uniques.
Explication : 2 événements et un set de 2 sources distinctes (« a », « b »).
resume(evenements) qui retourne une chaîne « X événements, Y sources uniques » à partir d'une liste de dicts {src, port}.Deux projets complets qui combinent tout le parcours : scanner de ports et générateur de rapports.
Le module socket ouvre des connexions TCP avec un timeout court : si la connexion réussit, le port est ouvert. On boucle sur une liste de ports courants et on structure le résultat :
import socket
def scanner(hote, ports, timeout=1.0):
"""Retourne le dict {port: True/False} des ports ouverts."""
resultats = {}
for p in ports:
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((hote, p))
resultats[p] = True
except OSError:
resultats[p] = False
finally:
s.close()
return resultats
print(scanner("127.0.0.1", [22, 80, 443]))socket avec timeout court donne un verdict ouvert/fermé fiable, à condition de ne scanner que ton lab.connect() réussit), la porte est ouverte ; si personne ne répond (exception OSError), elle est fermée. Le timeout, c'est le temps max avant de partir.Limites à connaître : scannez uniquement vos machines de lab, utilisez des timeouts courts, parallélisez avec concurrent.futures pour les gros périmètres, et journalisez chaque résultat.
scanner("127.0.0.1", [22, 80, 443]) :connect(("127.0.0.1", 22)) réussit → resultats[22] = True → close().OSError (refusé) → False → close() dans finally.s.close() (ou le finally) : les descripteurs saturent. Scanner séquentiellement un /24 entier : trop lent, parallélise avec concurrent.futures.{22: True, 80: False, 443: True}, qu'affiche afficher() si elle liste les ports ouverts ?Réponse : les ports 22 et 443, ex. Ouverts : [22, 443].
Explication : elle filtre le dict sur les valeurs True : [p for p, ouvert in resultats.items() if ouvert].
scanner() un paramètre ports_bien_connus = [21, 22, 23, 25, 53, 80, 443, 3389] par défaut et une fonction afficher(resultats) qui liste les ports ouverts.Le projet final combine les modules 5, 8 et 11 : lire un CSV d'événements, filtrer par regex, compter avec Counter, croiser des IOC, écrire un rapport texte + CSV. Chaque étape est une fonction testable :
import csv
from collections import Counter
def charger(csv_path):
with open(csv_path, encoding="utf-8", newline="") as f:
return list(csv.DictReader(f))
def top_sources(lignes, n=5):
return Counter(l["src"] for l in lignes).most_common(n)
def exporter_csv(top, out="top_sources.csv"):
with open(out, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["ip", "tentatives"])
w.writerows(top)
lignes = charger("evenements.csv")
top = top_sources(lignes)
exporter_csv(top)
print("Top :", top)charger lit le CSV), le poste 2 trie et compte (top_sources avec Counter), le poste 3 emballe (exporter_csv écrit le résultat).Présentez ce projet avec un README (objectif, installation, exemple d'exécution) : c'est exactement ce qu'un recruteur ou un jury veut voir.
charger("evenements.csv") → liste de dicts, ex. 100 lignes.top_sources(lignes, 5) → Counter(l["src"] for l in lignes).most_common(5) → ex. [(« 1.1.1.1 », 42), ...].exporter_csv(top) → écrit en-tête ip,tentatives + une ligne par tuple. État affiché : « Top : [...] ».list(DictReader) sur un fichier géant sature la RAM : traite en streaming quand c'est massif. Écrire avec writerows sans en-tête : le CSV est inexploitable. Fonctions jamais testées seules : l'erreur se cache dans l'assemblage. Chemins de fichiers en dur au lieu de paramètres.top = [("a", 42), ("b", 3)], que renvoie filtrer_seuil(top, seuil=10) qui ne garde que les IP au-dessus du seuil ?Réponse : [('a', 42)].
Explication : 42 > 10 est gardé, 3 est écarté. La compréhension est [(ip, n) for ip, n in top if n > seuil].
filtrer_seuil(top, seuil=10) qui ne garde que les IP dépassant le seuil, et intégrez-la avant l'export.extraire_ip), constantes en MAJUSCULES (SEUIL = 10).python -m pyflakes ou flake8 pour traquer les erreurs et le style.Checklist avant mise en production : testé en lab, sauvegardes prévues, logs horodatés, plan de retour arrière, revue par un pair.
charger, filtrer_seuil) + constante SEUIL = 10.os.environ["API_TOKEN"], jamais en dur.python -m pyflakes rapport_soc.py → aucune erreur. État : script présentable et déployable en lab.Réponse : dans une variable d'environnement (ou un fichier exclu du Git), jamais en dur ; vérification avec python -m pyflakes ou flake8.
Explication : le secret reste hors du code versionné, et le linter attrape les erreurs avant la revue par un pair.