devis_expositions pose la question positive (une exposition declaree repond-elle). Celle-ci manquait, et ce n'est pas la meme : un pare-feu peut servir tout ce qu'on lui demande ET laisser passer tout le reste. Rien n'y est saisi. Les cibles sont les ports REELLEMENT en ecoute (sonder un port ferme ne prouverait rien du pare-feu) ; la politique attendue est lue dans devis_opnsense.py, la source qui configure la frontiere. scripts/sonde_tcp.py refuse de conclure : un CONTROLE (adresse ou personne n'ecoute) avant tout verdict — s'il repond, le releve est declare NUL. Et etablir n'est pas livrer : banniere, sinon requete HTTP, sinon poignee TLS ; sans reponse le verdict est AMBIGU, jamais « ouvert ». Deux pieges rencontres en le construisant, corriges et commentes sur place : la sonde « tenant » s'executait sur le poste (dans un play connection: local, delegate_to herite de cette connexion) ; et 2 s de lecture faisaient ressortir un Postfix sain en AMBIGU (postscreen retarde sa banniere expres) — porte a 8 s, compense par du parallelisme. Premier verdict, frontiere en l'etat : 38 ecarts, dont collab-01:9980 qui livre vraiment un HTTP 200 depuis le poste. Releve sortant declare NUL. Verifie : ansible-lint Passed, prouver.py 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.5 KiB
Python
126 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Sonde TCP honnete : distingue LIVRE, BLOQUE et AMBIGU — et refuse de conclure.
|
|
|
|
Pourquoi ce fichier existe. Pendant deux jours, `connect()` a menti : a travers la
|
|
frontiere, toute connexion aboutissait, y compris vers `172.31.99.99` — une adresse qui
|
|
n'appartient a personne. La cause etait un `synproxy` porte par une regle heritee : le
|
|
pare-feu repondait au SYN a la place de la destination. Trois faux diagnostics en sont
|
|
sortis avant qu'un controle ne le revele.
|
|
|
|
D'ou les deux principes de cette sonde :
|
|
|
|
1. **Un CONTROLE avant toute conclusion.** On sonde d'abord une adresse ou personne
|
|
n'ecoute. Si elle « repond », l'instrument est en cause et le releve est declare NUL —
|
|
aucun verdict n'est rendu. Mieux vaut pas de mesure qu'une mesure fausse.
|
|
|
|
2. **On ne conclut jamais sur `connect()` seul.** Etablir n'est pas livrer. La sonde
|
|
essaie de faire PARLER le service, dans cet ordre : lire une banniere (SSH, SMTP,
|
|
IMAP parlent en premier), sinon envoyer une requete HTTP minimale, sinon tenter une
|
|
poignee TLS. Si rien ne revient, le verdict est **AMBIGU**, pas « ouvert » : certains
|
|
protocoles (LDAP, PostgreSQL) attendent un message bien forme qu'on ne fabrique pas
|
|
ici, et un synproxy se comporte exactement pareil.
|
|
|
|
Usage : la liste des cibles arrive en JSON sur l'entree standard, les verdicts sortent en
|
|
JSON sur la sortie standard. Aucun etat, aucun fichier, aucun privilege.
|
|
|
|
echo '{"controle": ["172.31.99.99", 22], "cibles": [["10.27.18.21", 22, "ssh"]]}' \
|
|
| python3 scripts/sonde_tcp.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
DELAI_CONNEXION = 4.0
|
|
# Genereux a dessein : Postfix retarde sa banniere de plusieurs secondes (postscreen le
|
|
# fait expres, pour pieger les spambots). A 2 s, un port parfaitement sain ressortait
|
|
# AMBIGU. On compense le cout par le parallelisme plutot qu'en raccourcissant l'attente —
|
|
# raccourcir aurait fabrique de faux ecarts, ce que cette sonde existe pour eviter.
|
|
DELAI_LECTURE = 8.0
|
|
PARALLELE = 12
|
|
|
|
|
|
def _sonder(hote: str, port: int) -> dict:
|
|
"""Un verdict pour une cible : BLOQUE, LIVRE ou AMBIGU, avec la preuve."""
|
|
debut = time.monotonic()
|
|
try:
|
|
s = socket.create_connection((hote, port), timeout=DELAI_CONNEXION)
|
|
except OSError as e:
|
|
return {"verdict": "BLOQUE", "ms": int((time.monotonic() - debut) * 1000),
|
|
"preuve": type(e).__name__}
|
|
ms = int((time.monotonic() - debut) * 1000)
|
|
try:
|
|
s.settimeout(DELAI_LECTURE)
|
|
# 1. Le serveur parle-t-il en premier ? (SSH, SMTP, IMAP, POP3, redis apres coup)
|
|
try:
|
|
data = s.recv(96)
|
|
if data:
|
|
return {"verdict": "LIVRE", "ms": ms,
|
|
"preuve": data.decode("utf-8", "replace").strip()[:60]}
|
|
except (TimeoutError, socket.timeout, OSError):
|
|
pass
|
|
# 2. Sinon, le faire parler. Une requete HTTP minimale suffit pour tout ce qui
|
|
# parle HTTP, et arrache une erreur lisible a plusieurs qui ne le parlent pas.
|
|
try:
|
|
s.sendall(b"GET / HTTP/1.0\r\nHost: sonde\r\n\r\n")
|
|
data = s.recv(96)
|
|
if data:
|
|
return {"verdict": "LIVRE", "ms": ms,
|
|
"preuve": data.decode("utf-8", "replace").strip()[:60]}
|
|
except (TimeoutError, socket.timeout, OSError):
|
|
pass
|
|
finally:
|
|
try:
|
|
s.close()
|
|
except OSError:
|
|
pass
|
|
# 3. Dernier essai : une vraie poignee TLS. En TLS c'est le CLIENT qui parle en
|
|
# premier — le silence des deux etapes precedentes ne prouve donc rien ici.
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
with socket.create_connection((hote, port), timeout=DELAI_CONNEXION) as brut:
|
|
with ctx.wrap_socket(brut) as tls:
|
|
cert = tls.getpeercert(binary_form=True)
|
|
if cert:
|
|
return {"verdict": "LIVRE", "ms": ms,
|
|
"preuve": f"TLS {tls.version()}, certificat servi"}
|
|
except (OSError, ssl.SSLError):
|
|
pass
|
|
return {"verdict": "AMBIGU", "ms": ms,
|
|
"preuve": "connexion etablie, aucune donnee applicative"}
|
|
|
|
|
|
def main() -> int:
|
|
# En argument quand la sonde est deposee sur un hote distant (`ansible.builtin.script`
|
|
# ne sait pas alimenter l'entree standard), sur stdin en local.
|
|
entree = json.loads(sys.argv[1]) if len(sys.argv) > 1 else json.load(sys.stdin)
|
|
ctrl_hote, ctrl_port = entree["controle"]
|
|
controle = _sonder(str(ctrl_hote), int(ctrl_port))
|
|
|
|
# Le controle DOIT etre bloque. S'il ne l'est pas, quelque chose sur le chemin repond
|
|
# a la place des destinations : plus aucun verdict de ce releve n'a de sens.
|
|
if controle["verdict"] != "BLOQUE":
|
|
json.dump({"valide": False, "controle": controle, "resultats": []}, sys.stdout)
|
|
return 0
|
|
|
|
def _un(cible) -> dict:
|
|
hote, port = str(cible[0]), int(cible[1])
|
|
return {"hote": hote, "port": port,
|
|
"etiquette": cible[2] if len(cible) > 2 else "",
|
|
**_sonder(hote, port)}
|
|
|
|
liste = entree.get("cibles") or []
|
|
with ThreadPoolExecutor(max_workers=PARALLELE) as pool:
|
|
resultats = list(pool.map(_un, liste))
|
|
json.dump({"valide": True, "controle": controle, "resultats": resultats}, sys.stdout)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|