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:
parent
67565aade6
commit
a546b03c3a
7 changed files with 506 additions and 2 deletions
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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). |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
118
playbooks/maintenance/devis-frontiere.yml
Normal file
118
playbooks/maintenance/devis-frontiere.yml
Normal 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
184
scripts/devis_frontiere.py
Normal 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
126
scripts/sonde_tcp.py
Normal 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())
|
||||||
Loading…
Reference in a new issue