frontiere : le devis sait desormais refuser SANS consigner
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 <noreply@anthropic.com>
This commit is contained in:
parent
14fa129731
commit
3b65384b0f
5 changed files with 143 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue