diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fbec2..c79ddf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # CHANGELOG — Set-OPS +## 2026-09-01 — Le gabarit etait un cache du socle, et il perimait sans le dire + +**56 preuves.** Question de l'exploitant : le gabarit dore et cloud-init gagnent-ils leur +place, vu tout ce que Set-OPS fixe par ailleurs ? La mesure a repondu avant le +raisonnement. + +**Le gabarit portait dix-sept roles — exactement ceux du socle et du durcissement**, que +le deploiement rejoue ensuite a l'identique. C'etait un **cache**, et comme tout cache il +perimait : + +``` +derniere recapture 2026-08-09 +roles changes depuis common_packages · cloud_init · ssh_baseline + ssh_hardening · auditd · nftables_baseline +``` + +**Rien ne le signalait.** Aucune preuve du harnais ne regardait sa fraicheur, et le +deploiement masquait la derive en reappliquant tout — *donc personne ne pouvait la voir*. +C'est le defaut que D-81 a corrige pour la forge du genome, jamais traite ici. + +### Il ne garde que ce qui doit exister avant qu'Ansible puisse agir + +``` +qemu_guest_agent l'agent repond AVANT SSH — c'est par lui que P52 confirme +cloud_init le seul chemin vers la premiere seconde : adresse, nom, cles +sudo_ansible le compte technique et son sudo — la porte par ou tout entre +ssh_baseline le serveur SSH, meme raison un cran plus bas +``` + +*Ce ne sont pas des choix d'efficacite, ce sont des conditions d'existence.* + +**cloud-init, lui, ne peut pas etre retire** : sans lui une VM neuve n'a ni adresse ni cle, +donc personne ne peut l'atteindre pour lui en donner une. Il reste le seul chemin vers la +premiere seconde — au prix connu de reecrire `/etc/hosts` a chaque demarrage, ce que le +depot a deja du contourner. + +### Ce que la reduction coute, et qui le couvre + +Une VM neuve n'est plus durcie a la naissance. **Elle nait cependant derriere le pare-feu +de l'hyperviseur** — `policy_in=REJECT`, arme au clonage (verifie sur `obs-01`) : seuls +les flux declares l'atteignent, avant meme son premier paquet. *La fenetre d'exposition est +fermee par la fabric, pas par le gabarit* — mon objection initiale tombait devant la mesure. + +### P56 garde les deux moities + +Qu'il ne **regrossisse** pas : un role ajoute recree le cache, donc la peremption +invisible. Et que rien de retire ne soit **perdu** : un role absent du gabarit *et* du +socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, et la panne +arriverait des mois plus tard sur une machine qu'on croyait durcie. + +*Verifie : 14 retires, 14 repris, zero orphelin. Deux controles negatifs.* + ## 2026-09-01 — `make emancipation-prouver` : couper, pas sonder **55 preuves.** `docs/filiation-emancipation.md` decrivait quatre temps et n'en outillait diff --git a/docs/audit/preuve-2026-09-01.md b/docs/audit/preuve-2026-09-01.md index 1585276..992eac1 100644 --- a/docs/audit/preuve-2026-09-01.md +++ b/docs/audit/preuve-2026-09-01.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (55 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (56 OK · 0 echec · 0 saute) ## Preuves @@ -68,6 +68,7 @@ | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 15 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 15. | +| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | ## Couverture des affirmations ✅ du registre diff --git a/playbooks/modeles_vm/debian13_proxmox_preparer.yml b/playbooks/modeles_vm/debian13_proxmox_preparer.yml index 00360aa..dd81cdf 100644 --- a/playbooks/modeles_vm/debian13_proxmox_preparer.yml +++ b/playbooks/modeles_vm/debian13_proxmox_preparer.yml @@ -21,41 +21,39 @@ msg: "Attention : Debian {{ ansible_facts.distribution_version }} détecté. Le modèle cible est Debian 13." when: ansible_facts.distribution_major_version != "13" + # ─── LE GABARIT EST MINIMAL, ET C'EST UNE DECISION (2026-09-01) ────────────── + # + # Il portait DIX-SEPT roles : exactement ceux du socle et du durcissement, que le + # deploiement rejoue ensuite a l'identique. Le gabarit etait donc un CACHE du socle — + # et comme tout cache, il perimait sans le dire. + # + # Mesure du 2026-09-01 : derniere recapture le 2026-08-09, et SIX de ses roles avaient + # change depuis (common_packages, cloud_init, ssh_baseline, ssh_hardening, auditd, + # nftables_baseline). Rien ne le signalait — le deploiement masquait la derive en + # reappliquant tout, donc personne ne pouvait la voir. + # + # CE QUI RESTE ICI N'EST PAS UN CHOIX D'EFFICACITE, C'EST UNE CONDITION D'EXISTENCE : + # chacun doit etre en place AVANT qu'Ansible puisse agir. Tout le reste appartient au + # socle, qui l'applique de toute facon — et P56 refuse qu'un role retire ne soit repris + # par personne. + # + # `qemu_guest_agent` l'agent invite repond AVANT SSH. C'est par lui que `creer-vm` + # confirme la materialisation sans entrer chez le tenant (P52). + # `cloud_init` le seul chemin vers la premiere seconde — adresse, nom d'hote, + # cles de naissance. Sans lui, personne ne peut joindre la VM. + # `sudo_ansible` le compte technique et son sudo : la porte par ou tout entre. + # `ssh_baseline` le serveur SSH. Meme raison, un cran plus bas. + # + # CE QUE LA REDUCTION COUTE, ET QUI EST COUVERT : une VM neuve n'est plus durcie a la + # naissance. Elle nait cependant DERRIERE LE PARE-FEU DE L'HYPERVISEUR, + # `policy_in=REJECT` arme au clonage (verifie le 2026-09-01 sur `obs-01`) : seuls les + # flux declares l'atteignent, avant meme son premier paquet. La fenetre d'exposition est + # fermee par la fabric, pas par le gabarit. + # + # CE QU'ELLE RAPPORTE : un gabarit qui ne contient presque rien ne peut presque pas + # perimer. roles: - - common_packages - qemu_guest_agent - cloud_init - sudo_ansible - - chrony - ssh_baseline - - systemd_ssh_auto - - motd - - hardening_packages - - sysctl_hardening - - core_dumps - - unattended_upgrades - - apparmor - - auditd - - fail2ban_ssh - - journald - - ssh_hardening - - nftables_baseline - - post_tasks: - - name: Afficher le statut cloud-init - ansible.builtin.command: cloud-init status --long - register: cloud_init_status - changed_when: false - failed_when: false - - - name: Résultat cloud-init - ansible.builtin.debug: - var: cloud_init_status.stdout_lines - - - name: Message final - ansible.builtin.debug: - msg: - - "Préparation terminée." - - "Redémarrer si GRUB a changé." - - "Ne pas convertir avant le playbook de nettoyage final." - - "nftables est installé mais désactivé par défaut." diff --git a/scripts/prouver.py b/scripts/prouver.py index 6a3b930..8b3926c 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,81 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_gabarit_minimal_et_repris() -> tuple[bool, str]: + """Le gabarit ne porte que l'indispensable, et rien de retire n'est perdu. + + LE GABARIT ETAIT UN CACHE DU SOCLE, ET IL PERIMAIT SANS LE DIRE (2026-09-01). Il + portait dix-sept roles — exactement ceux que le deploiement rejoue. Derniere recapture + le 2026-08-09, et six d'entre eux avaient change depuis. Le deploiement masquait la + derive en reappliquant tout : personne ne pouvait la voir. + + Il est desormais MINIMAL — seulement ce qui doit exister AVANT qu'Ansible puisse agir. + Cette preuve garde les deux moities de cette decision : + + 1. il ne REGROSSIT pas. Un role ajoute ici recree le cache, donc la peremption + invisible. La liste est courte et chaque entree se justifie par une condition + d'existence, jamais par la vitesse. + + 2. rien de ce qu'il ne porte plus n'est PERDU. Un role retire du gabarit et absent + du socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, + et la panne arriverait des mois plus tard sur une machine qu'on croyait durcie. + C'est le mode de defaillance le plus couteux de cette famille. + """ + def roles_de(chemin: str) -> list[str]: + data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or [] + out: list[str] = [] + for play in data: + for r in (play.get("roles") or []): + nom = r if isinstance(r, str) else (r or {}).get("role") + if nom: + out.append(str(nom)) + return out + + gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml") + couverture = set(roles_de("playbooks/groupes/serveur_debian.yml")) | set( + roles_de("playbooks/groupes/serveur_durci.yml")) + + # CE QUI DOIT EXISTER AVANT ANSIBLE, et qui ne peut donc pas venir de lui. + # Elargir cette liste est une DECISION, pas un ajustement : elle doit passer par + # cette preuve, et par la question « pourquoi Ansible ne peut-il pas le faire ? ». + indispensables = { + "qemu_guest_agent": "l'agent invite repond avant SSH — P52 s'en sert pour " + "confirmer la materialisation sans entrer chez le tenant", + "cloud_init": "le seul chemin vers la premiere seconde : adresse, nom, cles", + "sudo_ansible": "le compte technique et son sudo — la porte par ou tout entre", + "ssh_baseline": "le serveur SSH, meme raison un cran plus bas", + } + + fautes: list[str] = [] + for r in gabarit: + if r not in indispensables: + fautes.append(f"`{r}` est dans le gabarit sans etre indispensable au premier " + f"demarrage — le gabarit redevient un cache du socle, qui " + f"perime sans le dire") + manquants = [r for r in indispensables if r not in gabarit] + if manquants: + fautes.append("le gabarit ne porte plus " + ", ".join(f"`{m}`" for m in manquants) + + " — une VM neuve serait injoignable ou muette") + + # LE CONTRAT AVEC LE SOCLE : ce que le gabarit ne fait plus, quelqu'un le fait. + ancien_gabarit = { + "common_packages", "chrony", "systemd_ssh_auto", "motd", "hardening_packages", + "sysctl_hardening", "core_dumps", "unattended_upgrades", "apparmor", "auditd", + "fail2ban_ssh", "journald", "ssh_hardening", "nftables_baseline", + } + perdus = sorted(r for r in ancien_gabarit if r not in couverture and r not in gabarit) + if perdus: + fautes.append("retire(s) du gabarit et repris par PERSONNE : " + + ", ".join(f"`{r}`" for r in perdus) + + " — les machines neuves ne l'auraient plus, sans erreur ni trace") + + if fautes: + return False, "Gabarit :\n - " + "\n - ".join(fautes) + return True, (f"Gabarit minimal : {len(gabarit)} role(s), tous indispensables au " + f"premier demarrage ; {len(ancien_gabarit)} role(s) retire(s), tous " + f"repris par le socle ou le durcissement.") + + def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]: """La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui. @@ -2215,6 +2290,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_insemination_sans_secret}, {"id": "P55", "titre": "La cle du SITE ne nait que sur le runner d'un tenant", "refs": [], "func": preuve_cle_du_site_bornee_au_runner}, + {"id": "P56", "titre": "Gabarit minimal, et rien de retire n'est perdu", + "refs": [], "func": preuve_gabarit_minimal_et_repris}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],