Une règle qu'aucune garde ne vérifie finit par ne plus être vraie — c'est ce qui était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle serveur_* porte un meta/authentification.yml, confronté à son code par P29. web-sso 5, socle-identite 2 (keycloak/openldap : ils SONT la chaîne d'identité), ldap-direct 2, interne-sans-auth 2, sans-auth-humaine 12. La preuve refuse l'oubli ET le mensonge. Éprouvée par sabotage sur sept cas : déclaration supprimée, portée inventée, secours retiré, posture de formulaire retirée, raison retirée, ldap-direct mensonger, réglage retiré des defaults. Les deux derniers passaient dans la première version : - le mensonge passait à cause d'un commentaire. Je cherchais le mot « ldap » dans le rôle, et serveur_grafana/defaults/main.yml contient « désactiver quelqu'un dans LDAP » : de la prose validait une déclaration fausse. La preuve exige maintenant un indice nommé — variable <rôle>_oidc / <rôle>_ldap, ou URI ldap:// - le réglage retiré passait parce que le gabarit citait encore la variable alors que plus rien ne lui donnait de valeur. La preuve lit defaults/main.yml en YAML et exige que la clé y soit définie, pas mentionnée. Elle a aussi forcé une valeur : oauth2-proxy était déclaré « formulaire local fermé » alors qu'il n'a aucun compte local. D'où formulaire_local: aucun, qui distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ». Correction d'une note de la veille : Prometheus et Loki ne sont PAS exposés publiquement (aucun expose au plan). Seuls six groupes le sont. Le risque est intra-tenant, pas frontalier. Les deux lacunes sont comptées à chaque exécution, pas masquées. AFF-111, D-42. 29 preuves OK, ansible-lint (production) sur 375 fichiers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
626 lines
31 KiB
Python
626 lines
31 KiB
Python
#!/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."
|
|
|
|
|
|
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 `<role>_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:]))
|