diff --git a/Makefile b/Makefile index 75f53c4..655b92a 100644 --- a/Makefile +++ b/Makefile @@ -323,6 +323,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_sonde_tcp.py python3 scripts/tests/test_restauration.py python3 scripts/tests/test_repertoires_partages.py + python3 scripts/tests/test_frontiere_refus.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/docs/audit/preuve-2026-10-01.md b/docs/audit/preuve-2026-10-01.md index 9c87ccc..58e2837 100644 --- a/docs/audit/preuve-2026-10-01.md +++ b/docs/audit/preuve-2026-10-01.md @@ -65,7 +65,7 @@ | 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 309 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | -| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | +| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa | | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | diff --git a/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 index 7c4964a..b41b43c 100644 --- a/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 +++ b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 @@ -16,10 +16,12 @@ # # CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre : # ouvert le flux passe -> OK -# ECONNREFUSED le paquet est PASSE, personne n'ecoute sur ce port -> INFORMATION -# (pas une coupure : un flux declare que rien ne sert — p. ex. une -# interface liee en local derriere la passerelle SSO. Nomme et compte, -# mais pas en jaune : un avertissement permanent apprend a ignorer le jaune) +# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT +# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un +# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les +# `seulement_si`, aucun flux declare ne mene plus a un port sans service : +# ce cas n'est plus jamais normal. Jaune, pas rouge — un service qui +# redemarre ne doit pas reveiller quelqu'un. # EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou # machine injoignable -> CRITIQUE # delai JETE en silence (politique `drop` de nftables) -> CRITIQUE @@ -54,7 +56,7 @@ def tcp(ip, port): return "delai" except OSError as e: if e.errno == errno.ECONNREFUSED: - return "personne" + return "refuse" if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM): return "rejete" return errno.errorcode.get(e.errno, str(e.errno)) @@ -75,8 +77,8 @@ sorties = d.get("sorties") or [] with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex: resultats = list(ex.map(essai, sorties)) -coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "personne")] -personne = [(f, r) for f, r in resultats if r == "personne"] +coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")] +personne = [(f, r) for f, r in resultats if r == "refuse"] ouverts = len(resultats) - len(coupes) - len(personne) # Entrees etablies hors des regles declarees. @@ -135,8 +137,10 @@ if coupes: print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}") sys.exit(2) avert = [] -info = (f" ; {len(personne)} flux declare(s) sans service (rien n'ecoute) — {nomme(personne)}" - if personne else "") +info = "" +if personne: + avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — " + f"{nomme(personne)}") if hors: avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — " + ", ".join(sorted(hors)[:5])) diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index eafe269..7f78979 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -1371,6 +1371,42 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"), }) + # L'INTERIEUR REFUSE A VOIX HAUTE, LA FRONTIERE AUSSI (2026-10-01). + # + # Les machines refusaient deja en le disant (nftables : `reject` admin-prohibited ; + # Proxmox : politique REJECT). La frontiere, elle, jetait en silence sur TOUTES ses + # interfaces — y compris celles qui ne voient que nos propres zones. La sonde + # `connectivite` l'a montre au site : un flux mal declare entre deux zones n'echouait pas, + # il EXPIRAIT, et l'application attendait son propre delai. « Pour eviter des delais + # internes en cas de pepin » (l'exploitant). + # + # Une regle d'interface INTERNE ne voit que du trafic venu de nos zones : repondre ne + # renseigne personne d'exterieur. Le WAN, lui, reste muet (P53, silence `wan`). Place + # APRES les autorisations (1), Wireguard (100) et les silences (900) : OPNsense evalue en + # `quick`. Journalise : c'est le refus par defaut qu'on consignait deja, rendu audible. + # + # OPNsense rend un RST pour TCP : indiscernable, depuis la source, d'un port ou personne + # n'ecoute. La sonde `connectivite` le classe donc en AVERTISSEMENT, pas en information. + _internes = ({if_gestion, if_transit, if_site, if_vpn} | set(if_zones.values()) + | {str(r.get("interface")) for r in regles}) + for _itf in sorted(i for i in _internes if i and i != if_wan and not i.startswith("<")): + regles.append({ + "sens": "in", + "interface": _itf, + "protocole": "any", + "source": "any", + "destination": "any", + "ports": [], + "role": f"refus-audible:{_itf}", + "tenant": "REFUS", + "action": "reject", + "journaliser": True, + "sequence": "950", + "chiffrement": "sans objet", + "raison": ("Refus par defaut AUDIBLE sur une interface interne : un flux non " + "declare echoue tout de suite au lieu d'expirer. Le WAN reste muet."), + }) + # DEUX DECLARATIONS JUSTES NE FONT QU'UNE REGLE (2026-09-10). # # Un flux interne au site est declare DES DEUX COTES : `client_journal` sort vers diff --git a/scripts/prouver.py b/scripts/prouver.py index 93c0987..ecaa738 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -2792,7 +2792,7 @@ def preuve_insemination_sans_secret() -> tuple[bool, str]: def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: - """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE. + """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE sur l'Internet. POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de @@ -2855,15 +2855,21 @@ def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: "`POLITIQUE_VM`, sinon les deux divergeront") mesures.append("pare-feu est-ouest en REJECT, source unique") - # 3. La bordure se tait — et ce n'est PAS une preference, c'est la condition de - # prudence du reject interne. Les actions du devis OPNsense restent `pass`/`block`. + # 3. La bordure se tait SUR LE WAN — c'est la condition de prudence de tout reject + # interne. Mais ses interfaces INTERNES refusent a voix haute depuis le 2026-10-01 : + # elles ne voient que nos zones, et un flux mal declare entre deux zones expirait au + # lieu d'echouer. Le devis emet donc des `reject`, et JAMAIS sur l'interface WAN. front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8") - if '"reject"' in front or "'reject'" in front: - fautes.append("le devis de la frontiere emet un `reject` : la bordure doit rester " - "muette, sans quoi elle repond aux balayages d'Internet") + if '"action": "reject"' not in front: + fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un " + "flux mal declare entre deux zones expirerait au lieu d'echouer") + if "i != if_wan" not in front: + fautes.append("le refus audible de la frontiere n'exclut pas le WAN : la bordure " + "repondrait aux balayages d'Internet") actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front)) | set(re.findall(r'action.*?or\s+"(\w+)"', front))) - mesures.append("frontiere muette (actions : " + ", ".join(actions or ["pass"]) + ")") + mesures.append("frontiere : WAN muet, interfaces internes audibles (actions : " + + ", ".join(actions or ["pass"]) + ")") if fautes: return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(fautes) @@ -3049,7 +3055,9 @@ def preuve_silences_declares() -> tuple[bool, str]: 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"] + # UN SILENCE EST UN `block` (2026-10-01). Les `reject` sont les refus AUDIBLES des + # interfaces internes (P53) : journalises par conception, ils ne sont pas des silences. + silences = [r for r in regles if str(r.get("action") or "pass") == "block"] if not silences: return True, ("Aucune regle de blocage au devis — rien a garder. La preuve " "redeviendra parlante des qu'un silence sera declare.") diff --git a/scripts/tests/test_frontiere_refus.py b/scripts/tests/test_frontiere_refus.py new file mode 100644 index 0000000..85374de --- /dev/null +++ b/scripts/tests/test_frontiere_refus.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""La frontiere refuse A VOIX HAUTE a l'interieur, et se tait sur l'Internet. + +POURQUOI (2026-10-01). Les machines refusaient deja en le disant (nftables, Proxmox) ; la +frontiere jetait en silence sur TOUTES ses interfaces. Entre deux zones, un flux mal declare +expirait au lieu d'echouer — la sonde `connectivite` l'a mesure au site. Chaque interface +interne recoit desormais un `reject` final. Le WAN, lui, reste muet : y repondre servirait +les balayages d'Internet (P53). + +Sur le VRAI devis (il faut voir le site : sans `underlay.yml`, le test se saute) : + - aucun `reject` sur le WAN ; + - chaque interface interne qui porte des regles porte un `reject` ; + - ce `reject` vient APRES toute autre regle de son interface (OPNsense evalue en `quick`). +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] + + +def main() -> int: + r = subprocess.run([sys.executable, "scripts/devis_opnsense.py", "--json"], + capture_output=True, text=True, cwd=RACINE) + if r.returncode != 0 or not r.stdout.strip().startswith("{"): + print("SAUTE le devis de la frontiere ne se genere pas ici (site invisible)") + return 0 + regles = json.loads(r.stdout).get("regles") or [] + wan = str((json.loads(r.stdout).get("interfaces") or {}).get("wan") or "wan") + echecs = [] + refus = [x for x in regles if x.get("action") == "reject"] + if any(x.get("interface") == wan for x in refus): + echecs.append("un `reject` sur le WAN : la bordure repondrait aux balayages") + interfaces = {x.get("interface") for x in regles} - {wan} + for itf in sorted(i for i in interfaces if i): + siens = [x for x in regles if x.get("interface") == itf] + rj = [x for x in siens if x.get("action") == "reject"] + if not rj: + echecs.append(f"{itf} : aucun refus audible") + continue + haut = max([int(x.get("sequence") or 1) for x in siens if x.get("action") != "reject"] or [0]) + if int(rj[0].get("sequence") or 1) <= haut: + echecs.append(f"{itf} : le `reject` ({rj[0].get('sequence')}) ne vient pas apres " + f"toutes les autres regles ({haut})") + for e in echecs: + print("ECHEC " + e) + if echecs: + return 1 + print(f"OK {len(refus)} interface(s) interne(s) refusent a voix haute, chacune en dernier ; " + f"WAN muet.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())