Set-OPS-Public/scripts/devis_frontiere.py

199 lines
8.6 KiB
Python
Raw Normal View History

#!/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(
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens 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>
2026-08-09 21:33:04 -04:00
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} ---")
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens 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>
2026-08-09 21:33:04 -04:00
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"))
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens 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>
2026-08-09 21:33:04 -04:00
# 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":
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens 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>
2026-08-09 21:33:04 -04:00
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:
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens 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>
2026-08-09 21:33:04 -04:00
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())