devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »

devis_expositions pose la question positive (une exposition declaree
repond-elle). Celle-ci manquait, et ce n'est pas la meme : un pare-feu peut
servir tout ce qu'on lui demande ET laisser passer tout le reste.

Rien n'y est saisi. Les cibles sont les ports REELLEMENT en ecoute (sonder un
port ferme ne prouverait rien du pare-feu) ; la politique attendue est lue
dans devis_opnsense.py, la source qui configure la frontiere.

scripts/sonde_tcp.py refuse de conclure : un CONTROLE (adresse ou personne
n'ecoute) avant tout verdict — s'il repond, le releve est declare NUL. Et
etablir n'est pas livrer : banniere, sinon requete HTTP, sinon poignee TLS ;
sans reponse le verdict est AMBIGU, jamais « ouvert ».

Deux pieges rencontres en le construisant, corriges et commentes sur place :
la sonde « tenant » s'executait sur le poste (dans un play connection: local,
delegate_to herite de cette connexion) ; et 2 s de lecture faisaient ressortir
un Postfix sain en AMBIGU (postscreen retarde sa banniere expres) — porte a
8 s, compense par du parallelisme.

Premier verdict, frontiere en l'etat : 38 ecarts, dont collab-01:9980 qui
livre vraiment un HTTP 200 depuis le poste. Releve sortant declare NUL.

Verifie : ansible-lint Passed, prouver.py 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 21:03:28 -04:00
parent 67565aade6
commit a546b03c3a
7 changed files with 506 additions and 2 deletions

View file

@ -1,5 +1,51 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? »
`devis_expositions.py` pose la question positive — chaque exposition déclarée répond-elle.
Il manquait la négative, et ce n'est pas la même : un pare-feu peut très bien servir tout
ce qu'on lui demande **et** laisser passer tout le reste. `make frontiere-mesurer` la pose.
**Les cibles ne sont pas saisies** : ce sont les ports réellement en écoute dans la flotte,
relevés par le playbook. Sonder un port fermé ne prouverait rien du pare-feu — le refus
viendrait de la machine. **La politique attendue non plus** : elle est lue dans
`devis_opnsense.py`, la source même qui configure la frontière.
**`scripts/sonde_tcp.py` refuse de conclure.** Deux principes, tirés des trois faux
diagnostics de la semaine :
1. Un **contrôle** avant tout verdict — une adresse où personne n'écoute. Si elle répond,
le relevé est déclaré **NUL** et aucun verdict n'est rendu. Mieux vaut pas de mesure
qu'une mesure fausse.
2. **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête
HTTP minimale, sinon poignée TLS. Si rien ne revient, le verdict est **AMBIGU** — pas
« ouvert ». LDAP et PostgreSQL attendent un message bien formé qu'on ne fabrique pas
ici, et un synproxy se comporte exactement pareil.
Le verdict distingue deux natures d'écart, qui n'appellent pas le même geste : *refusé
attendu mais connexion établie* = la bordure a relayé, c'est le trou ; *livré attendu mais
rien livré* = la bordure autorise et l'**hôte** refuse, rien ne fuit mais les deux couches
ne déclarent pas la même politique.
**Deux pièges rencontrés en le construisant, tous deux corrigés et commentés sur place :**
- La sonde « tenant » s'exécutait en réalité **sur le poste** : dans un play
`connection: local`, un `delegate_to` hérite de cette connexion. Elle rendait donc la
frontière joignable depuis un tenant — ce qu'elle est, depuis le VLAN d'administration.
Vérifié à la main avant de la croire : `TimeoutError` depuis `backup-01` comme depuis
`infra-dns-01`. Même famille que le `connect()` — vérifier d'où l'instrument mesure.
- Le délai de lecture de 2 s faisait ressortir le `25` d'un Postfix parfaitement sain en
AMBIGU : `postscreen` retarde sa bannière exprès. Porté à 8 s, compensé par du
parallélisme — raccourcir aurait fabriqué de faux écarts.
**Premier verdict, la frontière étant encore en l'état : 38 écarts.** Trente-sept sont
« la bordure a relayé » (la règle héritée étiquetée `PAS SUPPOSÉ -> ACTION REQUISE` laisse
passer le VLAN d'administration vers tout port en écoute), dont un livre vraiment —
`collab-01:9980`, Collabora, répond `HTTP/1.1 200 OK` depuis le poste. Le trente-huitième
est de l'autre nature : la frontière autorise `edge-mta-01:25` depuis le VLAN
d'administration, mais l'hôte le refuse. Le relevé sortant est déclaré NUL, son contrôle
ayant répondu.
## 2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés ## 2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés
Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre

View file

@ -418,7 +418,7 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S
-a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \ -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \
| tail -1 | tr -d ' \r' | tail -1 | tr -d ' \r'
.PHONY: frontiere-plan frontiere-appliquer .PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer
courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture) courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture)
@rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.*
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null
@ -433,6 +433,10 @@ expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (ed
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null
@python3 scripts/devis_expositions.py @python3 scripts/devis_expositions.py
frontiere-mesurer: ansible-runtime ## La frontiere refuse-t-elle ce qui n'est pas declare ? (sonde reelle, aucune ecriture)
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-frontiere.yml >/dev/null
@python3 scripts/devis_frontiere.py
certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture) certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture)
@rm -f $(SETOPS_INSTANCE)/devis-certificats.json.* @rm -f $(SETOPS_INSTANCE)/devis-certificats.json.*
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null

View file

@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 39 scripts expliques et atteignables, 89 cibles make documentees, 54 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 41 scripts expliques et atteignables, 90 cibles make documentees, 54 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |

View file

@ -9,6 +9,7 @@ make certificats-plan # certificats sur disque contre certificats réellement
make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste
make postgresql-plan # chiffrement imposé, et à quels réseaux make postgresql-plan # chiffrement imposé, et à quels réseaux
make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP
make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il refusé ?
``` ```
## Le trou qu'il comble ## Le trou qu'il comble
@ -65,6 +66,31 @@ elle-même, le certificat était **expiré depuis plus de huit heures** et le re
échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`. échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`.
Rien ne le signalait. Rien ne le signalait.
## Le devis d'étanchéité de la frontière
Le devis des expositions pose la question **positive** — chaque exposition déclarée
répond-elle. Celui-ci pose la **négative** : la frontière laisse-t-elle passer ce que la
politique n'autorise pas ? Les deux sont nécessaires. Un pare-feu peut très bien servir
tout ce qu'on lui demande *et* laisser passer tout le reste.
Rien n'y est saisi. Les **cibles** sont les ports réellement en écoute dans la flotte —
sonder un port fermé ne prouverait rien du pare-feu, le refus viendrait de la machine. La
**politique attendue** est lue dans `devis_opnsense.py`, la source même qui configure la
frontière.
`scripts/sonde_tcp.py` porte deux principes, tirés de trois faux diagnostics :
- **Un contrôle avant tout verdict** — une adresse où personne n'écoute. Si elle répond,
le relevé est déclaré **NUL**. Mieux vaut pas de mesure qu'une mesure fausse.
- **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête
HTTP minimale, sinon poignée TLS. Sans réponse, le verdict est **AMBIGU**, jamais
« ouvert » — LDAP et PostgreSQL attendent un message qu'on ne fabrique pas ici, et un
synproxy se comporte exactement pareil.
Le verdict sépare deux natures d'écart : *refusé attendu, connexion établie* = la bordure a
relayé, c'est le trou ; *livré attendu, rien livré* = la bordure autorise et l'hôte refuse
— rien ne fuit, mais les deux couches ne déclarent pas la même politique.
## Le devis des expositions ## Le devis des expositions
Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services

View file

@ -0,0 +1,118 @@
---
# Devis d'etancheite de la frontiere — RELEVE seul. Ne modifie rien (D-23).
#
# Question posee : la frontiere laisse-t-elle passer ce que la politique n'autorise PAS ?
# `devis-expositions.yml` pose la question inverse — chaque exposition declaree repond-elle.
# Les deux sont necessaires : un pare-feu peut servir tout ce qu'on lui demande *et*
# laisser passer tout le reste.
#
# LES CIBLES SONT LES PORTS REELLEMENT EN ECOUTE, releves ici. Sonder un port ferme ne
# prouverait rien du pare-feu : le refus viendrait de la machine, pas de la bordure.
#
# LE CONTROLE D'ABORD. La sonde interroge une adresse ou personne n'ecoute avant tout
# verdict ; si elle repond, le releve est declare NUL. C'est la lecon du 2026-08-09 :
# `connect()` a menti deux jours durant a travers un `synproxy` herite.
#
# make frontiere-mesurer
- name: Relevé des ports réellement en écoute
hosts: hotes_actifs
become: true
gather_facts: false
tasks:
- name: Recenser les ports TCP en écoute
ansible.builtin.shell:
executable: /bin/bash
# `|| true` en fin de chaîne : avec `pipefail`, un `grep -v` qui ne garde rien
# tuerait la tâche. Un hôte sans écoute publique est un relevé vide, pas une panne.
cmd: >-
set -o pipefail;
ss -ltnH | awk '{print $4}'
| grep -vE '^127\.|^\[::1\]' | sed 's/.*://' | sort -un | tr '\n' ' ' || true
register: devis_frontiere_ss
changed_when: false
- name: Retenir l'écoute de cet hôte
ansible.builtin.set_fact:
devis_frontiere_ecoute:
ip: "{{ ansible_host }}"
ports: "{{ devis_frontiere_ss.stdout.split() | map('int') | list }}"
- name: Sondage depuis les deux points de vue
hosts: localhost
connection: local
become: false
gather_facts: false
tasks:
- name: Rassembler l'écoute de toute la flotte
ansible.builtin.set_fact:
devis_frontiere_ports: >-
{{ dict(groups['hotes_actifs']
| map('extract', hostvars, 'devis_frontiere_ecoute')
| list | zip(groups['hotes_actifs']) | map('reverse') | list) }}
# Forme `argv` et non `cmd` : le chemin du dépôt peut contenir une espace, et `cmd`
# découpe dessus. Mesuré ici même — « /home/.../Espace Chezlepro/... » cassait.
- name: Dériver les cibles à sonder depuis la politique
ansible.builtin.command:
argv:
- python3
- "{{ playbook_dir }}/../../scripts/devis_frontiere.py"
- --cibles
stdin: "{{ devis_frontiere_ports | to_json }}"
register: devis_frontiere_cibles
changed_when: false
- name: Sonder depuis le VLAN d'administration (le poste de l'exploitant)
ansible.builtin.command:
argv:
- python3
- "{{ playbook_dir }}/../../scripts/sonde_tcp.py"
stdin: >-
{{ {'controle': (devis_frontiere_cibles.stdout | from_json).controle,
'cibles': (devis_frontiere_cibles.stdout | from_json).admin} | to_json }}
register: devis_frontiere_admin
changed_when: false
# Play distinct, et ce n'est pas cosmétique. Dans un play `connection: local`, un
# `delegate_to` hérite de cette connexion : la sonde « tenant » s'exécutait en réalité sur
# le poste. Elle rendait donc la frontière JOIGNABLE — depuis le VLAN d'administration,
# elle l'est. Mesuré et corrigé le 2026-08-09 ; c'est la même famille de piège que le
# `connect()` : vérifier d'où l'instrument mesure avant de croire ce qu'il dit.
- name: Sondage depuis un hôte du tenant (le sens sortant)
# Un seul hôte suffit : le devis mesure la bordure, pas les hôtes. Les règles sortantes
# portent sur l'alias du tenant, donc sur tous ses hôtes indistinctement.
# Motif `groupe[0]` et non un template : un `hosts:` templaté échoue quand l'inventaire
# est vide — c'est ce qui cassait `ansible-lint`, qui s'exécute sans inventaire.
hosts: hotes_actifs[0]
become: false
gather_facts: false
tasks:
- name: Sonder les destinations internes interdites
ansible.builtin.script:
cmd: >-
"{{ playbook_dir }}/../../scripts/sonde_tcp.py"
'{{ {"controle": (hostvars["localhost"].devis_frontiere_cibles.stdout
| from_json).controle,
"cibles": (hostvars["localhost"].devis_frontiere_cibles.stdout
| from_json).tenant} | to_json }}'
args:
executable: python3
register: devis_frontiere_tenant
changed_when: false
- name: Déposer le relevé sur le contrôleur
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../../instance/devis-frontiere.json"
mode: "0600"
content: >-
{{ {
'admin': hostvars['localhost'].devis_frontiere_admin.stdout | from_json,
'tenant': devis_frontiere_tenant.stdout | from_json,
'sonde_depuis': inventory_hostname,
} | to_nice_json }}
delegate_to: localhost
become: false

184
scripts/devis_frontiere.py Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Devis d'etancheite de la frontiere : ce qui n'est pas declare est-il refuse ?
`devis_expositions.py` pose la question POSITIVE chaque exposition declaree repond-elle.
Celui-ci pose la NEGATIVE, et c'est une autre question : la frontiere laisse-t-elle passer
ce que la politique n'autorise pas. Un pare-feu peut tres bien servir tout ce qu'on lui
demande *et* laisser passer tout le reste.
La politique attendue n'est pas saisie : elle est LUE dans `devis_opnsense.py`, la meme
source qui produit les regles posees sur le boitier. Les cibles ne sont pas saisies non
plus : ce sont les ports REELLEMENT EN ECOUTE dans la flotte, releves par le playbook.
Sonder un port ferme ne prouverait rien du pare-feu.
Deux modes :
--cibles lit le releve des ports en ecoute (stdin), emet les listes a sonder
(defaut) lit le releve complet et rend le verdict, code 1 s'il y a un ecart
Aucun acces reseau ici c'est le playbook qui sonde (D-23).
make frontiere-mesurer
"""
from __future__ import annotations
import argparse
import ipaddress
import json
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
# Adresse de controle : documentee comme non routable et attribuee a personne. Si elle
# repond, c'est l'instrument qui ment — voir l'en-tete de `sonde_tcp.py`.
CONTROLE = ["172.31.99.99", 22]
def _politique() -> dict:
"""Ce que la frontiere est CENSEE autoriser, lu a la source qui la configure."""
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"),
"--json"], cwd=RACINE, capture_output=True, text=True)
if r.returncode != 0:
raise SystemExit("Le devis de la frontiere ne se genere pas :\n"
+ r.stderr.strip()[:400])
return json.loads(r.stdout)
def _adresses(devis: dict, nom_alias: str) -> list[str]:
"""Les adresses derriere un alias — hotes tels quels, reseaux developpes en prefixe."""
a = devis["alias"].get(nom_alias.lstrip("!"))
return list(a["contenu"]) if a else []
def autorise_depuis_gestion(devis: dict) -> set[tuple[str, int]]:
"""Couples (adresse, port) que le VLAN d'administration a le DROIT d'atteindre."""
permis: set[tuple[str, int]] = set()
for r in devis["regles"]:
if r["interface"] != devis.get("if_gestion") or r["sens"] != "in":
continue
if r["protocole"] not in ("tcp",):
continue
for cible in _adresses(devis, r["destination"]):
for p in r["ports"] or []:
if str(p).isdigit():
permis.add((cible, int(p)))
return permis
def _couvert(ip: str, permis: set[tuple[str, int]], port: int) -> bool:
"""Un permis peut etre un hote OU un reseau (le `/24` d'un tenant, par exemple)."""
for cible, p in permis:
if p != port:
continue
if cible == ip:
return True
if "/" in cible:
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(cible, strict=False):
return True
except ValueError:
continue
return False
def cibles(ports_par_hote: dict) -> dict:
"""Les listes a sonder, derivees de la politique et de ce qui ecoute vraiment."""
devis = _politique()
permis = autorise_depuis_gestion(devis)
admin = []
for hote, info in sorted(ports_par_hote.items()):
ip = str(info["ip"])
for port in sorted(set(info["ports"])):
attendu = "LIVRE" if _couvert(ip, permis, int(port)) else "REFUSE"
admin.append([ip, int(port), f"{hote}:{port} attendu={attendu}"])
# Sens sortant : la frontiere elle-meme, qu'aucun tenant ne doit joindre. C'est le
# trou mesure le 2026-08-09 — `https://10.0.0.1/` repondait depuis une VM.
interne = []
for r in devis["regles"]:
if r["interface"] == devis.get("if_gestion") and r["sens"] == "in":
for c in _adresses(devis, r["source"]):
if "/" in c:
reseau = ipaddress.ip_network(c, strict=False)
interne.append([str(next(reseau.hosts())), 443,
"frontiere depuis le tenant attendu=REFUSE"])
vus, tenant = set(), []
for c in interne:
if c[0] not in vus:
vus.add(c[0])
tenant.append(c)
return {"controle": CONTROLE, "admin": admin, "tenant": tenant}
def analyser(releve: dict) -> tuple[list[str], list[str]]:
"""Confronte les verdicts sondes a la politique. Rend (ecarts, lignes lisibles)."""
ecarts: list[str] = []
lignes: list[str] = []
for vantage, cle in (("VLAN d'administration", "admin"), ("depuis un tenant", "tenant")):
bloc = releve.get(cle) or {}
if not bloc:
continue
if not bloc.get("valide", False):
ecarts.append(
f"Releve {vantage} NUL : le controle {CONTROLE[0]} a repondu — quelque "
"chose sur le chemin repond a la place des destinations. Aucun verdict "
"de ce point de vue n'a de sens tant que ce n'est pas corrige."
)
continue
lignes.append(f" --- {vantage} ---")
for r in bloc.get("resultats") or []:
etiq = str(r.get("etiquette") or "")
attendu = etiq.rsplit("attendu=", 1)[-1] if "attendu=" in etiq else "?"
obtenu = str(r.get("verdict"))
# AMBIGU vaut « la connexion s'etablit sans rien livrer » : pour un port qui
# doit etre REFUSE, c'est un ecart — la frontiere a relaye. Pour un port qui
# doit etre LIVRE, c'est aussi un ecart : on n'a pas la preuve qu'il sert.
conforme = (attendu == obtenu)
lignes.append(f" {'ok ' if conforme else 'ECART'} {etiq[:52]:<52} "
f"{obtenu:<7} {str(r.get('preuve'))[:40]}")
if conforme:
continue
# Deux natures d'ecart, qui n'appellent pas le meme geste :
# - REFUSE attendu, connexion etablie -> la bordure a RELAYE. C'est le trou.
# - LIVRE attendu, rien livre -> la bordure autorise, l'HOTE refuse.
# Rien ne fuit ; ce sont les deux couches qui ne disent pas la meme chose.
if attendu == "LIVRE":
ecarts.append(f"{etiq} — la frontiere l'autorise mais l'hote le refuse : "
"les deux couches ne declarent pas la meme politique")
else:
ecarts.append(f"{etiq} — obtenu {obtenu} : la frontiere a relaye "
f"({r.get('preuve')})")
return ecarts, lignes
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--cibles", action="store_true",
help="emet les listes a sonder (lit les ports en ecoute sur stdin)")
ap.add_argument("--releve", default=None, help="fichier de releve a analyser")
a = ap.parse_args(argv)
if a.cibles:
json.dump(cibles(json.load(sys.stdin)), sys.stdout)
return 0
chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-frontiere.json"
if not chemin.is_file():
raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.")
ecarts, lignes = analyser(json.loads(chemin.read_text()))
print("\n".join(lignes))
if ecarts:
print(f"\nNON CONFORME : {len(ecarts)} ecart(s) — la frontiere ne dit pas ce que "
"la politique dit.")
for e in ecarts:
print(" -", e)
return 1
print("\nCONFORME : tout ce qui est declare est livre, tout le reste est refuse.")
return 0
if __name__ == "__main__":
sys.exit(main())

126
scripts/sonde_tcp.py Normal file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Sonde TCP honnete : distingue LIVRE, BLOQUE et AMBIGU — et refuse de conclure.
Pourquoi ce fichier existe. Pendant deux jours, `connect()` a menti : a travers la
frontiere, toute connexion aboutissait, y compris vers `172.31.99.99` une adresse qui
n'appartient a personne. La cause etait un `synproxy` porte par une regle heritee : le
pare-feu repondait au SYN a la place de la destination. Trois faux diagnostics en sont
sortis avant qu'un controle ne le revele.
D'ou les deux principes de cette sonde :
1. **Un CONTROLE avant toute conclusion.** On sonde d'abord une adresse ou personne
n'ecoute. Si elle « repond », l'instrument est en cause et le releve est declare NUL
aucun verdict n'est rendu. Mieux vaut pas de mesure qu'une mesure fausse.
2. **On ne conclut jamais sur `connect()` seul.** Etablir n'est pas livrer. La sonde
essaie de faire PARLER le service, dans cet ordre : lire une banniere (SSH, SMTP,
IMAP parlent en premier), sinon envoyer une requete HTTP minimale, sinon tenter une
poignee TLS. Si rien ne revient, le verdict est **AMBIGU**, pas « ouvert » : certains
protocoles (LDAP, PostgreSQL) attendent un message bien forme qu'on ne fabrique pas
ici, et un synproxy se comporte exactement pareil.
Usage : la liste des cibles arrive en JSON sur l'entree standard, les verdicts sortent en
JSON sur la sortie standard. Aucun etat, aucun fichier, aucun privilege.
echo '{"controle": ["172.31.99.99", 22], "cibles": [["10.27.18.21", 22, "ssh"]]}' \
| python3 scripts/sonde_tcp.py
"""
from __future__ import annotations
import json
import socket
import ssl
import sys
import time
from concurrent.futures import ThreadPoolExecutor
DELAI_CONNEXION = 4.0
# Genereux a dessein : Postfix retarde sa banniere de plusieurs secondes (postscreen le
# fait expres, pour pieger les spambots). A 2 s, un port parfaitement sain ressortait
# AMBIGU. On compense le cout par le parallelisme plutot qu'en raccourcissant l'attente —
# raccourcir aurait fabrique de faux ecarts, ce que cette sonde existe pour eviter.
DELAI_LECTURE = 8.0
PARALLELE = 12
def _sonder(hote: str, port: int) -> dict:
"""Un verdict pour une cible : BLOQUE, LIVRE ou AMBIGU, avec la preuve."""
debut = time.monotonic()
try:
s = socket.create_connection((hote, port), timeout=DELAI_CONNEXION)
except OSError as e:
return {"verdict": "BLOQUE", "ms": int((time.monotonic() - debut) * 1000),
"preuve": type(e).__name__}
ms = int((time.monotonic() - debut) * 1000)
try:
s.settimeout(DELAI_LECTURE)
# 1. Le serveur parle-t-il en premier ? (SSH, SMTP, IMAP, POP3, redis apres coup)
try:
data = s.recv(96)
if data:
return {"verdict": "LIVRE", "ms": ms,
"preuve": data.decode("utf-8", "replace").strip()[:60]}
except (TimeoutError, socket.timeout, OSError):
pass
# 2. Sinon, le faire parler. Une requete HTTP minimale suffit pour tout ce qui
# parle HTTP, et arrache une erreur lisible a plusieurs qui ne le parlent pas.
try:
s.sendall(b"GET / HTTP/1.0\r\nHost: sonde\r\n\r\n")
data = s.recv(96)
if data:
return {"verdict": "LIVRE", "ms": ms,
"preuve": data.decode("utf-8", "replace").strip()[:60]}
except (TimeoutError, socket.timeout, OSError):
pass
finally:
try:
s.close()
except OSError:
pass
# 3. Dernier essai : une vraie poignee TLS. En TLS c'est le CLIENT qui parle en
# premier — le silence des deux etapes precedentes ne prouve donc rien ici.
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((hote, port), timeout=DELAI_CONNEXION) as brut:
with ctx.wrap_socket(brut) as tls:
cert = tls.getpeercert(binary_form=True)
if cert:
return {"verdict": "LIVRE", "ms": ms,
"preuve": f"TLS {tls.version()}, certificat servi"}
except (OSError, ssl.SSLError):
pass
return {"verdict": "AMBIGU", "ms": ms,
"preuve": "connexion etablie, aucune donnee applicative"}
def main() -> int:
# En argument quand la sonde est deposee sur un hote distant (`ansible.builtin.script`
# ne sait pas alimenter l'entree standard), sur stdin en local.
entree = json.loads(sys.argv[1]) if len(sys.argv) > 1 else json.load(sys.stdin)
ctrl_hote, ctrl_port = entree["controle"]
controle = _sonder(str(ctrl_hote), int(ctrl_port))
# Le controle DOIT etre bloque. S'il ne l'est pas, quelque chose sur le chemin repond
# a la place des destinations : plus aucun verdict de ce releve n'a de sens.
if controle["verdict"] != "BLOQUE":
json.dump({"valide": False, "controle": controle, "resultats": []}, sys.stdout)
return 0
def _un(cible) -> dict:
hote, port = str(cible[0]), int(cible[1])
return {"hote": hote, "port": port,
"etiquette": cible[2] if len(cible) > 2 else "",
**_sonder(hote, port)}
liste = entree.get("cibles") or []
with ThreadPoolExecutor(max_workers=PARALLELE) as pool:
resultats = list(pool.map(_un, liste))
json.dump({"valide": True, "controle": controle, "resultats": resultats}, sys.stdout)
return 0
if __name__ == "__main__":
sys.exit(main())