Sysdig a décrit en février 2026 une intrusion dans un compte AWS : des identifiants de test trouvés dans des buckets publics, puis les droits d’administrateur en moins de dix minutes, dont huit minutes entre le vol des identifiants et l’exécution du code qui crée la clé d’administration (Sysdig). La question pratique : en huit minutes, que vois-je, et quand ? Nous avons chronométré nos propres alertes, de l’événement à la notification sur le téléphone. Le résultat a une moitié rassurante et une moitié inconfortable.

Les repères publics
- 8 minutes : du vol d’identifiants à l’exécution du code d’élévation, intrusion AWS du 28 novembre 2025 (Sysdig).
- 29 minutes : temps moyen de « rupture » (de l’accès initial au déplacement latéral) des acteurs eCrime en 2025, 27 secondes pour le plus rapide ; une exfiltration en 4 minutes dans un cas (CrowdStrike, Global Threat Report 2026).
- 14 jours : durée médiane mondiale de présence avant détection en 2025, selon Mandiant (M-Trends). Ces deux chiffres ne se contredisent pas : le premier décrit une intrusion qui avance vite, le second le temps qu’il faut en moyenne pour la découvrir.
La méthode
Nous avons écrit alert_latency.py (bibliothèque standard) qui injecte un événement factice, note l’heure d’arrivée de chaque notification sur le serveur ntfy qui alimente le téléphone, et interroge à côté chaque maillon : journal de fail2ban, ensemble réseau de CrowdSec, alertes Wazuh. Quatre scénarios :
- scan : rejeu d’un vrai scan de secrets (45 requêtes en une seconde, relevé dans nos journaux : voir l’article sur les scanners de secrets) ;
- brute : 6 échecs d’authentification (401) en une seconde ;
- fim : création d’un fichier dans
/root/.sshd’une machine surveillée (persistance par clé SSH) ; - hote : arrêt de l’exportateur de métriques d’une machine de laboratoire, pour mesurer l’alerte « machine injoignable ».
Garde-fous : toutes les adresses injectées sont dans le bloc réservé à la documentation, 203.0.113.0/24 (RFC 5737), jamais routable ; l’adresse est débannie après chaque essai ; l’horloge est unique (l’injecteur et l’écouteur tournent sur le même nœud, et le décalage de l’horloge du serveur Wazuh est mesuré avant chaque série). Limite à connaître : les lignes sont ajoutées au journal d’un hôte réel du reverse proxy, que lisent les trois outils : c’est volontaire (on teste la chaîne de production), mais ce n’est pas un trafic passant par le réseau.
Ce que nous avons mesuré
| Scénario | 1re notification | Dernière notification | Blocage de l’adresse | Essais |
|---|---|---|---|---|
| Scan de secrets (45 requêtes) | 1,0 à 2,3 s (médiane 2,0 s) | 2,0 à 12,2 s | CrowdSec 6,6 à 10,6 s (médiane 9,1 s) ; fail2ban ≤ 1 s | 4 |
| Force brute (6 × 401) | 2,5 à 3,1 s | 4,5 à 5,3 s | fail2ban 1,2 s ; CrowdSec 7,2 à 9,1 s | 3 |
| Fichier dans /root/.ssh | 0,8 et 1,4 s | — | — | 2 sur 3 (voir plus bas) |
| Machine tombée | 6 min 7 s | « Rétabli » 4 min 43 s après le retour | — | 1 |

Anatomie d’un scan, seconde par seconde
Un essai détaillé (adresse fictive .52). Le temps zéro est le début de l’injection, qui dure 0,9 s.
| Temps | Événement |
|---|---|
| 0,9 s | fail2ban inscrit le bannissement dans son journal (la détection se fait pendant l’injection) |
| 1,1 s | Wazuh : règle « bannissement fail2ban » (niveau 9) |
| 1,3 s | Wazuh : « fichiers secrets » (niveau 6) puis « attaquant actif » (niveau 12) |
| 1,9 s | notification ntfy : « fail2ban : adresse bannie » |
| 3,0 s | notification ntfy : « attaquant actif » |
| 9,2 s | CrowdSec : l’adresse entre dans l’ensemble réseau qui la bloque |
Les trois outils ne vont pas à la même vitesse. fail2ban bloque en moins d’une seconde ; il ne prévient pas lui-même, c’est Wazuh qui lit son journal ; CrowdSec décide en même temps, mais son composant de blocage ne relit les décisions que toutes les dix secondes (réglage par défaut du pare-feu CrowdSec, doc) ; Wazuh voit tout en une à deux secondes et pousse la notification en une seconde de plus. Pour une personne, tout cela est quasi immédiat. Mais ces alertes ne portent que sur des événements du réseau : un attaquant qui se connecte avec des identifiants valides ne déclenche rien de tout cela.
Trois surprises
- Le dédoublonnage fonctionne… et masque. Au premier essai de la série, nous avions réutilisé la même adresse fictive que l’essai précédent, moins de dix minutes auparavant : aucune notification. C’est le garde-fou anti-déluge de l’intégration (même règle, même machine, même adresse, une fois toutes les dix minutes). Un attaquant qui revient sous dix minutes avec la même adresse ne crée pas de nouveau message ; nous avons écarté cet essai de la table.
- Un message est arrivé en 12,2 s au lieu de 2 s. Dans cet essai, c’est la prison « 404 en rafale » de fail2ban et non celle des « sondes » qui a banni l’adresse ; nous n’avons pas expliqué l’écart avec certitude.
- Un événement de fichier sur trois est resté muet. Les essais étaient espacés de quelques secondes ; nous suspectons le plafond horaire ou le dédoublonnage des notifications, sans l’avoir établi.
Le coût caché de l’intégration
Wazuh appelle le script de notification pour chaque alerte de niveau 5 ou plus, avant que le script ne la filtre. Mesure faite sur des copies (la production n’a pas été touchée) : environ 67 ms par alerte filtrée, dont une grande part est l’importation d’une bibliothèque réseau ; en l’important seulement quand une notification part, 30 ms. L’appel est séquentiel (documentation Wazuh) : une rafale de 1 000 alertes ordinaires retarde de plus d’une minute la suivante, qui peut être la bonne. C’est une piste d’amélioration ; elle demande de modifier le script en production, donc l’accord de l’administrateur.
La moitié inconfortable : une règle qui ne se déclenchait jamais
Pour le scénario « machine tombée », nous avons arrêté l’exportateur de métriques d’un conteneur de laboratoire pendant plus de dix minutes. Prometheus a bien enregistré up = 0. Aucune alerte n’est partie. La règle « Machine injoignable » ne s’était jamais déclenchée depuis sa création, il y a un jour.
La cause tient en une ligne. La requête était up{job="node"} == 0. En PromQL, un filtre de comparaison renvoie les séries qui satisfont la condition avec leur valeur : pour une machine tombée, la valeur renvoyée est 0. Le seuil de l’alerte était « supérieur à 0 » : jamais franchi. La règle jumelle « Site en panne » (probe_success == 0) avait exactement le même défaut. Les deux règles de disque et de tâche planifiée, dont la valeur est naturellement positive, étaient correctes (documentation Grafana : requêtes et conditions).
# avant : la série existe quand la machine est tombée, mais sa valeur est 0, sous le seuil « > 0 »
up{job="node"} == 0
# après : la valeur vaut 1 quand la machine est tombée, 0 sinon
1 - up{job="node"}
Une aggravation : l’état de la règle était « Normal (NoData) » ; rien dans l’interface ne signalait l’anomalie. Une règle jamais déclenchée ressemble exactement à une règle qui n’a rien à signaler. Seul un test avec un vrai arrêt l’a révélé. Nous avons corrigé les deux règles (sauvegarde des anciennes avant), puis refait le test : alerte reçue 6 min 7 s après l’arrêt, « Rétabli » reçu 4 min 43 s après le retour de la machine.
Le délai de 6 minutes se décompose : 5 minutes d’attente réglées dans la règle (for: 5m, pour éviter les fausses alertes sur une coupure de quelques secondes), une évaluation par minute, et 30 secondes d’attente de regroupement dans la politique de notification (documentation Grafana : règles d’alerte). Passer à 2 minutes de confirmation ramènerait l’alerte vers 3 minutes. Nous ne l’avons pas appliqué : c’est un compromis entre vitesse et faux positifs qui revient à l’administrateur. Pour comparaison, la surveillance des sites dans Home Assistant, calculée d’après sa configuration (vérification toutes les 5 minutes, alerte après 10 minutes d’état hors ligne), met entre 10 et 15 minutes : calculé, non testé.
Ce qu’il faut retenir
- Les événements réseau sont vus en 1 à 3 secondes si quelqu’un a pensé à les décrire ; les alertes de disponibilité prennent des minutes par construction.
- Tout ce qu’on n’a pas déclenché à blanc est une hypothèse. Une alerte qui n’a jamais sonné n’est pas prouvée.
- La vitesse ne remplace pas la couverture : aucune de ces alertes ne verrait un attaquant qui se connecte avec un jeton volé. Des identifiants à courte durée de vie (rôles temporaires, rotation) comptent autant qu’une alarme rapide.
- Chaque outil joue un rôle différent : fail2ban bloque en moins d’une seconde, Wazuh alerte en quelques secondes, CrowdSec ajoute en une dizaine de secondes une liste d’adresses partagée avec d’autres utilisateurs.
- Testez avec des adresses de documentation, débannissez après, et notez les délais : sans chiffres, « c’est rapide » ne veut rien dire.
Limites
- Quelques essais seulement (4, 3, 3, 1) : les fourchettes sont indicatives, pas des statistiques.
- Événements injectés dans un journal et non un trafic réel passant par le réseau ; le temps de transit réseau est ignoré (quelques millisecondes en local).
- Même horloge, même nœud pour l’injecteur et l’écouteur ; l’écart entre l’horloge de l’injecteur et celle du serveur Wazuh est mesuré avant chaque série et retranché des heures des alertes Wazuh ; nous n’avons pas conservé sa valeur.
- Le délai jusqu’au téléphone (service de notification du système d’exploitation) n’est pas mesuré ; nous mesurons l’arrivée au serveur ntfy.
- Un seul scénario d’indisponibilité ; et l’essai « Rétabli » est un seul exemplaire.
Transparence : la recherche, la rédaction et, lorsqu’il y en a, les tests de cet article ont été réalisés avec l’aide d’un assistant IA (Claude) ; l’éditeur du site relit et valide chaque article avant publication. Les mesures ont été faites le 11 octobre 2026 sur notre propre infrastructure ; les adresses internes du script publié ont été remplacées par des noms génériques.
Le script complet
alert_latency.py, 4 tests intégrés (--test). Il suppose une architecture comme la nôtre (reverse proxy, fail2ban, CrowdSec, Wazuh, ntfy) : adaptez les constantes en tête de fichier.
#!/usr/bin/env python3
"""alert_latency.py : mesure le délai entre un événement et l'alerte reçue sur ntfy, de bout en bout.
Scénarios (événements injectés avec des adresses du bloc de documentation 203.0.113.0/24, RFC 5737, jamais routables) :
scan rejoue un vrai scan de secrets (45 requêtes en 1 s) dans le journal d'un hôte du reverse proxy
brute 6 échecs d'authentification (401) en une seconde
fim crée un fichier dans /root/.ssh d'une machine surveillée en temps réel (persistance par clé SSH)
hote arrête l'exportateur de métriques d'une machine de laboratoire (alerte « machine injoignable » de Grafana)
Chaque scénario mesure, avec une seule horloge (celle de la machine qui injecte et de la machine qui écoute : même noyau), l'arrivée
des notifications ntfy ; « scan » et « brute » mesurent aussi le bannissement fail2ban et le blocage effectif par CrowdSec.
Usage : alert_latency.py scan|brute|fim|hote [--n 3] [--json sortie.json] ; alert_latency.py --test"""
import argparse, base64, datetime, json, statistics, subprocess, sys, threading, time, urllib.request
from pathlib import Path
HERE = Path(__file__).parent
NPM, WAZUH = "root@proxy.lan", "root@siem.lan"
JOURNAL_NPM = "/data/logs/proxy-host-24_access.log" # hôte peu actif ; lu par Wazuh, fail2ban et CrowdSec (jokers sur proxy-host-*)
FIM_HOTE, FIM_DOSSIER = "root@supervision.lan", "/root/.ssh"
LAB_HOTE, LAB_INSTANCE = "root@cible-labo.lan", "lab-forensic-cible"
DOC_NET = "203.0.113."
def secrets():
return dict(l.strip().split("=", 1) for l in open("/root/.config/claude/secrets.env") if "=" in l and not l.startswith("#"))
def mediane(xs):
return statistics.median(xs) if xs else None
def percentile(xs, p):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(round(p / 100 * (len(xs) - 1))))] if xs else None
class EcouteNtfy(threading.Thread):
"""Reste abonnée aux sujets ntfy et note l'heure d'arrivée locale de chaque message."""
def __init__(self, sujets):
super().__init__(daemon=True)
s = secrets()
self.url = s["NTFY_URL"].strip("\"'").rstrip("/") + "/" + ",".join(sujets) + "/json"
self.auth = "Basic " + base64.b64encode(f"{s['NTFY_ADMIN_USER'].strip(chr(34)+chr(39))}:{s['NTFY_ADMIN_PASSWORD'].strip(chr(34)+chr(39))}".encode()).decode()
self.messages, self.verrou, self.pret = [], threading.Lock(), threading.Event()
def run(self):
depuis = int(time.time())
while True:
try:
req = urllib.request.Request(f"{self.url}?since={depuis}", headers={"Authorization": self.auth})
with urllib.request.urlopen(req, timeout=120) as r: # nosec B310
for ligne in r:
recu = time.time()
d = json.loads(ligne)
if d.get("event") == "open":
self.pret.set()
elif d.get("event") == "message":
depuis = d["time"]
with self.verrou:
self.messages.append({"recu": recu, "sujet": d["topic"], "titre": d.get("title", ""), "texte": d.get("message", ""), "prio": d.get("priority", 3)})
except Exception:
time.sleep(1)
def attendre(self, motif, apres, delai, nombre=1):
"""Attend `nombre` messages contenant `motif` reçus après l'instant `apres` ; renvoie la liste (éventuellement plus courte)."""
fin = time.time() + delai
while time.time() < fin:
with self.verrou:
trouves = [m for m in self.messages if m["recu"] >= apres and (motif in m["titre"] or motif in m["texte"])]
if len(trouves) >= nombre:
return trouves
time.sleep(0.05)
with self.verrou:
return [m for m in self.messages if m["recu"] >= apres and (motif in m["titre"] or motif in m["texte"])]
# Script exécuté sur le reverse proxy : injecte, puis surveille le bannissement fail2ban et le blocage CrowdSec effectif (ensemble nftables).
INJECTEUR = r'''
import json, os, subprocess, sys, time
cfg = json.loads(sys.argv[1]); ip = cfg["ip"]
f2b = "/var/log/fail2ban.log"; off = os.path.getsize(f2b)
def bloque():
r = subprocess.run(["nft", "list", "set", "ip", "crowdsec", "crowdsec-blacklists-crowdsec"], capture_output=True, text=True)
return ip in r.stdout
t0 = time.time()
with open(cfg["journal"], "a") as fh:
for modele in cfg["lignes"]:
fh.write(modele.replace("{TS}", time.strftime("%d/%b/%Y:%H:%M:%S +0000", time.gmtime())).replace("{IP}", ip) + "\n"); fh.flush()
time.sleep(cfg["pause"])
res = {"t0": t0, "fin_injection": time.time(), "f2b": {}, "crowdsec_nft": None}
while time.time() - t0 < cfg["attente"]:
now = time.time()
with open(f2b) as fh:
fh.seek(off); neuf = fh.read()
for l in neuf.splitlines():
if " Ban " + ip in l:
res["f2b"].setdefault(l.split("[")[2].split("]")[0] if l.count("[") >= 3 else "?", now)
if res["crowdsec_nft"] is None and bloque():
res["crowdsec_nft"] = now
if res["f2b"] and res["crowdsec_nft"]:
break
time.sleep(0.05)
a = subprocess.run(["cscli", "alerts", "list", "-i", ip, "-o", "json"], capture_output=True, text=True).stdout
try:
res["crowdsec_alertes"] = [{"scenario": x["scenario"], "cree": x["created_at"]} for x in json.loads(a) or []]
except ValueError:
res["crowdsec_alertes"] = []
print(json.dumps(res))
'''
def ssh(hote, cmd, entree=None, delai=120):
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", hote, cmd], input=entree, capture_output=True, text=True, timeout=delai)
def modele_scan():
return [l for l in (HERE / "scan-reel-modele.txt").read_text().splitlines() if l.strip()]
def modele_brute():
return ['[{TS}] - 401 401 - POST https wazuh.ourhappyness.fr "/api/sessions" [Client {IP}] [Length 54] [Gzip -] [Sent-to siem.lan] "Mozilla/5.0 (X11; Linux x86_64)" "-"'] * 6
def alertes_wazuh(ip, decalage):
"""Premières alertes Wazuh pour cette IP, ramenées à l'horloge locale (décalage = horloge Wazuh - horloge locale)."""
r = ssh(WAZUH, f"grep -h '{ip}' /var/ossec/logs/alerts/alerts.json")
premieres = {}
for l in r.stdout.splitlines():
a = json.loads(l)
t = datetime.datetime.fromisoformat(a["timestamp"]).timestamp() - decalage
cle = (a["rule"]["id"], a["rule"]["level"])
premieres[cle] = min(premieres.get(cle, t), t)
return premieres
def decalage_horloge(hote, essais=15):
"""Écart d'horloge entre `hote` et cette machine (médiane des meilleurs aller-retours)."""
mesures = []
for _ in range(essais):
a = time.time(); r = ssh(hote, "date +%s.%N", delai=20); b = time.time()
mesures.append((b - a, float(r.stdout) - (a + b) / 2))
mesures.sort()
return mediane([m[1] for m in mesures[:5]])
def prochain_ip(n):
return f"{DOC_NET}{50 + n}"
def run_reseau(ecoute, nom, modele, ip, pause, decalage):
cfg = json.dumps({"ip": ip, "journal": JOURNAL_NPM, "lignes": modele, "pause": pause, "attente": 45})
debut = time.time()
r = ssh(NPM, "python3 - " + "'" + cfg.replace("'", "'\"'\"'") + "'", entree=INJECTEUR, delai=120)
if r.returncode:
raise SystemExit(f"échec de l'injection : {r.stderr[:300]}")
res = json.loads(r.stdout.strip().splitlines()[-1])
t0 = res["t0"]
msgs = ecoute.attendre(ip, t0 - 1, 25, nombre=3)
sortie = {"scenario": nom, "ip": ip, "injection_s": round(res["fin_injection"] - t0, 2),
"fail2ban_ban_s": {j: round(t - t0, 2) for j, t in res["f2b"].items()},
"crowdsec_blocage_s": round(res["crowdsec_nft"] - t0, 2) if res["crowdsec_nft"] else None,
"crowdsec_alertes": res["crowdsec_alertes"],
"ntfy": sorted([{"s": round(m["recu"] - t0, 2), "titre": m["titre"][:70], "prio": m["prio"]} for m in msgs], key=lambda x: x["s"])}
time.sleep(2)
sortie["wazuh_premieres_alertes_s"] = {f"{k[0]} (niveau {k[1]})": round(t - t0, 2) for k, t in sorted(alertes_wazuh(ip, decalage).items(), key=lambda kv: kv[1])}
ssh(NPM, f"fail2ban-client unban {ip} >/dev/null 2>&1; cscli decisions delete -i {ip} >/dev/null 2>&1; true")
return sortie
def run_fim(ecoute, n):
nom = f"{FIM_DOSSIER}/latency-test-{int(time.time())}-{n}"
t0 = time.time()
ssh(FIM_HOTE, f"touch {nom}")
msgs = ecoute.attendre(nom, t0 - 1, 90)
ssh(FIM_HOTE, f"rm -f {nom}")
return {"scenario": "fim", "fichier": nom, "ntfy": [{"s": round(m["recu"] - t0, 2), "titre": m["titre"][:70], "prio": m["prio"]} for m in msgs]}
def run_hote(ecoute, delai_max=900):
ssh(LAB_HOTE, "systemctl stop node_exporter")
t0 = time.time()
msgs = ecoute.attendre(LAB_INSTANCE, t0 - 1, delai_max)
sortie = {"scenario": "hote", "instance": LAB_INSTANCE, "alerte_s": round(msgs[0]["recu"] - t0, 1) if msgs else None, "titre": msgs[0]["titre"] if msgs else None}
t1 = time.time(); ssh(LAB_HOTE, "systemctl start node_exporter")
rt = ecoute.attendre(LAB_INSTANCE, t1 - 0.1, delai_max)
rt = [m for m in rt if m["recu"] > t1]
sortie["retablissement_s"] = round(rt[0]["recu"] - t1, 1) if rt else None
return sortie
def resume(resultats):
lignes = []
for cle, extrait in (("ntfy_premier", lambda r: r["ntfy"][0]["s"] if r.get("ntfy") else None),
("ntfy_dernier", lambda r: r["ntfy"][-1]["s"] if r.get("ntfy") else None),
("blocage_crowdsec", lambda r: r.get("crowdsec_blocage_s")),
("ban_fail2ban", lambda r: min(r["fail2ban_ban_s"].values()) if r.get("fail2ban_ban_s") else None)):
xs = [v for v in (extrait(r) for r in resultats) if v is not None]
lignes.append(f"{cle:<18} n={len(xs)} min={min(xs) if xs else None} médiane={mediane(xs)} max={max(xs) if xs else None}")
return "\n".join(lignes)
def test():
import unittest
class T(unittest.TestCase):
def test_statistiques(self):
self.assertEqual(mediane([3, 1, 2]), 2); self.assertIsNone(mediane([])); self.assertEqual(percentile([1, 2, 3, 4, 5], 100), 5); self.assertEqual(percentile([1, 2, 3, 4, 5], 0), 1)
def test_modeles(self):
self.assertEqual(len(modele_brute()), 6); self.assertTrue(all("{IP}" in l and "{TS}" in l for l in modele_brute() + modele_scan()))
self.assertGreaterEqual(len(modele_scan()), 40); self.assertTrue(prochain_ip(3).startswith(DOC_NET))
def test_horodatage_wazuh(self):
self.assertAlmostEqual(datetime.datetime.fromisoformat("2026-10-11T11:13:31.197+0200").timestamp(), datetime.datetime(2026, 10, 11, 9, 13, 31, 197000, tzinfo=datetime.timezone.utc).timestamp(), places=3)
def test_ecoute_attendre(self):
e = EcouteNtfy(["x"]); e.messages = [{"recu": 10.0, "sujet": "x", "titre": "[12] attaquant 203.0.113.50", "texte": "", "prio": 5}, {"recu": 5.0, "sujet": "x", "titre": "203.0.113.50", "texte": "", "prio": 3}]
self.assertEqual(len(e.attendre("203.0.113.50", 6.0, 0.2)), 1) # le message plus ancien que `apres` est ignoré
self.assertEqual(e.attendre("autre", 0, 0.1), [])
r = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(T))
return 0 if r.wasSuccessful() else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("scenario", nargs="?", choices=["scan", "brute", "fim", "hote"]); ap.add_argument("--n", type=int, default=3)
ap.add_argument("--json"); ap.add_argument("--test", action="store_true"); ap.add_argument("--pause", type=float, default=0.02)
a = ap.parse_args()
if a.test:
return test()
if not a.scenario:
ap.error("scénario requis")
ecoute = EcouteNtfy(["wazuh-alertes", "supervision-alertes"]); ecoute.start()
if not ecoute.pret.wait(20):
raise SystemExit("abonnement ntfy impossible")
resultats = []
decalage = decalage_horloge(WAZUH) if a.scenario in ("scan", "brute") else 0
for i in range(a.n if a.scenario != "hote" else 1):
if a.scenario == "scan":
r = run_reseau(ecoute, "scan", modele_scan(), prochain_ip(i), a.pause, decalage)
elif a.scenario == "brute":
r = run_reseau(ecoute, "brute", modele_brute(), prochain_ip(10 + i), 0.15, decalage)
elif a.scenario == "fim":
r = run_fim(ecoute, i)
else:
r = run_hote(ecoute)
resultats.append(r); print(json.dumps(r, ensure_ascii=False)); sys.stdout.flush()
time.sleep(3)
if a.scenario in ("scan", "brute"):
print(resume(resultats))
if a.json:
Path(a.json).write_text(json.dumps(resultats, indent=1, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
À lire aussi
- Journaux Linux : lire une intrusion SSH : ce que laisse une intrusion une fois qu’elle a eu lieu.
- KEV CISA : que disent 1 728 failles exploitées ? : la vitesse d’exploitation côté vulnérabilités.
Sources
- Sysdig TRT, « AI-assisted cloud intrusion achieves admin access in 8 minutes » : sysdig.com
- CrowdStrike, Global Threat Report 2026 : crowdstrike.com
- Google Cloud / Mandiant, M-Trends : cloud.google.com
- IETF, RFC 5737 : rfc-editor.org
- Grafana, règles d’alerte et conditions : grafana.com ; requêtes et conditions
- ntfy, API d’abonnement (flux JSON) : docs.ntfy.sh
- fail2ban : github.com ; CrowdSec, pare-feu : docs.crowdsec.net ; Wazuh, intégrations : documentation.wazuh.com