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:
Daniel Allaire 2026-08-27 18:10:39 -04:00
parent 14fa129731
commit 3b65384b0f
5 changed files with 143 additions and 9 deletions

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` - **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 ## 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. | | 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). | | 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). | | 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). | | 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). | | 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. | | 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). | | 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. | | 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). | | 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 ## Couverture des affirmations ✅ du registre

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 61 | `roles/*/` | | rôles | 61 | `roles/*/` |
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` | | 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` | | unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.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 | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -111,7 +111,14 @@ def cle_regle(r: dict) -> str:
une regle a retirer, ce qui est exactement la verite. une regle a retirer, ce qui est exactement la verite.
""" """
ports = ",".join(map(str, r["ports"] or ["*"])) 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}") f"{r['source']}->{r['destination']}:{ports}")
@ -174,14 +181,29 @@ def _corps_regle(r: dict, k: str) -> dict:
# le champ `destination_not` d'OPNsense. # le champ `destination_not` d'OPNsense.
dst = str(r["destination"]) dst = str(r["destination"])
nie = dst.startswith("!") 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 = { corps = {
"enabled": "1", "enabled": "1",
"sequence": "1", "sequence": str(r.get("sequence") or "1"),
"action": "pass", "action": str(r.get("action") or "pass"),
"log": "1" if r.get("journaliser") else "0",
"interface": r["interface"], "interface": r["interface"],
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61) "direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
"ipprotocol": "inet", "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"], "source_net": r["source"],
"destination_net": dst[1:] if nie else dst, "destination_net": dst[1:] if nie else dst,
"destination_not": "1" if nie else "0", "destination_not": "1" if nie else "0",

View file

@ -832,6 +832,50 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"tenant": "SITE", "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 : # 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. # elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit) _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 # 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 # 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. # 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( 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"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']}") out.append(f"# {rg['raison']}")
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out") sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")

View file

@ -1396,6 +1396,66 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") 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]: def preuve_registre_flux_a_jour() -> tuple[bool, str]:
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI. """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}, "refs": [], "func": preuve_carte_dit_vrai},
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour", {"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
"refs": [], "func": preuve_registre_flux_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": [], {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site}, "func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],