Set-OPS-Public/scripts/tests/test_sasl_local.py
Daniel Allaire 63f5fda091 courriel : les mots de passe de soumission ne quittent plus la machine de Postfix
Postfix (edge-mta) faisait verifier chaque mot de passe de soumission par le
Dovecot du mailstore, sur :12345. Le client SASL Dovecot de Postfix ne chiffre
pas : chaque mot de passe traversait le reseau en clair, et le registre disait
ce flux tls.

Le flux est supprime, pas chiffre. serveur_postfix pose a cote de lui un
Dovecot d'authentification seule (serveur_dovecot/tasks/auth_locale.yml) :
coeur + LDAP, aucun protocole, aucun port, joint par private/auth, qui verifie
sur l'annuaire en LDAPS. Le 12345 disparait des deux roles et du pare-feu ;
serveur_dovecot_sasl_reseau est retire, et une garde arrete l'instance qui le
pose encore.

Deploye chez les deux locataires ; prouve par un envoi reel chez chacun
(sasl_username au journal, remise LMTP, relu par IMAP). test_sasl_local.py
garde le changement ; 95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:26:18 -04:00

106 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Les mots de passe de soumission ne quittent plus la machine de Postfix.
POURQUOI (2026-10-12). Directive : tout transit chiffre, a l'interne comme a l'externe.
La mesure des ecoutes l'a montre sur les deux locataires : Postfix (edge-mta) faisait
verifier chaque mot de passe de soumission par le Dovecot du mailstore, sur :12345, et le
client SASL Dovecot de Postfix ne sait pas chiffrer. Le registre declarait ce flux `tls`.
Le flux n'est pas chiffre, il est SUPPRIME : un Dovecot d'authentification seule vit a
cote de Postfix, joint par socket local, et verifie sur l'annuaire en LDAPS.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import jinja2
import yaml
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
from resoudre_flux import charger_flux # noqa: E402
DOVECOT = RACINE / "roles/serveur_dovecot"
POSTFIX = RACINE / "roles/serveur_postfix"
def main() -> int:
echecs: list[str] = []
# 1. Plus aucun role ne declare le port SASL reseau, d'un cote ou de l'autre.
for role, flux in charger_flux().items():
for f in flux:
if str(f.get("port")) == "12345":
echecs.append(f"{role} declare encore le flux {f.get('sens')} 12345")
# 2. Postfix lit le socket local, jamais un `inet:`, pour ses deux ports de soumission.
taches = (POSTFIX / "tasks/main.yml").read_text(encoding="utf-8")
chemins = re.findall(r"smtpd_sasl_path=(\S+)", taches)
if len(chemins) != 2 or set(chemins) != {"private/auth"}:
echecs.append(f"smtpd_sasl_path = {chemins}, attendu private/auth sur 587 et 465")
# 3. Le Dovecot local est pose APRES le demarrage de Postfix (qui cree `private/`),
# et seulement la ou la soumission existe.
liste = yaml.safe_load(taches)
noms = [t.get("name", "") for t in liste]
i_auth = next((i for i, t in enumerate(liste)
if (t.get("ansible.builtin.include_role") or {}).get("tasks_from") == "auth_locale.yml"), None)
i_demarrer = next((i for i, n in enumerate(noms) if n == "Activer et démarrer Postfix"), None)
if i_auth is None:
echecs.append("serveur_postfix n'inclut pas serveur_dovecot/auth_locale.yml")
else:
if i_demarrer is None or i_auth < i_demarrer:
echecs.append("le Dovecot local est pose avant le demarrage de Postfix")
if "serveur_postfix_submission_actif" not in str(liste[i_auth].get("when")):
echecs.append("le Dovecot local n'est pas conditionne a la soumission")
# 4. Authentification SEULE : aucun paquet de protocole, aucune ecoute reseau.
defauts = yaml.safe_load((DOVECOT / "defaults/main.yml").read_text(encoding="utf-8"))
paquets = defauts.get("serveur_dovecot_auth_locale_paquets") or []
if set(paquets) != {"dovecot-core", "dovecot-ldap"}:
echecs.append(f"auth locale installe {paquets}, attendu dovecot-core + dovecot-ldap")
if "serveur_dovecot_sasl_reseau" in defauts or "serveur_dovecot_sasl_port" in defauts:
echecs.append("serveur_dovecot porte encore ses variables SASL reseau")
fichier = DOVECOT / "templates/auth-locale.conf.j2"
if not fichier.exists():
echecs.append("serveur_dovecot n'a pas de gabarit d'authentification seule (auth-locale.conf.j2)")
else:
rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string(
fichier.read_text(encoding="utf-8")).render(
serveur_dovecot_ldap_uris="ldaps://idm-01.exemple.internal:636",
serveur_dovecot_ldap_base="dc=exemple,dc=ca",
serveur_dovecot_ldap_bind_dn="cn=dovecot,ou=services,dc=exemple,dc=ca",
serveur_dovecot_ldap_bind_password="banc",
serveur_dovecot_ldap_filter=defauts["serveur_dovecot_ldap_filter"],
serveur_dovecot_postfix_socket_dir=defauts["serveur_dovecot_postfix_socket_dir"],
)
for interdit in ("inet_listener", "protocols", "userdb"):
if interdit in rendu:
echecs.append(f"auth-locale.conf rend `{interdit}` : ce n'est plus de l'authentification seule")
for attendu in ("passdb ldap {", "ldap_bind = yes",
"unix_listener /var/spool/postfix/private/auth {"):
if attendu not in rendu:
echecs.append(f"auth-locale.conf ne rend pas `{attendu}`")
# 5. Et la machine des boites n'ouvre plus le port.
if "inet_listener sasl" in (DOVECOT / "templates/99-setops.conf.j2").read_text(encoding="utf-8"):
echecs.append("99-setops.conf.j2 ouvre encore l'ecoute SASL reseau")
# 6. La verification passe par l'annuaire chiffre : le resolveur partage vise LDAPS.
annuaire = yaml.safe_load((RACINE / "roles/resoudre_annuaire/defaults/main.yml").read_text(encoding="utf-8"))
if annuaire.get("resoudre_annuaire_encryption") != "ldaps" or int(annuaire.get("resoudre_annuaire_port", 0)) != 636:
echecs.append("resoudre_annuaire ne vise plus LDAPS:636")
for e in echecs:
print(f"ECHEC : {e}")
if not echecs:
print("OK : la soumission verifie ses mots de passe sur place, l'annuaire en LDAPS ; aucun port SASL reseau.")
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())