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>
184 lines
7.7 KiB
Python
184 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Devis d'etancheite de la frontiere : ce qui n'est pas declare est-il refuse ?
|
|
|
|
`devis_expositions.py` pose la question POSITIVE — chaque exposition declaree repond-elle.
|
|
Celui-ci pose la NEGATIVE, et c'est une autre question : la frontiere laisse-t-elle passer
|
|
ce que la politique n'autorise pas. Un pare-feu peut tres bien servir tout ce qu'on lui
|
|
demande *et* laisser passer tout le reste.
|
|
|
|
La politique attendue n'est pas saisie : elle est LUE dans `devis_opnsense.py`, la meme
|
|
source qui produit les regles posees sur le boitier. Les cibles ne sont pas saisies non
|
|
plus : ce sont les ports REELLEMENT EN ECOUTE dans la flotte, releves par le playbook.
|
|
Sonder un port ferme ne prouverait rien du pare-feu.
|
|
|
|
Deux modes :
|
|
--cibles lit le releve des ports en ecoute (stdin), emet les listes a sonder
|
|
(defaut) lit le releve complet et rend le verdict, code 1 s'il y a un ecart
|
|
|
|
Aucun acces reseau ici — c'est le playbook qui sonde (D-23).
|
|
|
|
make frontiere-mesurer
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
|
|
# Adresse de controle : documentee comme non routable et attribuee a personne. Si elle
|
|
# repond, c'est l'instrument qui ment — voir l'en-tete de `sonde_tcp.py`.
|
|
CONTROLE = ["172.31.99.99", 22]
|
|
|
|
|
|
def _politique() -> dict:
|
|
"""Ce que la frontiere est CENSEE autoriser, lu a la source qui la configure."""
|
|
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"),
|
|
"--json"], cwd=RACINE, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit("Le devis de la frontiere ne se genere pas :\n"
|
|
+ r.stderr.strip()[:400])
|
|
return json.loads(r.stdout)
|
|
|
|
|
|
def _adresses(devis: dict, nom_alias: str) -> list[str]:
|
|
"""Les adresses derriere un alias — hotes tels quels, reseaux developpes en prefixe."""
|
|
a = devis["alias"].get(nom_alias.lstrip("!"))
|
|
return list(a["contenu"]) if a else []
|
|
|
|
|
|
def autorise_depuis_gestion(devis: dict) -> set[tuple[str, int]]:
|
|
"""Couples (adresse, port) que le VLAN d'administration a le DROIT d'atteindre."""
|
|
permis: set[tuple[str, int]] = set()
|
|
for r in devis["regles"]:
|
|
if r["interface"] != devis.get("if_gestion") or r["sens"] != "in":
|
|
continue
|
|
if r["protocole"] not in ("tcp",):
|
|
continue
|
|
for cible in _adresses(devis, r["destination"]):
|
|
for p in r["ports"] or []:
|
|
if str(p).isdigit():
|
|
permis.add((cible, int(p)))
|
|
return permis
|
|
|
|
|
|
def _couvert(ip: str, permis: set[tuple[str, int]], port: int) -> bool:
|
|
"""Un permis peut etre un hote OU un reseau (le `/24` d'un tenant, par exemple)."""
|
|
for cible, p in permis:
|
|
if p != port:
|
|
continue
|
|
if cible == ip:
|
|
return True
|
|
if "/" in cible:
|
|
try:
|
|
if ipaddress.ip_address(ip) in ipaddress.ip_network(cible, strict=False):
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def cibles(ports_par_hote: dict) -> dict:
|
|
"""Les listes a sonder, derivees de la politique et de ce qui ecoute vraiment."""
|
|
devis = _politique()
|
|
permis = autorise_depuis_gestion(devis)
|
|
|
|
admin = []
|
|
for hote, info in sorted(ports_par_hote.items()):
|
|
ip = str(info["ip"])
|
|
for port in sorted(set(info["ports"])):
|
|
attendu = "LIVRE" if _couvert(ip, permis, int(port)) else "REFUSE"
|
|
admin.append([ip, int(port), f"{hote}:{port} attendu={attendu}"])
|
|
|
|
# Sens sortant : la frontiere elle-meme, qu'aucun tenant ne doit joindre. C'est le
|
|
# trou mesure le 2026-08-09 — `https://10.0.0.1/` repondait depuis une VM.
|
|
interne = []
|
|
for r in devis["regles"]:
|
|
if r["interface"] == devis.get("if_gestion") and r["sens"] == "in":
|
|
for c in _adresses(devis, r["source"]):
|
|
if "/" in c:
|
|
reseau = ipaddress.ip_network(c, strict=False)
|
|
interne.append([str(next(reseau.hosts())), 443,
|
|
"frontiere depuis le tenant attendu=REFUSE"])
|
|
vus, tenant = set(), []
|
|
for c in interne:
|
|
if c[0] not in vus:
|
|
vus.add(c[0])
|
|
tenant.append(c)
|
|
return {"controle": CONTROLE, "admin": admin, "tenant": tenant}
|
|
|
|
|
|
def analyser(releve: dict) -> tuple[list[str], list[str]]:
|
|
"""Confronte les verdicts sondes a la politique. Rend (ecarts, lignes lisibles)."""
|
|
ecarts: list[str] = []
|
|
lignes: list[str] = []
|
|
|
|
for vantage, cle in (("VLAN d'administration", "admin"), ("depuis un tenant", "tenant")):
|
|
bloc = releve.get(cle) or {}
|
|
if not bloc:
|
|
continue
|
|
if not bloc.get("valide", False):
|
|
ecarts.append(
|
|
f"Releve {vantage} NUL : le controle {CONTROLE[0]} a repondu — quelque "
|
|
"chose sur le chemin repond a la place des destinations. Aucun verdict "
|
|
"de ce point de vue n'a de sens tant que ce n'est pas corrige."
|
|
)
|
|
continue
|
|
lignes.append(f" --- {vantage} ---")
|
|
for r in bloc.get("resultats") or []:
|
|
etiq = str(r.get("etiquette") or "")
|
|
attendu = etiq.rsplit("attendu=", 1)[-1] if "attendu=" in etiq else "?"
|
|
obtenu = str(r.get("verdict"))
|
|
# AMBIGU vaut « la connexion s'etablit sans rien livrer » : pour un port qui
|
|
# doit etre REFUSE, c'est un ecart — la frontiere a relaye. Pour un port qui
|
|
# doit etre LIVRE, c'est aussi un ecart : on n'a pas la preuve qu'il sert.
|
|
conforme = (attendu == obtenu)
|
|
lignes.append(f" {'ok ' if conforme else 'ECART'} {etiq[:52]:<52} "
|
|
f"{obtenu:<7} {str(r.get('preuve'))[:40]}")
|
|
if conforme:
|
|
continue
|
|
# Deux natures d'ecart, qui n'appellent pas le meme geste :
|
|
# - REFUSE attendu, connexion etablie -> la bordure a RELAYE. C'est le trou.
|
|
# - LIVRE attendu, rien livre -> la bordure autorise, l'HOTE refuse.
|
|
# Rien ne fuit ; ce sont les deux couches qui ne disent pas la meme chose.
|
|
if attendu == "LIVRE":
|
|
ecarts.append(f"{etiq} — la frontiere l'autorise mais l'hote le refuse : "
|
|
"les deux couches ne declarent pas la meme politique")
|
|
else:
|
|
ecarts.append(f"{etiq} — obtenu {obtenu} : la frontiere a relaye "
|
|
f"({r.get('preuve')})")
|
|
return ecarts, lignes
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--cibles", action="store_true",
|
|
help="emet les listes a sonder (lit les ports en ecoute sur stdin)")
|
|
ap.add_argument("--releve", default=None, help="fichier de releve a analyser")
|
|
a = ap.parse_args(argv)
|
|
|
|
if a.cibles:
|
|
json.dump(cibles(json.load(sys.stdin)), sys.stdout)
|
|
return 0
|
|
|
|
chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-frontiere.json"
|
|
if not chemin.is_file():
|
|
raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.")
|
|
ecarts, lignes = analyser(json.loads(chemin.read_text()))
|
|
print("\n".join(lignes))
|
|
if ecarts:
|
|
print(f"\nNON CONFORME : {len(ecarts)} ecart(s) — la frontiere ne dit pas ce que "
|
|
"la politique dit.")
|
|
for e in ecarts:
|
|
print(" -", e)
|
|
return 1
|
|
print("\nCONFORME : tout ce qui est declare est livre, tout le reste est refuse.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|