From 67565aade6d61521df3235f2323a74778d6d7d2e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 9 Aug 2026 17:00:08 -0400 Subject: [PATCH] =?UTF-8?q?frontiere=20:=20=C2=AB=20vers=20Internet=20?= =?UTF-8?q?=C2=BB=20n'est=20plus=20=C2=AB=20vers=20n'importe=20ou=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation a l'instrument de l'exigence « aucun trafic impertinent », par de vraies requetes applicatives contre des destinations interdites. Le transit tenant etait deja correct : hyperviseur, frontiere, poste et Proxmox tous muets ; le 25 sortant passe depuis edge-mta-01 (banniere 220 mx.google.com) et est refuse depuis infra-dns-01. Trou 1, a nous : les flux sortants visaient `any`, donc n'excluaient ni le plan de gestion ni le voisin — https://10.0.0.1/ repondait depuis une VM. Ils visent desormais !SETOPS_INTERNES, destination NIEE valant les trois blocs prives RFC 1918. Pas la liste de nos reseaux : elle laissait dehors 192.168.11.0/24, le plan de gestion herite. Trou 2, pas a nous : la regle d'usine « Default allow LAN to any » privait notre defaut-deny de tout effet (mesure : le 443 d'un nginx repondait depuis le poste alors que seul le 22 est declare). Aucune API ne l'expose. Set-OPS declare donc les flux d'administration legitimes vers les services publies, pour que la desactiver ne coupe pas l'exploitant de ses consoles web. Verifie apres application : 10.0.0.1:443 bloque depuis le tenant, sortie web + DNS + SMTP public toujours passants, curl vers le nginx du tenant -> 302, flotte 14/14, frontiere-plan sans ecart, prouver.py 0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 39 ++++++++++++++++++++++++++++++ docs/audit/preuve-2026-08-09.md | 2 +- docs/frontiere-opnsense.md | 24 ++++++++++++++++++ scripts/appliquer_opnsense.py | 13 ++++++++-- scripts/devis_opnsense.py | 43 +++++++++++++++++++++++++++++++-- 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c52aa..1ce63af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés + +Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre +des destinations que la politique **interdit**, jamais un `connect()`. + +**Le transit tenant était déjà correct.** Depuis une VM : `192.168.11.41:22` (hyperviseur), +`10.0.0.1:22` (frontière), `10.0.0.17:22` (poste), `192.168.11.41:8006` (Proxmox) — tous +muets. Seul ce qui est déclaré passe. Mieux : le `25` sortant passe depuis `edge-mta-01` +(bannière `220 mx.google.com ESMTP`) et **est refusé depuis `infra-dns-01`** — le filtrage +est bien par hôte source, pas par tenant. + +**Trou n° 1 — nos flux sortants visaient `any`.** « Vers Internet » n'excluait ni le plan de +gestion, ni la frontière, ni le supernet du voisin. Mesuré : depuis une VM du tenant, +`https://10.0.0.1/` — la console d'administration du pare-feu — **répondait**. Autorisé par +notre propre règle. Les dix-huit règles sortantes visent désormais `!SETOPS_INTERNES`, une +destination **niée** valant les trois blocs privés RFC 1918. Pas la liste de nos réseaux : +elle laissait dehors `192.168.11.0/24`, le plan de gestion hérité — et une exclusion +incomplète ne protège rien. Un réseau interne ajouté demain est couvert sans rien changer. + +Vérifié après application : `10.0.0.1:443` bloqué depuis les deux VM testées, sortie web, +DNS public et SMTP vers un MX public toujours passants, flotte 14/14. + +**Trou n° 2 — le défaut-deny du LAN n'avait aucun effet.** Mesuré depuis le poste : le `443` +d'un nginx répondait alors que seul le `22` est déclaré. La cause est la règle **d'usine** +`Default allow LAN to any rule`, qui autorise tout depuis le VLAN d'administration. Elle +n'est pilotable par **aucune API** — vérifié : zéro règle non-Set-OPS visible côté API. Sa +désactivation appartient donc à l'exploitant, dans l'interface. + +Ce que Set-OPS pouvait faire, et fait : **déclarer les flux d'administration légitimes** +pour que cette désactivation ne coupe pas l'exploitant de ses propres services. Un service +publié est joignable depuis Internet par le WAN, mais aussi depuis le VLAN d'administration +où se trouve son poste ; ce second chemin ne reposait jusqu'ici que sur la règle d'usine. +Dix règles ajoutées sur l'interface de gestion (80, 443, 993, 25 et ICMP `frag-needed` vers +les hôtes concernés, par tenant). Vérifié : `curl` vers le nginx du tenant rend `302`. + +**Ce qui reste, et qui n'est pas à nous** : tant que `Default allow LAN to any rule` est +active, le VLAN d'administration atteint tout. Les règles qui la rendent superflue sont +maintenant en place — la désactiver est un geste d'interface, à faire les yeux ouverts. + ## 2026-08-09 — La frontière ne déclare plus que ce qui existe, et l'applicateur possède enfin ses routes Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à diff --git a/docs/audit/preuve-2026-08-09.md b/docs/audit/preuve-2026-08-09.md index c4402b7..720aa7d 100644 --- a/docs/audit/preuve-2026-08-09.md +++ b/docs/audit/preuve-2026-08-09.md @@ -36,7 +36,7 @@ | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (20 sections). | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. | -| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 31 regles, 12 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 41 regles, 12 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 38 groupe(s), 64 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 0 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index a20083b..6237b72 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -86,6 +86,30 @@ Les alias d'hôtes sont préfixés du tenant (`SETOPS_CHEZ17_SERVEUR_NGINX`), et — ce que les ACL de switch interdisent par ailleurs. La bordure ne doit pas rouvrir ce que l'isolation inter-tenant ferme. +### « Vers Internet » n'est pas « vers n'importe où » + +Les flux **sortants** d'un tenant (DNS, SMTP, NTP, HTTP/HTTPS) visaient `any`. Leur +destination n'excluait donc ni le plan de gestion, ni la frontière elle-même, ni le supernet +du voisin : mesuré le 2026-08-09, `https://10.0.0.1/` — la console d'administration du +pare-feu — répondait depuis une VM du tenant. Ils visent désormais **`!SETOPS_INTERNES`**, +une destination *niée* valant les trois blocs privés RFC 1918. + +Pourquoi RFC 1918 et non la liste de nos réseaux : cette liste laissait dehors +`192.168.11.0/24`, le plan de gestion hérité. Une exclusion incomplète ne protège rien, et +un réseau interne ajouté demain doit être couvert sans qu'on y pense. + +### Le VLAN d'administration atteint les services publiés + +Un service publié est joint depuis Internet par le WAN, mais aussi depuis le VLAN +d'administration, où se trouve le poste de l'exploitant. Ce second chemin ne reposait sur +aucune règle Set-OPS : il fonctionnait grâce à la règle **d'usine** +`Default allow LAN to any rule`, qui autorise tout depuis le LAN — et qui privait donc notre +défaut-deny de tout effet. Cette règle héritée n'a **aucune API** : sa désactivation est un +geste d'interface, qui appartient à l'exploitant. + +Set-OPS déclare donc explicitement ces flux sur l'interface de gestion, afin que la +désactiver ne coupe personne de ses propres consoles web. + Deux omissions sont **annoncées** plutôt que tues : un tenant sans inventaire généré (aucune règle) et un tenant dont `nftables_admin_ssh` est vide (règle SSH omise — l'ouvrir à `any` exposerait le SSH à Internet). diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index b1f01ac..9752dba 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -146,6 +146,11 @@ def _corps_nat(n: dict, k: str) -> dict: def _corps_regle(r: dict, k: str) -> dict: + # Une destination prefixee de `!` est une destination NIEE (« tout sauf »). Le devis + # porte le `!` pour qu'il compte dans l'identite de la regle ; c'est ici qu'il devient + # le champ `destination_not` d'OPNsense. + dst = str(r["destination"]) + nie = dst.startswith("!") corps = { "enabled": "1", "sequence": "1", @@ -155,7 +160,8 @@ def _corps_regle(r: dict, k: str) -> dict: "ipprotocol": "inet", "protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(), "source_net": r["source"], - "destination_net": r["destination"], + "destination_net": dst[1:] if nie else dst, + "destination_not": "1" if nie else "0", "description": f"{k} — {r['role']}", } if r["protocole"] in ("tcp", "udp") and r["ports"]: @@ -187,7 +193,10 @@ def plan(api: Frontiere, devis: dict) -> dict: # Un alias encore reference par une regle qui SURVIT ne doit pas partir : la # suppression echouerait, et le boitier resterait a moitie reconcilie. survivants = {r["source"] for k, r in voulues.items()} - survivants |= {r["destination"] for r in devis["regles"]} + # `lstrip('!')` : une destination niee reference le MEME alias. L'oublier ferait + # passer `SETOPS_INTERNES` pour un orphelin, et le script tenterait de supprimer + # l'alias que ses propres regles utilisent. + survivants |= {str(r["destination"]).lstrip("!") for r in devis["regles"]} survivants |= {n["source"] for n in devis.get("nat") or []} # references par le NAT nat_voulus = {cle_nat(n): n for n in devis.get("nat") or []} diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 737228c..d02f408 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -285,6 +285,26 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "description": f"Sous-reseaux attribues du tenant {nom} (index {n['index']})", } + # « Vers Internet » ne veut pas dire « vers n'importe ou ». Les flux sortants d'un + # tenant (DNS, SMTP, NTP, HTTP/HTTPS) visaient `any` : la destination n'excluait donc + # ni le plan de gestion, ni la frontiere elle-meme, ni le supernet du VOISIN. Mesure du + # 2026-08-09 depuis une VM du tenant : `https://10.0.0.1/` — la console d'administration + # du pare-feu — repondait. Autorise par notre propre regle, pas par une regle heritee. + # + # Cet alias dit ce qui est INTERNE ; les regles sortantes le prennent en destination + # NIEE. Il vaut les TROIS BLOCS PRIVES (RFC 1918) et non la liste de nos reseaux : une + # exclusion incomplete ne protege rien, et une liste derivee de l'underlay laissait + # dehors `192.168.11.0/24` — le plan de gestion herite, celui-la meme ou fuyaient les + # paquets du 2026-08-09. Un reseau interne ajoute demain est couvert sans rien changer. + # + # C'est aussi la definition honnete de « vers Internet » : tout ce qui n'est pas prive. + alias["SETOPS_INTERNES"] = { + "type": "network", + "contenu": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], + "description": "Espaces prives RFC 1918 — sert en destination NIEE aux flux " + "sortants : « vers Internet » n'est pas « vers n'importe ou »", + } + # Reseaux d'administration : UN ALIAS PAR TENANT, jamais une union. Chaque tenant # declare les siens (intrant `nftables_admin_ssh`) et ils n'ouvrent QUE son supernet : # une union laisserait le plan de gestion d'un tenant entrer chez le voisin, ce que @@ -389,15 +409,34 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion)) if admin_par_if[etiquette]["wan"]: portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan)) + elif entrant: + # Un service publie est joint DEPUIS INTERNET par le WAN, mais aussi + # depuis le VLAN d'administration — le poste de l'exploitant y est. + # Ce second chemin fonctionnait jusqu'ici par la regle d'usine + # `Default allow LAN to any`, qui autorise TOUT depuis le LAN : notre + # defaut-deny n'y avait donc aucun effet. Mesure du 2026-08-09 : depuis + # le poste, le 443 d'un nginx repondait alors que seul le 22 est declare. + # + # Cette regle heritee n'a AUCUNE API (verifie : zero regle non-Set-OPS + # visible) — elle se desactive a la main. La declarer ici est ce qui rend + # cette desactivation possible SANS couper l'exploitant de ses propres + # services. Sans elle, fermer le LAN fermerait aussi ses consoles web. + portees = [("any", if_wan)] + if admin_par_if[etiquette]["gestion"]: + portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion)) else: - portees = [("any", if_wan if entrant else if_transit)] + portees = [("any", if_transit)] for source, interface in portees: regles.append({ "sens": "in" if entrant else "out", "interface": interface, "protocole": fl.get("protocole", "tcp"), "source": source if entrant else destination, - "destination": destination if entrant else "any", + # Sortant : « tout sauf l'interne ». Le `!` est porte par le devis + # lui-meme pour qu'il apparaisse dans l'identite de la regle — sans + # quoi la version niee et la version ouverte auraient la meme cle et + # la seconde ne remplacerait jamais la premiere. + "destination": destination if entrant else "!SETOPS_INTERNES", "ports": _ports(fl), "chiffrement": fl.get("chiffrement"), "role": role,