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