Set-OPS-Public/scripts/prouver.py

535 lines
26 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Harnais de preuve Set-OPS : rejoue les preuves automatisables du registre.
`make prouver` enchaine les commandes de preuve du registre des affirmations
(`docs/audit/affirmations.md`) et produit un rapport horodate
`docs/audit/preuve-AAAA-MM-JJ.md` rejouable, presentable comme piece justificative.
Ce script est un ORCHESTRATEUR MINCE : il ne reimplemente AUCUNE validation. Il
appelle l'outillage existant (les memes `scripts/*.py`, `ansible-lint`, `make syntaxe`
que `make verifier`) et met en forme les resultats. Ce n'est pas un framework de test
parallele : la logique de validation vit dans les scripts appeles.
Sortie : 0 si aucune preuve automatisable n'echoue (les preuves SAUTEES — ex. voute
requise absente ne sont pas des echecs) ; 1 sinon.
Usage :
python3 scripts/prouver.py # execute + ecrit docs/audit/preuve-<date>.md
python3 scripts/prouver.py --verifier # preuves seules (verdict + code sortie), sans rapport
"""
from __future__ import annotations
import datetime as _dt
import json
import os
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE", "instance"))
DOSSIER_AUDIT = RACINE / "docs" / "audit"
GROUPES = "playbooks/groupes"
DEPENDANCES = "docs/dependances-groupes.yml"
def _inventaire() -> str:
"""Inventaire de l'instance : premier existant (principal > production > lab)."""
for nom in ("principal", "production", "lab"):
p = INSTANCE / "inventories" / nom / "hosts.yml"
if (RACINE / p).exists():
return str(p)
return str(INSTANCE / "inventories" / "principal" / "hosts.yml")
INV = _inventaire()
# --- Preuves natives (petites verifications d'invariants, pas de logique metier) ---
def preuve_handlers() -> tuple[bool, str]:
"""Tout `notify` d'un role pointe vers un handler du meme role (AGENTS.md)."""
import glob
import yaml
problemes: list[str] = []
roles_dir = RACINE / "roles"
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
rp = roles_dir / role
handlers: set[str] = set()
for hf in glob.glob(str(rp / "handlers" / "*.yml")):
data = yaml.safe_load(Path(hf).read_text(encoding="utf-8")) or []
def collect(tasks):
for t in tasks or []:
if not isinstance(t, dict):
continue
if "name" in t:
handlers.add(t["name"])
if "listen" in t:
listen = t["listen"]
handlers.update(listen if isinstance(listen, list) else [listen])
for k in ("block", "rescue", "always"):
if k in t:
collect(t[k])
collect(data)
notifies: set[str] = set()
for tf in glob.glob(str(rp / "tasks" / "*.yml")):
data = yaml.safe_load(Path(tf).read_text(encoding="utf-8")) or []
def scan(tasks):
for t in tasks or []:
if not isinstance(t, dict):
continue
if "notify" in t:
n = t["notify"]
notifies.update(n if isinstance(n, list) else [n])
for k in ("block", "rescue", "always"):
if k in t:
scan(t[k])
scan(data)
manquants = notifies - handlers
if manquants:
problemes.append(f"{role}: notify sans handler -> {sorted(manquants)}")
if problemes:
return False, "; ".join(problemes)
return True, "Tout notify pointe vers un handler du meme role (49 roles)."
def preuve_runbooks() -> tuple[bool, str]:
"""Les runbooks / registres cites par les surfaces publiques existent."""
attendus = [
"docs/plan-et-generation.md", "docs/catalogue-services.md",
"docs/dependances-groupes.yml", "docs/dns-interne.md",
"docs/nomenclature-vm.md", "docs/config-proxmox.md", "docs/vm-lifecycle.md",
"docs/procedure-template-debian13-proxmox.md", "docs/positionnement.md",
"docs/bindings-conception.md", "docs/flux-conception.md",
"docs/couches-deploiement.yml", "docs/registre-flux.md",
"docs/carte-set-ops.md", "docs/runbooks-exploitation.md",
"exemples/vault.exemple.yml", "exemples/instance.exemple.yml",
]
manquants = [f for f in attendus if not (RACINE / f).exists()]
if manquants:
return False, "manquants : " + ", ".join(manquants)
return True, f"{len(attendus)}/{len(attendus)} runbooks/registres cites presents."
def preuve_structurels() -> tuple[bool, str]:
"""Invariants de structure/doctrine verifiables par le contenu du depot."""
echecs: list[str] = []
def lire(rel: str) -> str:
p = RACINE / rel
return p.read_text(encoding="utf-8") if p.exists() else ""
# AFF-015 : LICENSE present
if not (RACINE / "LICENSE").exists():
echecs.append("LICENSE absent")
# AFF-022 : modele socle en forme dossier (intrants charges, pas de shadowing)
if not (RACINE / "exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml").exists():
echecs.append("socle: all/10-intrants.yml absent")
if (RACINE / "exemples/modeles/socle/inventories/production/group_vars/all.yml").exists():
echecs.append("socle: all.yml (forme fichier) masquerait all/")
# AFF-038 : pas de playbooks de couches paralleles
for couche in ("socle", "durcissement"):
if (RACINE / "playbooks" / couche).exists():
echecs.append(f"playbooks/{couche}/ ne doit pas exister")
# AFF-037 : SSH clef-only des la construction
if 'ssh_baseline_password_authentication: "no"' not in lire("roles/ssh_baseline/defaults/main.yml"):
echecs.append("ssh_baseline: PasswordAuthentication != no")
if "AuthenticationMethods publickey" not in lire("roles/ssh_baseline/templates/10-setops.conf.j2"):
echecs.append("ssh_baseline: AuthenticationMethods publickey absent")
# AFF-062 : nftables installe mais desactive par defaut
if "nftables_baseline_enabled: false" not in lire("roles/nftables_baseline/defaults/main.yml"):
echecs.append("nftables_baseline_enabled != false")
if echecs:
return False, "; ".join(echecs)
return True, "LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK."
def preuve_modele_socle() -> tuple[bool, str]:
"""Le modele public `socle` valide (registres) — garde contre une regression d'exemple."""
socle = "exemples/modeles/socle"
env = {**os.environ, "SETOPS_INSTANCE": socle}
for s in ("domaines", "serveurs", "applications", "bases_donnees"):
r = subprocess.run([sys.executable, f"scripts/{s}.py", "verifier"],
cwd=RACINE, env=env, capture_output=True, text=True)
if r.returncode != 0:
sortie = (r.stdout + r.stderr).strip().splitlines()
return False, f"{s}: {sortie[-1] if sortie else 'rc!=0'}"
return True, "Modele public socle : domaines/serveurs/applications/bases valides."
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
def preuve_propriete_des_intrants() -> tuple[bool, str]:
"""Aucune cle de l'HEBERGEUR ne subsiste dans un group_vars de tenant.
Un cluster, une fabric et une frontiere appartiennent a l'hebergeur : recopies
chez chaque tenant, ils avaient deja diverge (deux listes de stockages
contradictoires pour le meme materiel). La garde attrape la rechute un
`make config` d'un autre poste, une reprise a la main — avant qu'elle ne
reintroduise deux valeurs pour un seul objet.
Portee : l'instance liee. Un depot sans hebergeur monte (aucun `underlay.yml`)
ecrit legitimement tout au meme endroit : la preuve se saute alors.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import yaml as _yaml
underlay = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
if not underlay.exists():
return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet."
hebergeur = underlay.resolve().parent
instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
if instance.resolve() == hebergeur.resolve():
cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux
else:
fichier_heb = hebergeur / "proxmox-hebergeur.yml"
if not fichier_heb.is_file():
return False, f"Fichier du cluster absent chez l'hebergeur : {fichier_heb}"
cles = _yaml.safe_load(fichier_heb.read_text(encoding="utf-8")) or {}
fautes = []
for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")):
data = _yaml.safe_load(gv.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
continue
redites = sorted(set(data) & set(cles))
if redites:
fautes.append(f"{gv.parent.name}/{gv.name}: {', '.join(redites)}")
if fautes:
return False, "cle(s) de l'hebergeur recopiee(s) chez le tenant — " + " ; ".join(fautes[:4])
return True, (f"{len(cles)} cle(s) de cluster chez l'hebergeur, aucune recopiee dans les "
f"group_vars du tenant.")
def preuve_integrations_universelles() -> tuple[bool, str]:
"""Aucun hote n'echappe a une integration universelle, et le plan ne la redit pas.
C'est la garde de l'inversion du defaut (D-33). Elle attrape les deux rechutes
possibles : un hote sans supervision/journal/certificat, et une recopie dans le
plan qui reintroduirait deux sources et donc, tot ou tard, un ecart.
"""
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import (charger_applications, charger_serveurs,
integrations_de, integrations_universelles)
universelles = integrations_universelles(RACINE)
if not universelles:
return False, "Aucune integration universelle declaree : la politique a disparu."
instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {})
apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {})
services: dict[str, set[str]] = {}
for app in apps.values():
services.setdefault(app.get("hote"), set()).add(app.get("groupe"))
fautes, exemptes = [], 0
for nom, srv in serveurs.items():
rendus = services.get(nom, set())
obtenues = integrations_de(srv, rendus, universelles)
for role, politique in universelles.items():
exempt = politique["sauf_role"] and politique["sauf_role"] in rendus
if exempt:
exemptes += 1
elif role not in obtenues:
fautes.append(f"{nom} sans {role}")
redites = sorted(set(srv.get("integrations") or []) & set(universelles))
if redites:
fautes.append(f"{nom} redit {', '.join(redites)} (politique du role)")
if fautes:
return False, "; ".join(fautes[:6])
return True, (f"{len(serveurs)} hote(s) x {len(universelles)} integration(s) universelle(s) : "
f"aucune lacune, aucune recopie ({exemptes} exemption(s) derivee(s) du service rendu).")
2026-07-23 02:58:15 -04:00
def preuve_nomenclature_derivee() -> tuple[bool, str]:
"""Aucune nomenclature ne STOCKE d'adressage : tout se derive du seed `index`.
Interdit `supernet` au niveau racine et `sous_reseau`/`passerelle`/`vlan` par zone.
Attrape toute rechute vers l'ecriture manuelle de l'adressage (rupture du 2026-07-23).
Portee : l'instance liee + tous les modeles decouverts (SETOPS_MODELES inclus).
"""
import yaml
sys.path.insert(0, str(RACINE / "scripts"))
import modeles as _mod
cibles = []
inst_nom = RACINE / INSTANCE / "plan" / "nomenclature.yml"
if inst_nom.exists():
cibles.append(inst_nom)
cibles += [m / "plan" / "nomenclature.yml" for m in _mod.decouvrir()
if (m / "plan" / "nomenclature.yml").exists()]
interdits_zone = {"sous_reseau", "passerelle", "vlan"}
fautes = []
for p in cibles:
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
rel = p.relative_to(RACINE) if p.is_relative_to(RACINE) else p
if "supernet" in n:
fautes.append(f"{rel}: supernet stocke")
if "vmid_schema" in n:
fautes.append(f"{rel}: vmid_schema obsolete (mode compact retire)")
for z, c in (n.get("categories") or {}).items():
for k in interdits_zone & set(c or {}):
fautes.append(f"{rel}: categorie {z}.{k} stocke")
if fautes:
return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4])
return True, f"{len(cibles)} nomenclature(s) : adressage 100% derive du seed index."
def preuve_pas_lab_code_en_dur() -> tuple[bool, str]:
"""Aucun chemin `instance/inventories/lab/group_vars` code en dur (AFF-097).
On vise le vrai chemin (prefixe `instance/`) et on ignore les fichiers meta qui
*parlent* de l'audit (CHANGELOG, docs/audit) sans etre des chemins d'exploitation.
"""
r = subprocess.run(
["git", "grep", "-n", "instance/inventories/lab/group_vars", "--", "*.md", "*.yml"],
cwd=RACINE, capture_output=True, text=True,
)
exclus = ("docs/audit/", "CHANGELOG.md:")
lignes = [l for l in r.stdout.splitlines() if not l.startswith(exclus)]
if lignes:
return False, f"{len(lignes)} occurrence(s) : " + " | ".join(lignes[:3])
return True, "Aucun chemin instance/inventories/lab/group_vars code en dur."
def preuve_inventaire_ansible() -> tuple[bool, str]:
"""`ansible-inventory --list` : l'inventaire (voute dechiffree) se parse (AFF-030)."""
r = subprocess.run(["ansible-inventory", "-i", INV, "--list"],
cwd=RACINE, capture_output=True, text=True, timeout=600)
if r.returncode != 0:
sortie = (r.stdout + r.stderr).strip().splitlines()
return False, f"rc={r.returncode} : {sortie[-1] if sortie else ''}"
try:
data = json.loads(r.stdout)
except ValueError:
return True, "inventaire liste (JSON non parse)."
n_hotes = len((data.get("_meta") or {}).get("hostvars") or {})
n_groupes = len([k for k in data if k not in ("_meta", "all")])
return True, f"{n_hotes} hotes, {n_groupes} groupes (inventaire dechiffre et parse)."
# --- Registre des preuves : (id, titre, refs AFF, executeur) -----------------------
#
# executeur = liste de commandes argv (toutes doivent renvoyer 0), ou callable -> (ok, detail).
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
{"id": "P02", "titre": "Tests unitaires (inventory_host)", "refs": [],
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"]]},
{"id": "P03", "titre": "Diff-vide du plan (inventaire genere)", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"cmds": [[sys.executable, "scripts/instancier.py", "generer"],
[sys.executable, "scripts/instancier.py", "comparer"]]},
{"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"],
"cmds": [[sys.executable, "scripts/inventory_host.py", "--inventaire", INV,
"verifier-playbooks", "--dossier-playbooks", GROUPES]]},
{"id": "P05", "titre": "Dependances causales de groupes", "refs": ["AFF-009", "AFF-084"],
"cmds": [[sys.executable, "scripts/inventory_host.py", "--inventaire", INV,
"--dependances", DEPENDANCES, "verifier-dependances",
"--dossier-playbooks", GROUPES]]},
{"id": "P06", "titre": "Validateurs de registres (serveurs/apps/bases/domaines)", "refs": ["AFF-003"],
"refs": ["AFF-003"],
"cmds": [[sys.executable, "scripts/serveurs.py", "verifier"],
[sys.executable, "scripts/applications.py", "verifier"],
[sys.executable, "scripts/bases_donnees.py", "verifier"],
[sys.executable, "scripts/domaines.py", "verifier"]]},
{"id": "P07", "titre": "GUI (node --check)", "refs": ["AFF-033"],
"cmds": [[sys.executable, "scripts/verifier_gui.py"]]},
{"id": "P08", "titre": "Orchestration (couches + graphe)", "refs": ["AFF-070"],
"cmds": [[sys.executable, "scripts/orchestrer.py", "verifier"]]},
{"id": "P09", "titre": "Flux reseau (schema + matrice)", "refs": ["AFF-071"],
"cmds": [[sys.executable, "scripts/resoudre_flux.py", "verifier"]]},
{"id": "P10", "titre": "Handlers <-> notify", "refs": ["AFF-034", "AFF-035"],
"func": preuve_handlers},
{"id": "P11", "titre": "Syntaxe des playbooks (--syntax-check)", "refs": ["AFF-083"],
"cmds": [["make", "--no-print-directory", "syntaxe"]]},
{"id": "P12", "titre": "Existence des runbooks cites", "refs": ["AFF-010", "AFF-011", "AFF-012"],
"refs": ["AFF-010", "AFF-011", "AFF-012", "AFF-083"], "func": preuve_runbooks},
{"id": "P13", "titre": "Invariants structurels/doctrinaux", "refs": ["AFF-015", "AFF-022", "AFF-037", "AFF-038", "AFF-062"],
"refs": ["AFF-015", "AFF-022", "AFF-037", "AFF-038", "AFF-062"], "func": preuve_structurels},
{"id": "P14", "titre": "Pas de chemin lab/ code en dur", "refs": ["AFF-097"],
"func": preuve_pas_lab_code_en_dur},
{"id": "P15", "titre": "Modele public socle valide", "refs": ["AFF-022", "AFF-099"],
"func": preuve_modele_socle},
{"id": "P17", "titre": "Tous les modeles valident (registres + underlay)", "refs": ["AFF-105"],
Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00
"refs": ["AFF-022", "AFF-099"],
"cmds": [[sys.executable, "scripts/modeles.py", "verifier"]]},
{"id": "P18", "titre": "Gabarit de voute complet", "refs": ["AFF-026"],
"cmds": [[sys.executable, "scripts/voute.py", "verifier"]]},
{"id": "P19", "titre": "Le GUI couvre le schema du plan", "refs": ["AFF-106"],
Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00
"refs": ["AFF-002", "AFF-095"],
"cmds": [[sys.executable, "scripts/couverture_gui.py", "verifier",
"--tolerer", "nomenclature"]]},
{"id": "P20", "titre": "Adressage 100% derive du seed (aucun stocke)", "refs": ["AFF-101"],
2026-07-23 02:58:15 -04:00
"refs": ["AFF-001", "AFF-003"], "func": preuve_nomenclature_derivee},
{"id": "P21", "titre": "Federation : aucun index en collision", "refs": ["AFF-102"],
"cmds": [[sys.executable, "scripts/instances.py", "--verifier"]]},
{"id": "P22", "titre": "Plan de recette a jour (genere du wiki)", "refs": ["AFF-002"],
"cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]},
{"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-103"],
"cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]},
{"id": "P25", "titre": "Pare-feu Proxmox : est-ouest intra-tenant derive", "refs": ["AFF-107"],
"cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]},
{"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"],
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
"cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]},
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
{"id": "P26", "titre": "Integrations universelles : aucun hote laisse de cote", "refs": ["AFF-108"],
"func": preuve_integrations_universelles},
{"id": "P27", "titre": "Propriete des intrants : hebergeur et tenant separes", "refs": ["AFF-109"],
"func": preuve_propriete_des_intrants},
]
def _vault_requis_absent() -> bool:
"""Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni."""
if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
return False
gv = (RACINE / INV).parent / "group_vars"
if not gv.exists():
return False
for f in gv.rglob("*"):
if f.is_file():
try:
if f.read_text(encoding="utf-8", errors="ignore").startswith("$ANSIBLE_VAULT"):
return True
except OSError:
continue
return False
def _underlay_absent() -> bool:
"""Vrai si aucun underlay.yml n'est defini (fabric non decrite : rien a prouver)."""
import underlay
return underlay.chemin() is None
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
def _frontiere_absente() -> bool:
"""Vrai si aucune instance n'est active (depot public nu : aucune frontiere a prouver)."""
return not (RACINE / "instance").exists()
def _executer(preuve: dict) -> tuple[str, str]:
"""Renvoie (statut, detail). statut in {OK, ECHEC, SAUTE}."""
if "func" in preuve:
ok, detail = preuve["func"]()
return ("OK" if ok else "ECHEC"), detail
dernier = ""
for cmd in preuve["cmds"]:
r = subprocess.run(cmd, cwd=RACINE, capture_output=True, text=True, timeout=600)
sortie = [l for l in (r.stdout + r.stderr).strip().splitlines()
if l.strip() and not l.lstrip().startswith("[WARNING]")]
dernier = sortie[-1] if sortie else ""
if r.returncode != 0:
return "ECHEC", f"rc={r.returncode} : {dernier}"
return "OK", dernier
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
# --verifier : execute les preuves (verdict + code de sortie) sans ecrire de rapport,
# pour un appel depuis `make verifier` (evite d'ecraser la piece justificative committee).
ecrire_rapport = "--verifier" not in argv
date = _dt.date.today().isoformat()
resultats: list[tuple[dict, str, str]] = []
# Preuve conditionnelle : inventaire Ansible complet (necessite la voute).
preuve_inv = {"id": "P16", "titre": "Inventaire Ansible complet (--list)",
"refs": ["AFF-030"], "func": preuve_inventaire_ansible}
liste = list(PREUVES)
if _vault_requis_absent():
resultats.append((preuve_inv, "SAUTE",
"Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026)."))
else:
liste.append(preuve_inv)
# Preuve conditionnelle : underlay (fabric physique). Sautee si non definie.
if _underlay_absent():
p23 = next((p for p in liste if p["id"] == "P23"), None)
if p23:
liste.remove(p23)
resultats.append((p23, "SAUTE", "Aucun underlay.yml (fabric non definie) — rien a prouver."))
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
# Preuve conditionnelle : frontiere nord/sud. Sautee sans instance active.
if _frontiere_absente():
p24 = next((p for p in liste if p["id"] == "P24"), None)
if p24:
liste.remove(p24)
resultats.append((p24, "SAUTE", "Aucune instance active — aucune frontiere a prouver."))
for preuve in liste:
statut, detail = _executer(preuve)
resultats.append((preuve, statut, detail))
Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00
# Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot.
resultats.sort(key=lambda r: r[0]["id"])
n_ok = sum(1 for _, s, _ in resultats if s == "OK")
n_echec = sum(1 for _, s, _ in resultats if s == "ECHEC")
n_saute = sum(1 for _, s, _ in resultats if s == "SAUTE")
conforme = n_echec == 0
print(f"{'CONFORME' if conforme else 'NON CONFORME'} : "
f"{n_ok} OK, {n_echec} echec, {n_saute} saute.")
if not ecrire_rapport:
return 0 if conforme else 1
icone = {"OK": "", "ECHEC": "", "SAUTE": ""}
lignes = [
f"# Preuve de conformite — Set-OPS — {date}",
"",
"> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer",
"> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;",
"> aucune validation n'est reimplementee ici. Voir le mode d'emploi :",
"> [`docs/audit/README.md`](README.md), et le registre trace :",
"> [`docs/audit/affirmations.md`](affirmations.md).",
"",
f"- **Instance** : `{INSTANCE}` — inventaire `{INV}`",
f"- **Verdict** : {'✅ CONFORME' if conforme else '❌ NON CONFORME'} "
f"({n_ok} OK · {n_echec} echec · {n_saute} saute)",
"",
"## Preuves",
"",
"| # | Preuve | Affirmations | Statut | Detail |",
"|---|---|---|---|---|",
]
for preuve, statut, detail in resultats:
refs = ", ".join(preuve["refs"]) or ""
det = detail.replace("|", "\\|")[:160]
lignes.append(f"| {preuve['id']} | {preuve['titre']} | {refs} | {icone[statut]} {statut} | {det} |")
lignes += [
"",
"## Couverture des affirmations ✅ du registre",
"",
"Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.",
"Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005",
"`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite",
"d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;",
"elles restent hors du harnais recurrent (rien d'executable a rejouer).",
"",
"## Declarations d'intention (⚪ invérifiables localement — assumees)",
"",
"Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**",
"comme declarations d'intention, non comme preuves :",
"",
"- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement",
" contre une flotte vivante.",
"- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.",
"- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.",
"- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.",
"",
f"_Rapport genere le {date}._",
"",
]
DOSSIER_AUDIT.mkdir(parents=True, exist_ok=True)
rapport = DOSSIER_AUDIT / f"preuve-{date}.md"
rapport.write_text("\n".join(lignes), encoding="utf-8")
print(f"Rapport : {rapport.relative_to(RACINE)}")
return 0 if conforme else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))