#!/usr/bin/env python3 """Devis PostgreSQL : le chiffrement est-il réellement imposé, et à qui ? Lit les relevés de `playbooks/maintenance/devis-postgresql.yml`. Sort en code 1 s'il y a un écart. Deux défauts déjà vécus dans ce dépôt, et que ce devis rend visibles : - un réseau **écrit** dans `pg_hba.conf` au lieu d'être dérivé (`10.11.0.0/16` sous un commentaire « AJUSTER » que personne n'a suivi) : la base acceptait un supernet qui n'était pas le sien ; - une ligne `host` (en clair) là où il faut `hostssl` : le verrou saute sans bruit, puisque les clients en `verify-full` continuent de fonctionner. """ from __future__ import annotations import glob import ipaddress import json import sys from pathlib import Path RACINE = Path(__file__).resolve().parent.parent MOTIF = str(RACINE / "instance" / "devis-postgresql.json.*") LOCAL = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128")) def analyser(releves: list[dict]) -> list[str]: ecarts = [] for r in sorted(releves, key=lambda x: x["hote"]): h = r["hote"] tls = dict(l.split("|", 1) for l in r["tls"] if "|" in l) if tls.get("ssl") != "on": ecarts.append(f"{h} : `ssl` n'est pas actif — tout le trafic est en clair") cert = tls.get("ssl_cert_file", "") if "snakeoil" in cert or not cert: ecarts.append(f"{h} : certificat serveur « {cert or 'aucun'} » — ce n'est pas " f"celui de l'AC interne, la confiance est-ouest ne tient pas") if not tls.get("ssl_ca_file"): ecarts.append(f"{h} : aucun `ssl_ca_file` — le serveur ne peut pas verifier " f"un certificat client") supernet = ipaddress.ip_network(r["supernet"]) if r.get("supernet") else None for ligne in r["hba"]: champs = [c.strip() for c in ligne.split("|")] if len(champs) < 4: continue type_, adresse, masque = champs[0], champs[1], champs[2] if type_ == "local" or not adresse: continue # `address` peut valoir « all », « samehost », un nom d'hote, une adresse # nue (`::1`) ou un CIDR ; `netmask` n'est renseigne que parfois. On essaie # les deux recompositions avant de conclure que ce n'est pas un reseau. res = None for candidat in ((f"{adresse}/{masque}" if masque else None), adresse): if not candidat: continue try: res = ipaddress.ip_network(candidat, strict=False) break except ValueError: res = None if res is None: if type_ != "hostssl": ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour « {adresse} » " f"— le chiffrement n'est pas IMPOSE, seulement possible") continue if any(res.subnet_of(l) for l in LOCAL if res.version == l.version): continue if type_ != "hostssl": ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour {res} — le " f"chiffrement n'est pas IMPOSE, seulement possible") if supernet and res.version == supernet.version and not res.subnet_of(supernet): ecarts.append(f"{h} : {res} est autorise mais n'appartient pas au " f"supernet derive {supernet} — un reseau ECRIT au lieu " f"d'etre derive") return ecarts def main(argv: list[str] | None = None) -> int: fichiers = sorted(glob.glob(argv[0] if argv else MOTIF)) if not fichiers: print("Relevés absents. Lancer d'abord : make postgresql-plan", file=sys.stderr) return 2 releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers] ecarts = analyser(releves) print(f"Devis PostgreSQL — {len(releves)} instance(s)\n") for e in ecarts: print(f" [écart] {e}") if not ecarts: print("CONFORME : chiffrement imposé, et aucun réseau hors du supernet dérivé.") return 0 print(f"\nÉCART : {len(ecarts)} divergence(s).") return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))