devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
#!/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."
|
devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
)
|
|
|
|
|
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.)")
|
devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
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")
|
devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
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.")
|
devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
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')})")
|
devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
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>
2026-08-09 21:03:28 -04:00
|
|
|
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())
|