#!/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-.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." PORTEES_AUTH = {"web-sso", "socle-identite", "ldap-direct", "interne-sans-auth", "sans-auth-humaine"} def preuve_authentification() -> tuple[bool, str]: """Chaque role serveur_* declare sa position d'authentification, et ne ment pas. La directive (D-38..D-41) est une regle de conduite : sans garde, elle finit par ne plus etre vraie — c'est ce qui etait arrive aux 28 lignes d'integration recopiees. La declaration seule ne suffirait pas non plus : on verifie qu'elle est COHERENTE avec le code du role. Echecs : role sans declaration, portee inconnue, `web-sso` sans acces de secours, `web-sso natif` avec compte local mais sans reglage `_connexion_locale`, `web-sso natif` dont le code ne mentionne aucun OIDC, `ldap-direct` sans reference LDAP. Les portees `interne-sans-auth` sont COMPTEES, pas refusees : ce sont des lacunes assumees et nommees. Les taire les ferait oublier ; les refuser bloquerait le harnais sur une decision deja prise. """ import yaml as _yaml roles = RACINE / "roles" fautes, lacunes, par_portee = [], [], {} for d in sorted(x for x in roles.iterdir() if x.is_dir() and x.name.startswith("serveur_")): meta = d / "meta" / "authentification.yml" if not meta.is_file(): fautes.append(f"{d.name}: aucune declaration (meta/authentification.yml)") continue decl = ((_yaml.safe_load(meta.read_text(encoding="utf-8")) or {}) .get("authentification") or {}) portee = str(decl.get("portee", "")).strip() if portee not in PORTEES_AUTH: fautes.append(f"{d.name}: portee '{portee}' inconnue") continue par_portee.setdefault(portee, []).append(d.name) if not str(decl.get("raison", "")).strip(): fautes.append(f"{d.name}: portee sans raison") # Confronter la declaration au CODE. Les premieres versions cherchaient les # mots « ldap » / « oidc » dans le role : trop faible — le mot « LDAP » dans un # commentaire de serveur_grafana suffisait a valider une declaration # `ldap-direct` mensongere. On exige donc des indices NOMMES : une variable du # namespace du role, ou une URI de schema ldap. Une phrase en prose n'en cree pas. corps = "" for f in d.rglob("*"): if f.is_file() and f.suffix in (".yml", ".j2"): corps += f.read_text(encoding="utf-8", errors="ignore").lower() defauts = {} fd = d / "defaults" / "main.yml" if fd.is_file(): defauts = _yaml.safe_load(fd.read_text(encoding="utf-8")) or {} preuve_ldap = (f"{d.name}_ldap" in corps or "ldap://" in corps or "ldaps://" in corps) preuve_oidc = any(f"{d.name}_{m}" in corps for m in ("oidc", "oauth")) if portee in ("web-sso", "socle-identite") and not str(decl.get("secours", "")).strip(): fautes.append(f"{d.name}: {portee} sans acces de secours declare") if portee == "web-sso": if decl.get("mecanisme") not in ("natif", "oauth2-proxy"): fautes.append(f"{d.name}: web-sso sans mecanisme (natif|oauth2-proxy)") # `aucun` = le service n'a pas de compte local du tout (une passerelle). # Sans cette valeur, on serait tente d'ecrire « ferme » — ce qui laisserait # croire qu'une porte a ete fermee alors qu'il n'y en a jamais eu. if decl.get("formulaire_local") not in ("ferme", "masque", "ouvert", "aucun"): fautes.append(f"{d.name}: web-sso sans posture de formulaire local") if decl.get("mecanisme") == "natif" and decl.get("formulaire_local") != "aucun": # DEFINI dans defaults, pas seulement reference ailleurs : le gabarit # peut citer la variable alors que plus rien ne lui donne de valeur. if f"{d.name}_connexion_locale" not in defauts: fautes.append(f"{d.name}: web-sso natif sans reglage " f"{d.name}_connexion_locale dans defaults/main.yml") if not preuve_oidc: fautes.append(f"{d.name}: se declare web-sso natif, mais aucune " f"variable {d.name}_oidc/_oauth dans son code") if portee == "ldap-direct" and not preuve_ldap: fautes.append(f"{d.name}: se declare ldap-direct, mais aucune variable " f"{d.name}_ldap ni URI ldap:// dans son code") if portee == "interne-sans-auth": lacunes.append(d.name) if fautes: return False, "; ".join(fautes[:6]) total = sum(len(v) for v in par_portee.values()) resume = ", ".join(f"{k} {len(v)}" for k, v in sorted(par_portee.items())) suffixe = f" ; {len(lacunes)} lacune(s) nommee(s) : {', '.join(lacunes)}" if lacunes else "" return True, f"{total} role(s) serveur declares ({resume}){suffixe}." 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).") 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"], "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"], "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"], "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"], "cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]}, {"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}, {"id": "P28", "titre": "Pools Proxmox : un par tenant, sans collision", "refs": ["AFF-110"], "cmds": [[sys.executable, "scripts/devis_proxmox_pools.py", "--verifier"]]}, {"id": "P29", "titre": "Authentification : chaque role declare sa position", "refs": ["AFF-111"], "func": preuve_authentification}, ] 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 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.")) # 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)) # 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:]))