L'exploitant a retire la derniere regle heritee. `make frontiere-mesurer` rend CONFORME (code 0) : tout ce qui est declare est livre, tout le reste est refuse — y compris collab-01:9980, le seul qui livrait vraiment un HTTP 200 depuis le poste. Et mon instrument avait tort, pas la frontiere. Il comptait 38 ecarts en concluant depuis le client : « connexion etablie => la bordure a relaye ». Faux, verifie A LA DESTINATION : pendant que le poste tenait une connexion « etablie » vers idm-01:389, idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontiere repond a la poignee TCP sans relayer. Le devis raisonne desormais sur la LIVRAISON seule, et le controle ne rend le releve NUL que s'il LIVRE des donnees — qu'il ressorte AMBIGU est attendu ici et le rapport le dit a chaque execution. Cette relaxation rend aussi le sens sortant mesurable : il etait declare NUL en permanence. Nouveau mot-cle `poste: false` dans meta/flux.yml : un service publie n'est pas forcement fait pour un poste de travail. Le 25 entrant de Postfix est un flux serveur a serveur ; la frontiere l'etendait au VLAN d'administration ou le nftables de l'hote le refusait. Deux regles retirees. Le mot-cle vit avec le role qui sait ce que son port veut dire ; le generateur ne connait toujours aucun numero de port. Verifie : frontiere-plan sans ecart (41 regles, 12 routes), flotte 14/14, frontiere-mesurer CONFORME, prouver.py 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
8.6 KiB
Python
198 lines
8.6 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 LIVRE des donnees — "
|
|
"quelque chose parle 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} ---")
|
|
ctrl = str((bloc.get("controle") or {}).get("verdict"))
|
|
if ctrl == "AMBIGU":
|
|
lignes.append(
|
|
f" (la poignee TCP est repondue par la frontiere — le controle "
|
|
f"{CONTROLE[0]} « s'etablit » aussi. Seule la LIVRAISON compte ici.)")
|
|
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"))
|
|
# SEULE LA LIVRAISON COMPTE, et c'est tout l'enseignement de la semaine.
|
|
#
|
|
# `AMBIGU` vaut « la connexion s'etablit sans rien livrer ». Longtemps compte
|
|
# comme un trou — « la bordure a relaye » — c'etait faux : verifie A LA
|
|
# DESTINATION le 2026-08-09, ni `idm-01` ni `collab-01` ne voyaient la moindre
|
|
# connexion pendant que le poste en tenait une « etablie ». La frontiere
|
|
# repond a la poignee sans jamais relayer. Le controle le confirme a chaque
|
|
# execution : lui aussi ressort AMBIGU, et personne n'ecoute derriere.
|
|
#
|
|
# Un port est donc conforme s'il livre quand il doit livrer, et ne livre pas
|
|
# quand il ne doit pas. Ce que fait la poignee TCP ne regarde personne.
|
|
conforme = (obtenu == "LIVRE") == (attendu == "LIVRE")
|
|
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} — rien n'est livre : la frontiere l'autorise mais "
|
|
"l'hote le refuse. Les deux couches ne declarent pas la "
|
|
"meme politique.")
|
|
else:
|
|
ecarts.append(f"{etiq} — DES DONNEES SONT LIVREES alors que la politique "
|
|
f"l'interdit ({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())
|