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:
parent
3ef470be76
commit
67565aade6
5 changed files with 116 additions and 5 deletions
39
CHANGELOG.md
39
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'à
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue