frontiere : « vers Internet » n'est plus « vers n'importe ou »

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 17:00:08 -04:00
parent 3ef470be76
commit 67565aade6
5 changed files with 116 additions and 5 deletions

View file

@ -1,5 +1,44 @@
# CHANGELOG — Set-OPS # 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 ## 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'à Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à

View file

@ -36,7 +36,7 @@
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | | 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). | | 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. | | 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). | | 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). | | 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. | | 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. |

View file

@ -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 — ce que les ACL de switch interdisent par ailleurs. La bordure ne doit pas rouvrir ce que
l'isolation inter-tenant ferme. 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 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` règle) et un tenant dont `nftables_admin_ssh` est vide (règle SSH omise — l'ouvrir à `any`
exposerait le SSH à Internet). exposerait le SSH à Internet).

View file

@ -146,6 +146,11 @@ def _corps_nat(n: dict, k: str) -> dict:
def _corps_regle(r: 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 = { corps = {
"enabled": "1", "enabled": "1",
"sequence": "1", "sequence": "1",
@ -155,7 +160,8 @@ def _corps_regle(r: dict, k: str) -> dict:
"ipprotocol": "inet", "ipprotocol": "inet",
"protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(), "protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(),
"source_net": r["source"], "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']}", "description": f"{k}{r['role']}",
} }
if r["protocole"] in ("tcp", "udp") and r["ports"]: 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 # Un alias encore reference par une regle qui SURVIT ne doit pas partir : la
# suppression echouerait, et le boitier resterait a moitie reconcilie. # suppression echouerait, et le boitier resterait a moitie reconcilie.
survivants = {r["source"] for k, r in voulues.items()} 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 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 []} nat_voulus = {cle_nat(n): n for n in devis.get("nat") or []}

View file

@ -285,6 +285,26 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"description": f"Sous-reseaux attribues du tenant {nom} (index {n['index']})", "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 # 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 : # 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 # 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)) portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["wan"]: if admin_par_if[etiquette]["wan"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_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: else:
portees = [("any", if_wan if entrant else if_transit)] portees = [("any", if_transit)]
for source, interface in portees: for source, interface in portees:
regles.append({ regles.append({
"sens": "in" if entrant else "out", "sens": "in" if entrant else "out",
"interface": interface, "interface": interface,
"protocole": fl.get("protocole", "tcp"), "protocole": fl.get("protocole", "tcp"),
"source": source if entrant else destination, "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), "ports": _ports(fl),
"chiffrement": fl.get("chiffrement"), "chiffrement": fl.get("chiffrement"),
"role": role, "role": role,