From 3b65384b0f4ea0ab82423801cdbeba10b76f9598 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 27 Aug 2026 18:10:39 -0400 Subject: [PATCH] frontiere : le devis sait desormais refuser SANS consigner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur. Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux a la main sur le boitier : exactement ce que ce projet refuse. CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour, dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour. Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira. TROIS PIECES. `cle_regle` accepte une action sans changer d'un octet la cle des regles `pass` deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer, 0 a retirer, 121 inchangees. `_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un blocage large emis avant les `pass` fermerait courrier, web et acces distant. `devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli. P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1 echoue, un silence sans motif echoue. Carte : 28 pieces d'audit (P48 l'avait vu juste). make prouver : CONFORME, 50 OK, 0 echec. Co-Authored-By: Claude Opus 5 --- docs/audit/preuve-2026-08-27.md | 5 +-- docs/carte-set-ops.md | 2 +- scripts/appliquer_opnsense.py | 30 +++++++++++++--- scripts/devis_opnsense.py | 53 ++++++++++++++++++++++++++-- scripts/prouver.py | 62 +++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/docs/audit/preuve-2026-08-27.md b/docs/audit/preuve-2026-08-27.md index 07edfe0..8aadbda 100644 --- a/docs/audit/preuve-2026-08-27.md +++ b/docs/audit/preuve-2026-08-27.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (49 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (50 OK · 0 echec · 0 saute) ## Preuves @@ -46,7 +46,7 @@ | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 50 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). | -| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (24 genere(s) exempte(s)). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (25 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). | | P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | | P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | @@ -62,6 +62,7 @@ | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (110 lignes). | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 121 regles `pass`), tous non consignes et tous motives. | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 934bc2a..1ed733b 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous. | rôles | 61 | `roles/*/` | | README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | documents | 38 | `docs/*.md` | -| pièces d'audit | 27 | `docs/audit/*` | +| pièces d'audit | 28 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index eb5fcb3..295aabf 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -111,7 +111,14 @@ def cle_regle(r: dict) -> str: une regle a retirer, ce qui est exactement la verite. """ ports = ",".join(map(str, r["ports"] or ["*"])) - return (f"setops:{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:" + # L'ACTION N'ENTRE DANS LA CLE QUE SI ELLE N'EST PAS `pass` (2026-08-27). Les regles + # de SILENCE — des `block` non consignes — ont besoin d'une identite distincte, mais + # l'ajout naif d'un champ aurait change la cle des 117 regles deja posees : le + # rapprochement les aurait toutes detruites pour les recreer a l'identique, sur la + # frontiere, en production. Une marque vide pour le cas majoritaire garde ces cles + # octet pour octet, et n'en donne une nouvelle qu'a ce qui est nouveau. + marque = "" if str(r.get("action") or "pass") == "pass" else f"{r['action']}:" + return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:" f"{r['source']}->{r['destination']}:{ports}") @@ -174,14 +181,29 @@ def _corps_regle(r: dict, k: str) -> dict: # le champ `destination_not` d'OPNsense. dst = str(r["destination"]) nie = dst.startswith("!") + # L'ACTION ET LA CONSIGNATION VIENNENT DU DEVIS (2026-08-27). Elles etaient en dur : + # l'outil ne savait qu'AUTORISER, et une regle qui fait taire du bruit deja refuse + # n'avait donc aucun moyen de naitre du depot. Les defauts reproduisent exactement + # l'ancien comportement — `pass`, non consigne, sequence 1 — pour que les regles + # existantes soient emises a l'identique. + # + # LA SEQUENCE EST CE QUI REND UN `block` SUR : OPNsense evalue en `quick`, donc la + # PREMIERE regle qui correspond gagne. Un blocage large pose avant les `pass` du + # devis fermerait le courrier, le web et l'acces distant. Les silences se declarent + # a 900, apres tout le reste. corps = { "enabled": "1", - "sequence": "1", - "action": "pass", + "sequence": str(r.get("sequence") or "1"), + "action": str(r.get("action") or "pass"), + "log": "1" if r.get("journaliser") else "0", "interface": r["interface"], "direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61) "ipprotocol": "inet", - "protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(), + # `any` reste EN MINUSCULES : OPNsense attend ses protocoles en majuscules mais + # nomme « tout protocole » `any`. Un `ANY` majuscule est rejete a la validation. + "protocol": ("any" if r["protocole"] == "any" + else "ICMP" if r["protocole"] == "icmp" + else r["protocole"].upper()), "source_net": r["source"], "destination_net": dst[1:] if nie else dst, "destination_not": "1" if nie else "0", diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 48416f6..4bf42fd 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -832,6 +832,50 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "tenant": "SITE", }) + # LES SILENCES — refuser sans CONSIGNER (2026-08-27). + # + # Un pare-feu refuse deja tout ce qui n'est pas declare ; le probleme n'est pas ce + # qu'il bloque, c'est ce qu'il ECRIT. Mesure du jour : 982 000 entrees par jour, dont + # 82 % un balayage Internet contre le port VNC et le reste du bavardage de decouverte + # (mDNS, SSDP, NetBIOS) du reseau local. La consequence n'est pas cosmetique — la + # fenetre utile du journal tombait a QUARANTE-QUATRE SECONDES, moins que le temps de + # reproduire un defaut. Un journal noye ne dit rien, exactement comme un journal mort. + # + # Une regle de silence ne change AUCUN comportement : ce qu'elle bloque etait deja + # refuse par le defaut. Elle ne fait que taire une trace que personne ne lira. C'est + # pourquoi elle se declare ici, avec son MOTIF : sans lui, une regle `block` muette + # dans un pare-feu est indiscernable d'un oubli. + for s in (intrants.get("opnsense_silences") or []): + _dest = str(s.get("destination") or "any") + # Une liste d'adresses devient un ALIAS, pas une regle par adresse : l'alias + # nomme l'intention, et le boitier montre alors POURQUOI c'est tu. + if s.get("destinations"): + _nom_alias = f"SETOPS_SILENCE_{str(s['nom']).upper()}" + alias[_nom_alias] = { + "type": "network", + "contenu": [str(x) for x in s["destinations"]], + "description": f"Silence — {s.get('motif') or s['nom']}", + } + _dest = _nom_alias + regles.append({ + "sens": "in", + "interface": str(s.get("interface") or if_wan), + "protocole": str(s.get("protocole") or "any"), + "source": str(s.get("source") or "any"), + "destination": _dest, + "ports": [str(p) for p in (s.get("ports") or [])], + "role": f"silence:{s.get('nom') or 'bruit'}", + "tenant": "SILENCE", + "action": "block", + "journaliser": False, + # 900 : APRES tous les `pass` (sequence 1) et Wireguard (101). OPNsense evalue + # en `quick` — un blocage large place avant eux fermerait le courrier, le web + # et l'acces distant d'un seul coup. + "sequence": str(s.get("sequence") or 900), + "chiffrement": "sans objet", + "raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"), + }) + # Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois : # elle sert au prochain saut des routes ET au commentaire qui l'explique. _ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit) @@ -1011,10 +1055,15 @@ def rendre(devis: dict) -> str: # Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est # la que le paquet penetre le pare-feu. Le sens du flux (entrant/sortant vis-a-vis # de l'ecosysteme) se lit alors dans l'interface, pas dans un mot-cle. + # L'ACTION S'AFFICHE (2026-08-27) : le rendu disait « pass » pour tout, ce qui + # etait vrai tant que l'outil ne savait qu'autoriser. Un devis qui affiche + # « pass » devant une regle de blocage serait pire qu'un devis muet. + _act = str(rg.get("action") or "pass") + _mut = "" if rg.get("journaliser", False) or _act == "pass" else " [non consigne]" out.append( - f"pass in on {rg['interface']:8} proto {rg['protocole']:3} " + f"{_act:4} in on {rg['interface']:8} proto {rg['protocole']:3} " f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} " - f"# {rg['role']} ({rg['chiffrement']})" + f"# {rg['role']} ({rg['chiffrement']}){_mut}" ) out.append(f"# {rg['raison']}") sortants = sum(1 for r in devis["regles"] if r["sens"] == "out") diff --git a/scripts/prouver.py b/scripts/prouver.py index fb4dd6b..20d9719 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,66 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_silences_declares() -> tuple[bool, str]: + """Un `block` muet doit etre DECLARE, place APRES les `pass`, et porter son motif. + + POURQUOI (mesure du 2026-08-27). Le journal de la frontiere ecrivait 982 000 entrees + par jour, dont 82 % un balayage Internet contre le port VNC. Sa fenetre utile etait + tombee a QUARANTE-QUATRE SECONDES : j'y ai cherche la trace d'un flux du site vers les + hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne + bloquait. Un journal noye ment aussi surement qu'un journal mort. + + La reponse — taire ce qui est deja refuse — introduit dans l'outil sa PREMIERE regle + de blocage. Elle amene deux dangers que cette preuve garde : + + 1. L'ORDRE. OPNsense evalue en `quick` : la premiere regle qui correspond gagne. Un + blocage large emis avant les `pass` du devis fermerait d'un coup le courrier, le + web et l'acces distant. Les silences doivent donc porter une sequence STRICTEMENT + superieure a celle de toute regle `pass`. + + 2. LE MOTIF. Une regle `block` sans raison ecrite est indiscernable d'un oubli pour + qui relira cette frontiere dans six mois. Le silence se justifie ou n'existe pas. + + On rejoue le devis en memoire — rien n'est ecrit, rien n'est joint sur le reseau. + """ + try: + sys.path.insert(0, str(RACINE / "scripts")) + import devis_opnsense as DO + devis = DO.construire(DO.decouvrir_du_site()) + except Exception as e: # noqa: BLE001 + return False, (f"Le devis de la frontiere n'a pas pu etre rejoue : {e}. " + f"Sans lui, on ne sait pas ce que la frontiere recevrait.") + + regles = devis.get("regles") or [] + silences = [r for r in regles if str(r.get("action") or "pass") != "pass"] + if not silences: + return True, ("Aucune regle de blocage au devis — rien a garder. La preuve " + "redeviendra parlante des qu'un silence sera declare.") + + seq_pass = [int(r.get("sequence") or 1) for r in regles + if str(r.get("action") or "pass") == "pass"] + plancher = max(seq_pass) if seq_pass else 1 + fautes = [] + for r in silences: + seq = int(r.get("sequence") or 1) + if seq <= plancher: + fautes.append( + f"{r['interface']}/{r['role']} : sequence {seq} <= {plancher}, la plus " + f"haute des regles `pass`. En `quick`, ce blocage gagnerait AVANT elles.") + if r.get("journaliser"): + fautes.append(f"{r['interface']}/{r['role']} : consigne — ce n'est pas un silence.") + raison = str(r.get("raison") or "").strip() + if len(raison) < 30: + fautes.append( + f"{r['interface']}/{r['role']} : motif absent ou trop court " + f"({len(raison)} car.). Un blocage muet sans raison ecrite est un oubli.") + if fautes: + return False, " | ".join(fautes) + return True, (f"{len(silences)} silence(s) declare(s), tous en sequence > {plancher} " + f"(la plus haute des {len(seq_pass)} regles `pass`), tous non consignes " + f"et tous motives.") + + def preuve_registre_flux_a_jour() -> tuple[bool, str]: """La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI. @@ -1792,6 +1852,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_carte_dit_vrai}, {"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour", "refs": [], "func": preuve_registre_flux_a_jour}, + {"id": "P50", "titre": "Silences : un refus muet est declare, place en dernier, et motive", + "refs": [], "func": preuve_silences_declares}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],