Un robot ne vous connaît pas. Il demande la même liste de chemins à toutes les adresses qu’il trouve, et il attend qu’un seul réponde « 200 ». Nous avons relu dix jours de journaux de notre reverse proxy pour voir cette liste de près. Elle contient, à côté des classiques .env et .aws, des fichiers liés aux outils d’IA : /.claude/settings.json, /.env.anthropic, /.mcp.json.

Pourquoi c’est la bonne question en 2026
Le rapport de Sysdig décrit une intrusion AWS du 28 novembre 2025 : des identifiants de test, trouvés dans des buckets S3 publics qui contenaient des données d’IA, ont mené à des droits d’administrateur en moins de 10 minutes, dont huit minutes pour la séquence entre le vol des identifiants et l’exécution du code. Les buckets portaient des noms d’outils d’IA, que l’attaquant cherchait activement (Sysdig). Autre chiffre, côté correction : dans les cas étudiés par le rapport DBIR 2025 de Verizon, un secret découvert dans un dépôt GitHub restait 94 jours en médiane avant d’être corrigé (Help Net Security). Ce sont deux mesures différentes : la première décrit la vitesse d’une attaque, la seconde la lenteur d’une correction. Elles montrent ensemble qu’un secret exposé est un risque dont on ne peut pas attendre la correction.
Les honeypots du SANS Internet Storm Center l’ont documenté de longue date : un article invité de mars 2024 compare une machine AWS et une machine Azure et constate que seule celle d’AWS reçoit des demandes de fichiers /.aws/config et /.aws/credentials — les robots choisissent leurs chemins selon l’hébergeur de l’adresse (ISC, diary 30722). En janvier 2026, Aviatrix a signalé à l’ISC une vague qui demande .env, docker-compose.yml et terraform.tfstate sous un préfixe /$(pwd)/ (Aviatrix).
Ce que nos journaux montrent
Source : les journaux d’accès de notre reverse proxy (Nginx Proxy Manager), du 1er au 11 octobre 2026, réseau local exclu. L’analyseur ci-dessous classe chaque requête dont le chemin ressemble à un secret.
| Mesure | Valeur |
|---|---|
| Requêtes externes vers des chemins à secret | 1 484 |
| Adresses différentes | 73 (dont 41 n’ont demandé qu’un seul chemin) |
| Adresses en rafale (≥ 10 chemins distincts en 60 s) | 16 |
| Réponses | 523 × 404, 520 × 403, 405 × 401, 33 × 301, 3 × 302 |
| Requêtes ayant reçu un 2xx (secret servi) | 0 |

Une même boîte à outils, plusieurs adresses
Les chemins demandés par les plus gros scanneurs se recoupent fortement : entre cinq adresses (réparties sur des machines d’un grand fournisseur cloud et sur un même bloc /24 d’un hébergeur), la part de chemins communs va de 0,56 à 0,89 (indice de Jaccard sur les listes de chemins). Cela suggère un même outil, lancé depuis plusieurs machines louées (c’est une déduction, pas une preuve). Bloquer une adresse n’empêche pas la suivante ; ce qui reste stable, c’est la liste de chemins.
Autres traits relevés : plusieurs scanners demandent 99 à 120 chemins distincts en une seconde environ ; la chaîne User-Agent est un navigateur ordinaire (Windows, Chrome) dans près de 9 requêtes sur 10, donc ne servez pas de ce champ pour filtrer ; les sites protégés par mot de passe répondent 401 : le robot les visite aussi, sans rien obtenir.
Les fichiers d’outils d’IA
Dans la liste figurent six chemins liés aux assistants de code et aux clés d’API d’IA : /.claude/settings.json, /.claude/settings.local.json, /.env.anthropic, /.env.openai, /.anthropic/config.json et /.mcp.json. 54 requêtes, de 11 adresses. Nous n’avons pas de preuve de ce que ces robots espèrent y trouver ; l’hypothèse la plus simple est la même que pour .aws : un projet déployé tel quel, dossier de travail compris (ou un serveur statique lancé à la racine d’un projet), publie aussi ces fichiers, et ces fichiers peuvent contenir des clés d’API ou des réglages d’accès. Ce que l’on peut dire avec certitude : les chemins sont demandés, par plusieurs adresses.
Le script d’analyse
secretscan_report.py (bibliothèque standard de Python) lit un ou plusieurs journaux au format Nginx Proxy Manager, classe chaque chemin par famille, mesure la rafale maximale par adresse, les User-Agent, les codes de réponse, et liste les requêtes qui ont reçu un 2xx. Trois tests intégrés (--test) couvrent la classification (y compris les chemins encodés comme /%2e%65nv et les faux positifs comme backup-hero.png), la fenêtre de rafale et la détection des réponses 2xx.
$ python3 secretscan_report.py --test
...
Ran 3 tests ... OK
$ python3 secretscan_report.py journal-externe.log --top 4
Adresses : 73 requêtes vers des chemins à secret : 1484
Familles : env 832, config-appli 213, depot-vcs 148, sauvegardes 101, cloud 94, ia-outils 54, conteneurs-ci 42
Statuts : 301 33, 302 3, 401 405, 403 520, 404 523
Rafales : 16 requêtes ayant reçu un 2xx : 0
Vérifier votre propre site
L’analyse des journaux dit ce qui a été demandé ; elle ne dit pas ce qui répondrait. verifier_exposition.py demande à votre site 35 chemins de la liste (dont les fichiers d’IA) et affiche seulement le code et la taille — jamais le contenu. Le code de sortie vaut 1 si un chemin répond 2xx, ce qui permet de l’ajouter à une tâche planifiée.
Deux essais. D’abord un serveur statique jetable (python3 -m http.server, lancé dans un dossier de projet contenant un faux .env et un faux .git/config) : c’est le comportement par défaut de ce serveur, il sert les fichiers cachés.
$ python3 verifier_exposition.py http://127.0.0.1:18123 --lent 0
Codes : 200 ×2, 404 ×33
EXPOSÉ /.env 200 20 octets
EXPOSÉ /.git/config 200 7 octets
2 chemin(s) exposé(s).
Ensuite notre blog : 28 chemins refusés en 403, 7 en 404, aucun exposé. Les chemins légitimes (/robots.txt, plan du site) restent servis.
$ python3 verifier_exposition.py https://killchain.fr --lent 0.3
Codes : 403 ×28, 404 ×7
Aucun chemin exposé.
Nos détecteurs voyaient-ils ces chemins ?
Nous avons rejoué les chemins d’outils d’IA, avec une adresse du bloc réservé à la documentation (RFC 5737), dans trois couches de défense déjà en place. Le résultat est instructif : ce qui est lié à .env est vu, ce qui est nouveau ne l’est pas.
| Chemin rejoué | Wazuh (règle 100110) | fail2ban (npm-probe) |
|---|---|---|
| /.env | vu (niveau 6) | vu |
| /.env.anthropic | vu (niveau 6) | vu |
| /.claude/settings.json | non : règle 100002, niveau 2 « routine » | non |
| /.claude/settings.local.json | non (même règle) | non |
| /.anthropic/config.json | non (même règle) | non |
| /.mcp.json | non (même règle) | non |
Pour CrowdSec, cscli explain sur les 8 lignes rejouées n’a déclenché le scénario « fichiers sensibles » que sur 2 d’entre elles ; aucun des chemins d’outils d’IA n’y figure (cscli explain). Dans la réalité, un scanner qui demande ces fichiers en demande aussi des dizaines d’autres, ce qui le fait bannir malgré tout : la lacune est réelle mais masquée. Un robot qui n’enverrait que les chemins d’IA passerait sous tous nos seuils.
Le correctif, testé hors ligne
Étendre l’expression régulière du filtre : /\.claude/|/\.anthropic|/\.mcp\.json|/\.cursor/|/\.openai|/\.aider|/\.gemini. Essai sur une copie du filtre, avec fail2ban-regex : sur 9 lignes rejouées (dont 3 légitimes : /robots.txt, /sitemap.xml, un article dont le titre contient « claude »), 1 ligne reconnue avant, 6 après, les 3 légitimes toujours ignorées. Sur les 84 488 lignes réelles : 1 230 reconnues avant, 1 262 après (+32, toutes des chemins d’outils d’IA). Ce correctif n’est pas appliqué en production : recharger un filtre et la règle Wazuh correspondante demande l’accord de l’administrateur, et c’est sa décision.
Que faire côté défense
- Ne servez jamais les fichiers cachés : refus des chemins commençant par un point (sauf
/.well-known/) au niveau du serveur web ; refus par défaut dans l’image de déploiement. - Ne déployez pas un dossier de projet entier : copiez le résultat de la construction, pas la racine du dépôt (c’est là que vivent
.env,.gitet.claude). - Mettez à jour la liste de détection avec les chemins d’outils d’IA, et testez-la avec une adresse de documentation plutôt que d’attendre un vrai scanner.
- Rotation plutôt que cachette : si un secret a pu être servi un instant, considérez-le comme public ; 94 jours de correction médiane montrent que c’est le point faible.
- Identités à courte durée de vie : des rôles temporaires plutôt que des clés longues dans des fichiers, comme le recommande Sysdig.
- Vérifiez en continu : lancez le vérificateur depuis une tâche planifiée sur vos propres sites et alertez sur le code de sortie 1.
Limites
- Dix jours, un seul réseau : 73 adresses ne font pas une statistique d’Internet ; les listes évoluent.
- Seules les requêtes arrivant au reverse proxy sont vues : un scanner qui s’arrête à la résolution DNS ou à la poignée de main TLS n’y figure pas.
- Nos 403 viennent d’une règle du serveur : un autre déploiement peut répondre autrement ; le vérificateur est là pour le dire.
- La classification par motifs peut se tromper (un article dont l’URL contiendrait « .env ») ; le script écarte certains faux positifs connus mais pas tous.
- Le rapprochement entre adresses est une inférence : des listes de chemins qui se recoupent à 0,8 suggèrent un outil commun, sans l’établir.
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 portent sur nos journaux réels ; les noms de nos applications sont volontairement généralisés, les adresses des scanners sont omises.
Les scripts complets
secretscan_report.py
#!/usr/bin/env python3
"""secretscan_report.py : anatomie des scanners de secrets dans des journaux d'accès Nginx Proxy Manager (stdlib seule).
Classe chaque requête vers un chemin « à secret » (.env, identifiants cloud, dépôts, clés d'API d'outils d'IA, sauvegardes…),
regroupe par adresse, détecte les rafales, résume les User-Agent et la réponse obtenue (403/404 = rien livré ; 200 = à examiner).
Usage : secretscan_report.py FICHIER... [--rafale 10] [--json] [--taille-min 5] ; secretscan_report.py --test"""
import argparse, collections, json, re, sys, datetime
from urllib.parse import urlsplit, unquote
LIGNE = re.compile(r'^\[(?P<ts>[^\]]+)\] (?:- )?(?P<up>\S+) (?P<st>\d{3}) - (?P<m>[A-Z]+) (?P<sch>\S+) (?P<host>\S+) "(?P<uri>[^"]*)" \[Client (?P<ip>[^\]]+)\].*?"(?P<ua>[^"]*)" "[^"]*"\s*$')
# (famille, motif sur le chemin en minuscules) ; le premier qui correspond gagne, l'ordre va du plus précis au plus général
FAMILLES = [
("ia-outils", re.compile(r'(\.claude/|\.cursor/|\.continue/|\.aider|\.openai|\.anthropic|\.gemini|\.copilot|\.windsurf|\.codeium|mcp\.json|\.mcp|claude[_.-]?(desktop|settings)|openai[_.-]?(key|api)|anthropic[_.-]?(key|api)|\.env\.(openai|anthropic|claude|gemini|llm|ai\b))')),
("cloud", re.compile(r'(\.aws/|aws[_.-]?(credentials|config|keys?)|\.azure|\.gcloud|gcloud|\.config/gcloud|service[_-]?account.*\.json|credentials\.json|\.boto|\.s3cfg|\.oci/|\.kube/|kubeconfig)')),
("depot-vcs", re.compile(r'(/\.git(/|$)|\.git/(config|head|index)|/\.svn|/\.hg|/\.bzr|\.gitlab-ci|\.github/|\.gitignore$|\.gitconfig|\.git-credentials)')),
("env", re.compile(r'(?:^|/)\.env(?:[._~-]|$)|(?:^|/)env(?:\.(?:js|json|txt|bak))?$|/(?:config|app|api|backend|laravel|core|admin|local|prod|production|staging|dev|docker)[/_-]*\.env|\benv\.(?:bak|old|save|backup)|/\.environment')),
("ssh-cles", re.compile(r'(\.ssh/|id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.pem$|\.key$|\.ppk$)')),
("conteneurs-ci", re.compile(r'(docker-compose|dockerfile|\.docker/config|\.dockercfg|jenkins|\.travis|circleci|\.drone|bitbucket-pipelines|terraform\.tfstate|\.tfvars|ansible|\.vault)')),
("config-appli", re.compile(r'(wp-config|config\.(php|json|ya?ml|inc|bak|old)|settings\.(py|php|json)|appsettings|web\.config|application\.(ya?ml|properties)|database\.ya?ml|secrets?\.(ya?ml|json)|parameters\.ya?ml|\.htpasswd|\.npmrc|\.pypirc|\.netrc|composer\.(json|lock)|phpinfo|info\.php|server-status|actuator)')),
("sauvegardes", re.compile(r'(\.(zip|tar|tgz|gz|7z|rar|bak|old|orig|save|swp|sql|dump)$|/backups?/|/dump|backup\.)')),
]
IDX_EXCLUS = re.compile(r'(?:koko-analytics-collect\.php)|(?:/wp-content/uploads/.*\.(?:png|jpe?g|webp|gif|svg)$)')
NAVIGATEURS_COMMUNS = re.compile(r'(mozilla/5\.0 \((windows|macintosh|x11)[^)]*\) applewebkit/537\.36)')
def lire(chemins):
for p in chemins:
with open(p, errors="replace") as fh:
for l in fh:
m = LIGNE.match(l.rstrip("\n"))
if m:
d = m.groupdict()
try:
d["t"] = datetime.datetime.strptime(d["ts"], "%d/%b/%Y:%H:%M:%S %z").timestamp()
except ValueError:
continue
yield d
def classer(uri):
chemin = unquote(urlsplit(uri).path).lower()
if IDX_EXCLUS.search(chemin):
return None
for nom, rx in FAMILLES:
if rx.search(chemin):
return nom
return None
def analyser(lignes, rafale=10, fenetre=60):
par_ip = collections.defaultdict(list)
for d in lignes:
f = classer(d["uri"])
if f:
d["famille"] = f
par_ip[d["ip"]].append(d)
rapport = {"adresses": [], "familles": collections.Counter(), "statuts": collections.Counter(), "reussites": []}
for ip, rs in par_ip.items():
rs.sort(key=lambda r: r["t"])
chemins = {unquote(urlsplit(r["uri"]).path).lower() for r in rs}
# rafale = plus grand nombre de chemins distincts dans une fenêtre glissante
meilleur, i = 0, 0
for j in range(len(rs)):
while rs[j]["t"] - rs[i]["t"] > fenetre:
i += 1
meilleur = max(meilleur, len({unquote(urlsplit(r["uri"]).path).lower() for r in rs[i:j + 1]}))
uas = collections.Counter(r["ua"] for r in rs)
for r in rs:
rapport["familles"][r["famille"]] += 1
rapport["statuts"][r["st"]] += 1
if r["st"].startswith("2") and r["up"] != "-":
rapport["reussites"].append({"ip": ip, "uri": r["uri"], "st": r["st"], "host": r["host"]})
rapport["adresses"].append({
"ip": ip, "requetes": len(rs), "chemins_distincts": len(chemins), "rafale_max": meilleur,
"types": sorted({r["famille"] for r in rs}), "duree_s": round(rs[-1]["t"] - rs[0]["t"], 1),
"ua": uas.most_common(3), "navigateur_banal": bool(NAVIGATEURS_COMMUNS.search(rs[0]["ua"].lower())),
"hotes": sorted({r["host"] for r in rs}), "statuts": dict(collections.Counter(r["st"] for r in rs)),
"debut": datetime.datetime.fromtimestamp(rs[0]["t"], datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
})
rapport["adresses"].sort(key=lambda a: (-a["chemins_distincts"], -a["requetes"]))
rapport["rafales"] = [a for a in rapport["adresses"] if a["rafale_max"] >= rafale]
return rapport
def texte(r, top=8):
s = [f"Adresses : {len(r['adresses'])} requêtes vers des chemins à secret : {sum(r['familles'].values())}",
"Familles : " + ", ".join(f"{k} {v}" for k, v in r["familles"].most_common()),
"Statuts : " + ", ".join(f"{k} {v}" for k, v in sorted(r["statuts"].items())),
f"Rafales (≥ seuil de chemins distincts/min) : {len(r['rafales'])} requêtes ayant reçu un 2xx : {len(r['reussites'])}"]
for a in r["adresses"][:top]:
s.append(f" {a['ip']:<16} {a['requetes']:>4} req {a['chemins_distincts']:>3} chemins rafale {a['rafale_max']:>3} {a['duree_s']:>6}s {','.join(a['types'])} {a['statuts']}")
return "\n".join(s)
def test():
import unittest, tempfile, os
def l(ts, st, uri, ip="203.0.113.9", ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", host="exemple.fr"):
return f'[{ts}] - {st} {st} - GET https {host} "{uri}" [Client {ip}] [Length 12] [Gzip -] [Sent-to 10.0.0.1] "{ua}" "-"'
class T(unittest.TestCase):
def test_familles(self):
cas = {"/.env": "env", "/.env.production": "env", "/api/.env": "env", "/.aws/credentials": "cloud", "/.git/config": "depot-vcs",
"/.claude/settings.json": "ia-outils", "/.env.anthropic": "ia-outils", "/.cursor/mcp.json": "ia-outils", "/wp-config.php.bak": "config-appli",
"/id_rsa": "ssh-cles", "/docker-compose.yml": "conteneurs-ci", "/backup.zip": "sauvegardes", "/": None, "/blog/environnement": None,
"/%2e%65nv": "env", "/wp-content/uploads/2026/10/backup-hero.png": None, "/envoi.html": None}
for u, att in cas.items():
self.assertEqual(classer(u), att, u)
def test_rafale_et_reussite(self):
ls = [l(f"11/Oct/2026:13:00:{s:02d} +0000", "403", p) for s, p in enumerate(["/.env", "/.env.bak", "/.aws/credentials", "/.git/config"])]
ls.append(l("11/Oct/2026:13:00:10 +0000", "200", "/.env.example", ip="203.0.113.10"))
ls.append(l("11/Oct/2026:13:00:11 +0000", "200", "/", ip="203.0.113.10"))
with tempfile.NamedTemporaryFile("w", delete=False) as f:
f.write("\n".join(ls) + "\nligne illisible\n")
try:
r = analyser(lire([f.name]), rafale=4)
finally:
os.unlink(f.name)
self.assertEqual(len(r["adresses"]), 2)
self.assertEqual(r["adresses"][0]["ip"], "203.0.113.9"); self.assertEqual(r["adresses"][0]["rafale_max"], 4)
self.assertEqual(len(r["rafales"]), 1); self.assertEqual(len(r["reussites"]), 1)
self.assertEqual(r["reussites"][0]["uri"], "/.env.example")
def test_fenetre(self):
ls = [l("11/Oct/2026:13:00:00 +0000", "404", "/.env"), l("11/Oct/2026:13:10:00 +0000", "404", "/.git/config")]
with tempfile.NamedTemporaryFile("w", delete=False) as f:
f.write("\n".join(ls))
try:
r = analyser(lire([f.name]), rafale=2)
finally:
os.unlink(f.name)
self.assertEqual(r["adresses"][0]["rafale_max"], 1); self.assertEqual(r["rafales"], [])
res = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(T))
return 0 if res.wasSuccessful() else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("fichiers", nargs="*"); ap.add_argument("--rafale", type=int, default=10); ap.add_argument("--json", action="store_true")
ap.add_argument("--top", type=int, default=8); ap.add_argument("--test", action="store_true")
a = ap.parse_args()
if a.test:
return test()
if not a.fichiers:
ap.error("au moins un fichier de journal")
r = analyser(lire(a.fichiers), rafale=a.rafale)
if a.json:
r["familles"], r["statuts"] = dict(r["familles"]), dict(r["statuts"])
print(json.dumps(r, ensure_ascii=False, indent=1))
else:
print(texte(r, a.top))
return 0
if __name__ == "__main__":
sys.exit(main())
verifier_exposition.py
#!/usr/bin/env python3
"""verifier_exposition.py : demande à VOTRE site les chemins que réclament les scanners de secrets et dit lesquels répondent 200.
Ne lit que le code HTTP et la taille ; n'affiche jamais le contenu. À n'utiliser que sur un site dont vous êtes responsable.
Usage : verifier_exposition.py https://exemple.fr [--lent 0.2] (code de sortie 1 si au moins un chemin répond 2xx) ; --test"""
import argparse, sys, time, urllib.request, urllib.error, http.server, threading, tempfile, os, functools
CHEMINS = ["/.env", "/.env.local", "/.env.production", "/.env.backup", "/.env.bak", "/.env.example", "/app/.env", "/api/.env", "/backend/.env", "/.env.anthropic", "/.env.openai",
"/.claude/settings.json", "/.claude/settings.local.json", "/.anthropic/config.json", "/.mcp.json", "/.cursor/mcp.json", "/.aws/credentials", "/.aws/config", "/.boto",
"/.git/config", "/.git/HEAD", "/.git-credentials", "/.docker/config.json", "/docker-compose.yml", "/.github/workflows/deploy.yml", "/wp-config.php.bak", "/config.json",
"/.htpasswd", "/.npmrc", "/id_rsa", "/.ssh/id_rsa", "/backup.zip", "/dump.sql", "/phpinfo.php", "/.DS_Store"]
class _SansRedirection(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
def verifier(base, pause=0.2, chemins=CHEMINS):
if not base.startswith(("http://", "https://")):
raise ValueError("URL http(s) attendue")
ouv = urllib.request.build_opener(_SansRedirection)
res = []
for c in chemins:
try:
r = ouv.open(urllib.request.Request(base.rstrip("/") + c, headers={"User-Agent": "verifier-exposition/1.0 (controle de mon propre site)"}), timeout=10) # nosec B310
code, taille = r.status, len(r.read(2_000_000))
except urllib.error.HTTPError as e:
code, taille = e.code, 0
except Exception as e:
code, taille = type(e).__name__, 0
res.append((c, code, taille))
time.sleep(pause)
return res
def test():
import unittest
d = tempfile.mkdtemp()
open(os.path.join(d, ".env"), "w").write("FAUX_SECRET=exemple\n")
os.makedirs(os.path.join(d, ".git")); open(os.path.join(d, ".git", "config"), "w").write("[core]\n")
open(os.path.join(d, "index.html"), "w").write("ok")
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(http.server.SimpleHTTPRequestHandler, directory=d))
threading.Thread(target=srv.serve_forever, daemon=True).start()
class T(unittest.TestCase):
def test_detecte(self):
r = {c: (code, t) for c, code, t in verifier(f"http://127.0.0.1:{srv.server_port}", 0)}
self.assertEqual(r["/.env"][0], 200); self.assertGreater(r["/.env"][1], 0)
self.assertEqual(r["/.git/config"][0], 200); self.assertEqual(r["/.aws/credentials"][0], 404)
def test_url_invalide(self):
with self.assertRaises(ValueError):
verifier("ftp://x")
res = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(T))
srv.shutdown()
return 0 if res.wasSuccessful() else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]); ap.add_argument("url", nargs="?"); ap.add_argument("--lent", type=float, default=0.2); ap.add_argument("--test", action="store_true")
a = ap.parse_args()
if a.test:
return test()
if not a.url:
ap.error("URL requise")
res = verifier(a.url, a.lent)
from collections import Counter
print("Codes : " + ", ".join(f"{k} ×{v}" for k, v in sorted(Counter(str(c) for _, c, _ in res).items())))
exp = [(c, code, t) for c, code, t in res if isinstance(code, int) and 200 <= code < 300]
for c, code, t in exp:
print(f"EXPOSÉ {c} {code} {t} octets")
print("Aucun chemin exposé." if not exp else f"{len(exp)} chemin(s) exposé(s).")
return 1 if exp else 0
if __name__ == "__main__":
sys.exit(main())
À lire aussi
- Journaux Linux : lire une intrusion SSH : même démarche, côté système, pour repérer ce qui ressemble à une attaque.
- OWASP Top 10 LLM 2025 : la fuite de clés d’API d’IA y est un risque à part entière.
Sources
- Sysdig TRT, « AI-assisted cloud intrusion achieves admin access in 8 minutes » : sysdig.com
- Verizon DBIR 2025, résumé par Help Net Security : helpnetsecurity.com
- SANS ISC, « [Guest Diary] AWS Deployment Risks – Configuration and Credential File Targeting » (7 mars 2024) : isc.sans.edu
- Aviatrix, « Webserver Environment File Enumeration » (janvier 2026) : aviatrix.ai
- IETF, RFC 5737 (blocs d’adresses de documentation) : rfc-editor.org
- fail2ban : github.com/fail2ban ; CrowdSec, cscli explain : docs.crowdsec.net