From c5124999be2f484ab4c24901ae620107588233b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Thu, 22 Oct 2026 12:16:32 +0200 Subject: [PATCH 1/2] chore(garde-fous): le verdict separe de ce qu'il mesure, et un controle mort retire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux corrections venues des catalogues Bases de donnees et GitLab CI, ou elles ont ete trouvees et eprouvees le 2026-10-22. UN VERDICT NE SE COMMITE PAS AVEC CE QU'IL MESURE La peremption d'un verdict se jugeait en comparant des DATES : un lab etait « a reattester » des qu'un commit posterieur touchait ses fichiers. Cette comparaison repond a une autre question que celle qu'on pose, et elle s'est trompee dans les DEUX SENS le meme jour. Faux negatif, le sens dangereux : une correction du code partage et les verdicts qu'elle rendait caducs ont ete commites ENSEMBLE. Aucun verdict n'etait posterieur a quoi que ce soit, le controle a conclu que tout allait bien, et vingt et un verdicts VALIDE ne valaient plus rien. Faux positif : des labs reattestes sur une branche sont ressortis « a reattester » apres leur fusion, parce que le SQUASH ecrase la branche en un commit date de la fusion. Contenu inchange. Systematique, dans tout depot qui fusionne ainsi. Le controle qui fait foi compare desormais deux arbres git cote site, ce qui ne depend plus de la facon de fusionner. Mais un cas echappe a toute mesure a posteriori : une pull request squashee qui retouche un test APRES sa campagne. Le commit unique porte les deux et le squash a detruit ce qui les distinguait. Ni une date, ni un diff entre deux arbres, ni une empreinte inscrite ensuite ne peuvent dire lequel est venu avant. Il ne se constate donc pas, il s'empeche. Le hook refuse un commit portant `validation-labs.json` a cote de ce que le cycle joue : `challenge/tests/`, `setup.yaml`, `cleanup.yaml`, `lab.yaml` d'un lab, et l'outillage partage. L'enonce reste autorise, il n'est pas joue par le cycle et l'interdire obligerait a une campagne pour une coquille. Quatorze tests l'accompagnent, les deux sens couverts, et il a ete eprouve en le forcant a tout refuser : les cas legitimes tombent alors, ce qui est bien ce qu'on veut lire. UN CONTROLE PLUMBER QUI NE CONTROLE PLUS RIEN `pipelineMustNotLeakSecretsInConfig` a ete RETIRE de Plumber, qui repond desormais « was removed and is now ignored: secret detection is no longer part of Plumber; remove this block ». Il etait declare DEUX fois ici, dans les sections gitlab et github. Le garder, c'etait laisser croire que la detection de secrets est couverte. Elle ne l'est pas, et c'est `detect-private-key` qui s'en approche, en hook. --- .plumber.yaml | 30 ----- .pre-commit-config.yaml | 17 +++ tests/outils/verdicts_et_commun_separes.py | 135 +++++++++++++++++++ tests/test_verdicts_et_commun_separes.py | 145 +++++++++++++++++++++ 4 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 tests/outils/verdicts_et_commun_separes.py create mode 100644 tests/test_verdicts_et_commun_separes.py diff --git a/.plumber.yaml b/.plumber.yaml index 19e8cad..2b3fcf7 100644 --- a/.plumber.yaml +++ b/.plumber.yaml @@ -509,20 +509,6 @@ gitlab: # URLs that are trusted and should not trigger findings. # Supports wildcards (e.g., https://internal-artifacts.example.com/*). trustedUrls: [] - # - https://internal-artifacts.example.com/* - # ── Pipeline must not leak secrets in configuration ───────────── - # - # Runs gitleaks against the resolved pipeline YAML to detect hardcoded - # secrets (API tokens, private keys, passwords) committed directly in - # the CI configuration. Requires gitleaks to be installed and available - # on PATH (or configured via gitleaksPath). - pipelineMustNotLeakSecretsInConfig: - # Set to true to enable this control (requires gitleaks) - enabled: false - # Path to the gitleaks binary. Defaults to "gitleaks" (PATH lookup). - # gitleaksPath: /usr/local/bin/gitleaks - # Optional path to a custom gitleaks configuration file. - # gitleaksConfigPath: /path/to/gitleaks.toml # ── Pipeline must not use Docker-in-Docker ────────────────────── # # Detects CI/CD jobs that use Docker-in-Docker (dind) services. @@ -548,22 +534,6 @@ gitlab: # ============================================================================ github: controls: - # ── Pipeline must not leak secrets in configuration ───────────── - # - # Runs gitleaks against every workflow file under - # .github/workflows/ to detect hardcoded secrets (API tokens, - # private keys, passwords) committed directly into the YAML. - # Requires gitleaks to be installed and available on PATH (or - # configured via gitleaksPath). Disabled by default because - # gitleaks is an external dependency. - pipelineMustNotLeakSecretsInConfig: - # Set to true to enable this control (requires gitleaks) - enabled: false - # Path to the gitleaks binary. Defaults to "gitleaks" (PATH lookup). - # gitleaksPath: /usr/local/bin/gitleaks - # Optional path to a custom gitleaks configuration file. - # gitleaksConfigPath: /path/to/gitleaks.toml - # =========================================== # Actions must be pinned by commit SHA # =========================================== diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89934e4..f2f1543 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -130,3 +130,20 @@ repos: language: system pass_filenames: false files: '^(pyproject\.toml|uv\.lock)$' + + # Un verdict ne se commite pas avec ce qu'il mesure. Vecu le 2026-10-22 + # dans deux catalogues, et dans les DEUX SENS : une correction du code + # partage commitee avec les verdicts qu'elle perimait est passee pour + # saine, et des labs reattestes sur une branche sont ressortis « a + # reattester » apres leur fusion en squash, contenu inchange. + # + # Le cas qui ne se rattrape JAMAIS : une PR squashee qui retouche un test + # APRES sa campagne. Le commit unique porte les deux et le squash a + # detruit ce qui les distinguait. Aucune mesure a posteriori ne peut le + # voir, ni une date, ni un diff entre deux arbres. Il ne reste qu'a + # l'empecher. + - id: verdicts-et-commun-separes + name: les verdicts ne sont pas commites avec ce qu'ils mesurent + entry: python3 tests/outils/verdicts_et_commun_separes.py + language: system + pass_filenames: false diff --git a/tests/outils/verdicts_et_commun_separes.py b/tests/outils/verdicts_et_commun_separes.py new file mode 100644 index 0000000..2d18156 --- /dev/null +++ b/tests/outils/verdicts_et_commun_separes.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Un verdict ne se commite pas avec ce qu'il mesure. + +POURQUOI CE GARDE-FOU EXISTE + +Le 2026-10-22, une correction de `labs/_commun/` et les verdicts qu'elle +rendait caducs sont partis dans le MEME commit. Le defaut est passe inapercu +plusieurs heures, et pas par inattention : le generateur du registre, cote +site, classe un lab « a reattester » quand un commit POSTERIEUR a son verdict +touche ses fichiers. Verdicts et modification du commun etant dans le meme +commit, aucun verdict n'etait posterieur a quoi que ce soit, et le controle +concluait que tout allait bien. + +La correction elle-meme etait juste : elargir le patch du registre local a +tous les fichiers de pipeline. Mais elle employait `ansible.builtin.find`, qui +IGNORE LES FICHIERS CACHES par defaut, et `.gitlab-ci.yml` commence par un +point. Elle a donc repare les labs dont le service vit dans `ci/*.yml` et +casse en silence tous ceux dont le service vit a la racine. Vingt et un +verdicts VALIDE ne valaient plus rien, et rien ne le disait. + +CE QUE CE CONTROLE IMPOSE + +L'ordre, et qu'il soit VISIBLE dans l'historique : + + 1. le correctif du commun, dans son commit ; + 2. la campagne de validation ; + 3. le releve, dans un commit qui ne change rien d'autre. + +Un verdict n'est lisible que s'il est ecrit par un commit qui ne modifie pas ce +qu'il mesure. Autrement, « ce verdict est-il encore valable ? » n'a plus de +reponse mecanique, et il faut croire l'auteur sur parole. + +CE QU'IL NE PEUT PAS FAIRE + +Il ne verifie pas que la campagne a REELLEMENT tourne entre les deux commits : +rien dans un depot git ne le prouve. Il rend seulement la question posable. +""" + +from __future__ import annotations + +import subprocess +import sys + +# Ce qu'un verdict MESURE, et qui ne doit donc pas bouger dans le commit qui +# l'inscrit. +# +# La premiere version n'interdisait que le COMMUN et l'outillage, en jugeant +# legitime qu'un commit corrige UN lab et reinscrive SON verdict. C'etait une +# erreur, et elle laissait ouvert exactement le cas qu'aucune mesure ne peut +# rattraper ensuite : si une pull request squashee retouche un test APRES sa +# campagne, le commit unique porte les deux, et le squash a DETRUIT +# l'information qui les distinguait. Ni une date, ni un diff entre deux +# arbres, ni rien d'autre ne peut plus dire lequel est venu avant. +# +# Un controle ne peut donc pas constater cette faute apres coup. Il reste a +# l'empecher, ce que fait ce hook : le releve se commite SEUL, apres la +# campagne qui l'a produit. +# +# Ce qui est mesure, c'est ce que le cycle JOUE. L'enonce, le scenario et les +# indices n'en font pas partie : ils ne sont pas executes, et les interdire +# obligerait a une campagne pour une coquille d'orthographe. +MESURE = ( + "labs/_commun/", # porte tous les labs a la fois + "scripts/", # l'outillage qui produit le verdict +) +# Les fichiers d'un lab que le cycle joue, reconnus par leur fin de chemin. +MESURE_PAR_LAB = ( + "/lab.yaml", + "/setup.yaml", + "/cleanup.yaml", +) +TESTS_DU_LAB = "/challenge/tests/" +RELEVE = "validation-labs.json" + + +def mesure_un_verdict(chemin: str) -> bool: + """Vrai si ce fichier determine ce qu'un verdict dit.""" + if chemin.startswith(MESURE): + return True + if not chemin.startswith("labs/"): + return False + return TESTS_DU_LAB in chemin or chemin.endswith(MESURE_PAR_LAB) + + +def fichiers_indexes() -> list[str]: + issue = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if issue.returncode != 0: + print(f"git a repondu {issue.returncode} : {issue.stderr.strip()}", file=sys.stderr) + # Ne rien pouvoir mesurer n'est pas « tout va bien » : on sort en 2. + sys.exit(2) + return [ligne for ligne in issue.stdout.splitlines() if ligne] + + +def main() -> int: + indexes = fichiers_indexes() + if RELEVE not in indexes: + return 0 + + fautifs = [f for f in indexes if mesure_un_verdict(f)] + if not fautifs: + return 0 + + print( + f"REFUS : ce commit inscrit « {RELEVE} » ET modifie ce que les verdicts\n" + "mesurent :\n", + file=sys.stderr, + ) + for f in sorted(fautifs): + print(f" {f}", file=sys.stderr) + print( + "\nUn verdict ecrit dans le meme commit que le code partage qu'il mesure\n" + "n'est verifiable par personne : le controle de peremption compare les\n" + "dates de commit, et il n'y a plus rien a comparer. C'est exactement\n" + "ainsi que vingt et un verdicts sont restes VALIDE le 2026-10-22 alors\n" + "qu'un defaut du commun les avait tous perimes.\n" + "\n" + "Faire deux commits, dans cet ordre :\n" + "\n" + " git reset HEAD validation-labs.json # sortir le releve de l'index\n" + " git commit # le correctif, seul\n" + " python3 scripts/valider-labs.py # la campagne, apres lui\n" + " git add validation-labs.json && git commit\n", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_verdicts_et_commun_separes.py b/tests/test_verdicts_et_commun_separes.py new file mode 100644 index 0000000..20edf83 --- /dev/null +++ b/tests/test_verdicts_et_commun_separes.py @@ -0,0 +1,145 @@ +"""Le garde-fou qui separe un verdict de ce qu'il mesure, eprouve des deux cotes. + +Le hook `verdicts-et-commun-separes` lit l'INDEX git (`git diff --cached`). En +CI, `pre-commit run --all-files` le lance avec un index vide : il rend 0 sans +rien avoir regarde, et la « parite pre-commit » prouverait donc son existence, +pas son comportement. D'ou ce test, qui monte un depot jetable et place +reellement des fichiers dans l'index. + +Les deux sens sont eprouves. Un controle qui refuserait tout serait satisfait +par les trois premiers cas, et bloquerait alors tout commit de releve : le +defaut inverse. Un controle qui n'accepterait que le cas evident laisserait +passer ce qu'il doit arreter. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +RACINE = Path(__file__).resolve().parents[1] +OUTIL = RACINE / "tests" / "outils" / "verdicts_et_commun_separes.py" + + +def _git(depot: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(depot), *args], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + + +@pytest.fixture +def depot(tmp_path: Path) -> Path: + """Un depot jetable, jamais celui du catalogue : son index est partage.""" + for relatif in ( + "validation-labs.json", + "labs/_commun/gitlab.py", + "scripts/valider-labs.py", + "labs/gl-01/challenge/tests/test_functional.py", + "labs/gl-01/setup.yaml", + "labs/gl-01/cleanup.yaml", + "labs/gl-01/lab.yaml", + "labs/gl-01/README.md", + ): + chemin = tmp_path / relatif + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_text("depart\n", encoding="utf-8") + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.email", "banc@exemple.test") + _git(tmp_path, "config", "user.name", "Banc") + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-qm", "etat de depart") + return tmp_path + + +def _indexer(depot: Path, *fichiers: str) -> None: + for relatif in fichiers: + chemin = depot / relatif + chemin.write_text(chemin.read_text(encoding="utf-8") + "modifie\n", encoding="utf-8") + _git(depot, "add", relatif) + + +def _jouer(depot: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(OUTIL)], + cwd=depot, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + +REFUSES = [ + ("le releve et le commun", ["validation-labs.json", "labs/_commun/gitlab.py"]), + ("le releve et l'outillage", ["validation-labs.json", "scripts/valider-labs.py"]), + ( + "le releve et les deux", + ["validation-labs.json", "labs/_commun/gitlab.py", "scripts/valider-labs.py"], + ), + # Ces quatre cas etaient ACCEPTES par la premiere version du hook, qui + # jugeait legitime de corriger un lab et de reinscrire son verdict d'un + # seul geste. C'est precisement le cas qu'aucune mesure ne rattrape apres + # un squash : le commit unique porte la correction ET le verdict, et rien + # ne dit lequel est venu avant. + ( + "le releve et le test d'un lab", + ["validation-labs.json", "labs/gl-01/challenge/tests/test_functional.py"], + ), + ("le releve et un setup", ["validation-labs.json", "labs/gl-01/setup.yaml"]), + ("le releve et un cleanup", ["validation-labs.json", "labs/gl-01/cleanup.yaml"]), + ("le releve et un lab.yaml", ["validation-labs.json", "labs/gl-01/lab.yaml"]), +] + +ACCEPTES = [ + ("le releve seul", ["validation-labs.json"]), + ("le commun seul", ["labs/_commun/gitlab.py"]), + ("l'outillage seul", ["scripts/valider-labs.py"]), + ("le test d'un lab seul", ["labs/gl-01/challenge/tests/test_functional.py"]), + # L'enonce n'est pas joue par le cycle : l'interdire obligerait a une + # campagne pour une coquille d'orthographe, et un garde-fou qui refuse + # aussi les commits legitimes se contourne au bout de deux jours. + ("le releve et un enonce", ["validation-labs.json", "labs/gl-01/README.md"]), + ("rien du tout", []), +] + + +@pytest.mark.parametrize(("titre", "fichiers"), REFUSES, ids=[t for t, _ in REFUSES]) +def test_un_releve_avec_ce_qu_il_mesure_est_refuse( + depot: Path, titre: str, fichiers: list[str] +) -> None: + _indexer(depot, *fichiers) + issue = _jouer(depot) + assert issue.returncode == 1, ( + f"« {titre} » doit etre refuse : un verdict ecrit dans le meme commit " + "que le code partage qu'il mesure n'est verifiable par personne, le " + "controle de peremption comparant des dates de commit." + ) + assert "REFUS" in issue.stderr + for relatif in fichiers: + if relatif != "validation-labs.json": + assert relatif in issue.stderr, "le message doit NOMMER le fichier fautif" + + +@pytest.mark.parametrize(("titre", "fichiers"), ACCEPTES, ids=[t for t, _ in ACCEPTES]) +def test_ce_qui_est_legitime_passe(depot: Path, titre: str, fichiers: list[str]) -> None: + _indexer(depot, *fichiers) + issue = _jouer(depot) + assert issue.returncode == 0, ( + f"« {titre} » doit passer. Un garde-fou qui refuse aussi les commits " + "legitimes se contourne au bout de deux jours, et ne controle alors " + f"plus rien.\n{issue.stderr}" + ) + + +def test_le_message_donne_le_geste_de_reprise(depot: Path) -> None: + """Un refus qui ne dit pas quoi faire se contourne par --no-verify.""" + _indexer(depot, "validation-labs.json", "labs/_commun/gitlab.py") + issue = _jouer(depot) + assert "git reset HEAD validation-labs.json" in issue.stderr + assert "valider-labs.py" in issue.stderr From 395ee76da61f17bbfbdbf634659bebc42205434d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Thu, 22 Oct 2026 12:20:06 +0200 Subject: [PATCH 2/2] chore(garde-fous): le verdict separe de ce qu'il mesure, et un controle mort retire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux corrections venues des catalogues Bases de donnees et GitLab CI, ou elles ont ete trouvees et eprouvees le 2026-10-22. UN VERDICT NE SE COMMITE PAS AVEC CE QU'IL MESURE La peremption d'un verdict se jugeait en comparant des DATES : un lab etait « a reattester » des qu'un commit posterieur touchait ses fichiers. Cette comparaison repond a une autre question que celle qu'on pose, et elle s'est trompee dans les DEUX SENS le meme jour. Faux negatif, le sens dangereux : une correction du code partage et les verdicts qu'elle rendait caducs ont ete commites ENSEMBLE. Aucun verdict n'etait posterieur a quoi que ce soit, le controle a conclu que tout allait bien, et vingt et un verdicts VALIDE ne valaient plus rien. Faux positif : des labs reattestes sur une branche sont ressortis « a reattester » apres leur fusion, parce que le SQUASH ecrase la branche en un commit date de la fusion. Contenu inchange. Systematique, dans tout depot qui fusionne ainsi. Le controle qui fait foi compare desormais deux arbres git cote site, ce qui ne depend plus de la facon de fusionner. Mais un cas echappe a toute mesure a posteriori : une pull request squashee qui retouche un test APRES sa campagne. Le commit unique porte les deux et le squash a detruit ce qui les distinguait. Ni une date, ni un diff entre deux arbres, ni une empreinte inscrite ensuite ne peuvent dire lequel est venu avant. Il ne se constate donc pas, il s'empeche. Le hook refuse un commit portant `validation-labs.json` a cote de ce que le cycle joue : `challenge/tests/`, `setup.yaml`, `cleanup.yaml`, `lab.yaml` d'un lab, et l'outillage partage. L'enonce reste autorise, il n'est pas joue par le cycle et l'interdire obligerait a une campagne pour une coquille. Quatorze tests l'accompagnent, les deux sens couverts, et il a ete eprouve en le forcant a tout refuser : les cas legitimes tombent alors, ce qui est bien ce qu'on veut lire. UN CONTROLE PLUMBER QUI NE CONTROLE PLUS RIEN `pipelineMustNotLeakSecretsInConfig` a ete RETIRE de Plumber, qui repond desormais « was removed and is now ignored: secret detection is no longer part of Plumber; remove this block ». Il etait declare DEUX fois ici, dans les sections gitlab et github. Le garder, c'etait laisser croire que la detection de secrets est couverte. Elle ne l'est pas, et c'est `detect-private-key` qui s'en approche, en hook. --- .pre-commit-config.yaml | 11 +++++++++++ tests/outils/verdicts_et_commun_separes.py | 0 2 files changed, 11 insertions(+) mode change 100644 => 100755 tests/outils/verdicts_et_commun_separes.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2f1543..f38e0ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -147,3 +147,14 @@ repos: entry: python3 tests/outils/verdicts_et_commun_separes.py language: system pass_filenames: false + + # Le garde-fou ci-dessus est lui-meme eprouve, dans les DEUX sens : + # quatorze cas, ceux qu'il doit refuser et ceux qu'il doit laisser + # passer. Un garde-fou qui refuserait aussi les commits legitimes se + # contourne au bout de deux jours, et ne controle alors plus rien. + - id: garde-fou-des-verdicts-eprouve + name: le garde-fou des verdicts juge dans les deux sens + entry: uv run --no-project --with pytest --with pyyaml pytest tests/test_verdicts_et_commun_separes.py -q + language: system + pass_filenames: false + always_run: true diff --git a/tests/outils/verdicts_et_commun_separes.py b/tests/outils/verdicts_et_commun_separes.py old mode 100644 new mode 100755