Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
# ===========================================
Expand Down
28 changes: 28 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
135 changes: 135 additions & 0 deletions tests/outils/verdicts_et_commun_separes.py
Original file line number Diff line number Diff line change
@@ -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())
145 changes: 145 additions & 0 deletions tests/test_verdicts_et_commun_separes.py
Original file line number Diff line number Diff line change
@@ -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
Loading