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>
92 lines
4.7 KiB
Python
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())
|