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..f38e0ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -130,3 +130,31 @@ 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 + + # 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 new file mode 100755 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