Set-OPS-Public/scripts/tests/test_frontiere_pki.py
Daniel Allaire f3bb9caee3 chiffrement : metriques et journaux du site en TLS, et la frontiere certifiee par sa propre autorite
Metriques et journaux (CHANGELOG 142). Le site pose les quatre interrupteurs TLS
que chaque locataire pose dans ses group_vars : node_exporter et Loki servaient
en HTTP sur les neuf VM, le certificat etait la, inutilise. test_parite_tls_site.py
compare desormais le site aux locataires. Les hyperviseurs poussent leurs
journaux en HTTPS verifie, avec la racine du site comme simple fichier.
L'exportateur PostgreSQL sert en HTTPS partout, sur l'interrupteur de PostgreSQL
lui-meme, que Prometheus lit pour choisir son schema.

La frontiere (CHANGELOG 143). Une autorite subordonnee signee par la racine du
site, bornee (pathlen 0, noms sous le domaine et adresses du site, contrainte
critique), cle nee sur la boite (scripts/frontiere_pki.py). Syslog vers Loki en
TLS mutuel (seule la frontiere peut ecrire), exportateur en HTTPS, interface web
sur le certificat de services ; les clients d'API verifient la frontiere contre
la racine du site. La lecture du magasin passe par config.xml : l'API de
recherche renvoie les cles privees.

Deploye et mesure au site, chez les deux locataires et sur la frontiere ; 95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:25:28 -04:00

92 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""L'autorite de la frontiere est bornee, et ses cles ne quittent pas la boite.
POURQUOI (2026-10-12). La frontiere porte desormais une autorite subordonnee signee par la
racine du site (scripts/frontiere_pki.py). Ce test garde ce qui la rend sure :
1. le gabarit de signature : aucune autorite sous elle, signature seulement, contrainte
de noms CRITIQUE sur le domaine et le supernet du site ;
2. la lecture du magasin ne passe pas par `/api/trust/*/search`, qui renvoie les cles
privees (mesure du 2026-10-12 : la liste rapatriait la cle de l'autorite sur le poste) ;
3. les noms du certificat sont ELIGIBLES : FQDN et adresses du supernet seulement ;
4. l'interface web (R3) reste un geste a part, derriere sa propre confirmation ;
5. le registre dit `tls` pour le syslog (1514) et l'exportateur de la frontiere.
"""
from __future__ import annotations
import ipaddress
import json
import re
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import frontiere_pki as fp # noqa: E402
def main() -> int:
echecs: list[str] = []
# 1. Le gabarit, rendu comme le script le rend.
rendu = fp.GABARIT_SOUS_AC.format(domaine=json.dumps("exemple.internal"), supernet=json.dumps("10.99.0.0/16"))
gabarit = json.loads(rendu.replace("{{ toJson .Subject }}", "{}"))
if gabarit.get("basicConstraints") != {"isCA": True, "maxPathLen": 0}:
echecs.append(f"basicConstraints = {gabarit.get('basicConstraints')}, attendu isCA + maxPathLen 0")
if sorted(gabarit.get("keyUsage") or []) != ["certSign", "crlSign"]:
echecs.append(f"keyUsage = {gabarit.get('keyUsage')}, attendu certSign + crlSign seulement")
nc = gabarit.get("nameConstraints") or {}
if not nc.get("critical") or nc.get("permittedDNSDomains") != ["exemple.internal"] \
or nc.get("permittedIPRanges") != ["10.99.0.0/16"]:
echecs.append(f"nameConstraints = {nc}, attendu critique, domaine et supernet du site")
# 2. Aucune lecture du magasin par la recherche de l'API (elle rend les cles).
source = (RACINE / "scripts/frontiere_pki.py").read_text(encoding="utf-8")
code = "\n".join(l for l in source.splitlines() if not l.lstrip().startswith("#"))
if re.search(r"/api/trust/[^\"']*/search", code.replace("`/api/trust/{ca,cert}/search`", "")):
echecs.append("frontiere_pki.py lit le magasin par /api/trust/*/search, qui renvoie les cles privees")
if "/conf/config.xml" not in code or "'crt'" not in code:
echecs.append("la lecture du magasin ne se fait plus sur la frontiere (config.xml)")
# 3. Les noms du certificat, tels que l'inventaire du site les derive.
try:
import site_inventaire as si
hv = si.inventaire()["_meta"]["hostvars"]
f = (hv.get(fp.HOTE_PKI) or {}).get("setops_frontiere") or {}
except Exception: # noqa: BLE001 — aucun site monte
f = {}
if f:
reseau = ipaddress.ip_network(f["supernet"])
hors = [a for a in f["adresses"] if ipaddress.ip_address(a) not in reseau]
if hors:
echecs.append(f"adresses hors du supernet {reseau} dans le certificat : {hors}")
if not f.get("admin") or f["admin"] not in f["adresses"]:
echecs.append("l'adresse d'administration de la frontiere n'est pas dans ses adresses du site")
# 4. L'interface web : un geste a part, confirme a part.
if "def basculer_interface" not in code or 'action == "interface"' not in code:
echecs.append("la bascule de l'interface web n'est plus une commande separee")
if re.search(r"if action == .appliquer.[\s\S]*basculer_interface\(", code.split("def main")[1].split('if action == "interface"')[0]):
echecs.append("`appliquer` bascule l'interface web (R3) sans sa propre confirmation")
# 5. Le registre.
def chiffrement(role: str, port: int, pair: str) -> str:
flux = yaml.safe_load((RACINE / f"roles/{role}/meta/flux.yml").read_text(encoding="utf-8"))["flux"]
return next((x.get("chiffrement") for x in flux
if x.get("port") == port and pair in (x.get("pair") if isinstance(x.get("pair"), list) else [x.get("pair")])), "")
if chiffrement("serveur_loki", 1514, "fabric") != "tls-requis":
echecs.append("le registre ne dit pas le syslog de la frontiere (1514) en tls-requis")
if chiffrement("serveur_prometheus", 9100, "frontiere") != "tls":
echecs.append("le registre ne dit pas l'exportateur de la frontiere en tls")
for e in echecs:
print(f"ECHEC : {e}")
if not echecs:
print("OK : autorite de la frontiere bornee, magasin lu sans ses cles, noms eligibles, interface a part.")
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())