diff --git a/.github/workflows/always-on-guards.yml b/.github/workflows/always-on-guards.yml index d333eef7bd..a7ef546ed3 100644 --- a/.github/workflows/always-on-guards.yml +++ b/.github/workflows/always-on-guards.yml @@ -89,7 +89,7 @@ concurrency: jobs: always-on-guards: - name: "Always-on guards -- 15 organes, 1 checkout" + name: "Always-on guards -- 16 organes, 1 checkout" # Routage #14283 : premier consommateur GitHub-hosted du depot une fois # secret-scan bascule -- 2650 runs same-repo en 5 jours (2026-08-28 -> # 2026-09-02), timeout 25 min, aucun filtre `paths:`. Le pool Linux @@ -1319,6 +1319,23 @@ jobs: exit 1 + # Couverture des chemins sensibles par une regle VERSIONNEE (#17441). + # Organe purement local (aucun appel gh, aucun jeton, aucune lecture de + # diff) : il interroge `git check-ignore -v` et regarde QUI gagne. Le + # defaut vise est muet -- un `.git/info/exclude` local masque un secret + # sur la machine qui l'a ecrit, et `git status` y reste propre ; sur tout + # autre clone la regle n'existe pas et le secret est propose au commit. + # Les deux situations sont indiscernables a l'oeil, d'ou l'organe. + # + # Mesure fondatrice : `.secrets/master.env` (les cles API de la flotte) + # puis, un mois plus tard au MEME endroit, les cles privees d'App GitHub + # sous `.secrets/github-apps/` -- une lecon sans detecteur n'a pas tenu. + - name: "Chemins sensibles ignores par une regle versionnee (#17441)" + id: secret_paths_ignored + if: github.event_name == 'pull_request' + continue-on-error: true + run: python scripts/ci/check_secret_paths_ignored.py --repo . + # ----------------------------------------------------------------- # AGREGAT : la couleur du job. Un organe bloquant echoue -> rouge. # Les organes advisory ne figurent pas ici (ils sortent toujours 0 ; @@ -1349,6 +1366,7 @@ jobs: check hot_subset "${{ steps.hot_subset.outcome }}" check results_weight "${{ steps.results_weight.outcome }}" check grothendieck_umbrella "${{ steps.grothendieck_umbrella.outcome }}" + check secret_paths_ignored "${{ steps.secret_paths_ignored.outcome }}" if [ -n "$FAILED" ]; then echo "::error::Organes bloquants en echec :$FAILED (le detail est dans les steps correspondants ci-dessus)." exit 1 diff --git a/.gitignore b/.gitignore index 1318b3c596..8a830113ba 100644 --- a/.gitignore +++ b/.gitignore @@ -382,6 +382,13 @@ MigrationBackup/ # Tokens API backup (SÉCURITÉ CRITIQUE) .secrets/qwen-api-user.token.backup +# Répertoire de secrets à la racine — RÈGLE DE RÉPERTOIRE (SÉCURITÉ CRITIQUE). +# Les entrées nominatives ci-dessus ne couvrent que les fichiers déjà connus au moment +# où on les a ajoutées. master.env et les clés privées d'App GitHub n'étaient masqués +# que par .git/info/exclude — local, non versionné — donc protégés sur UN clone, pas +# sur le dépôt. Garde : scripts/ci/check_secret_paths_ignored.py +.secrets/ + # Scripts secrets directory (SÉCURITÉ CRITIQUE) scripts/.secrets/ scripts/docker-configurations/comfyui-qwen/.secrets/ diff --git a/scripts/ci/check_secret_paths_ignored.py b/scripts/ci/check_secret_paths_ignored.py new file mode 100644 index 0000000000..2ed9d0c93b --- /dev/null +++ b/scripts/ci/check_secret_paths_ignored.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Verifie que les chemins sensibles sont ignores par une regle VERSIONNEE. + +Le defaut vise est muet : un `.git/info/exclude` local masque un secret sur la machine +qui l'a ecrit, et `git status` y reste propre. Sur tout autre clone -- une autre machine +de la flotte, un runner, un fork -- la meme regle n'existe pas, et le secret est propose +au commit. Les deux situations sont **indiscernables** a l'oeil : seule la source gagnante +rendue par `git check-ignore -v` les separe. + +Instance fondatrice : `.secrets/master.env`, puis -- un mois plus tard, au meme endroit -- +les cles privees d'App GitHub sous `.secrets/github-apps/`. Une lecon sans organe ne tient +pas. + +Sorties : 0 = tous les chemins couverts par une regle versionnee · 1 = defaut · +2 = mesure impossible (hors depot, git absent). +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +EXIT_OK = 0 +EXIT_DEFECT = 1 +EXIT_UNREACHABLE = 2 + +# Chemins qui NE DOIVENT JAMAIS etre proposes au commit, sur n'importe quel clone. +# Ce sont des sondes : elles n'ont pas besoin d'exister sur le disque, `check-ignore` +# repond sur les motifs. +CHEMINS_SENSIBLES = ( + ".secrets/sonde", + ".secrets/master.env", + ".secrets/github-apps/sonde.pem", + "scripts/.secrets/sonde", + "docker-configurations/services/comfyui-qwen/.secrets/sonde", +) + +# Chemins qui doivent rester VISIBLES. Sans eux, un organe qui declarerait tout +# ignore passerait au vert en ne mesurant rien. +CONTROLES_POSITIFS = ("README.md", "CLAUDE.md") + + +class MesureImpossible(RuntimeError): + """L'instrument n'a pas pu rendre de verdict -- jamais confondu avec un vert.""" + + +def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess: + try: + return subprocess.run( + ["git", *args], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8" + ) + except OSError as exc: # git absent du PATH + raise MesureImpossible(f"git injoignable : {exc}") from exc + + +def parse_ligne_check_ignore(ligne: str) -> dict | None: + """Decoupe une ligne `git check-ignore -v` : `::\t`. + + Le nom de fichier source peut contenir des `:` (rare mais legal), donc on + decoupe par la DROITE sur les deux derniers separateurs, pas par la gauche. + """ + if "\t" not in ligne: + return None + gauche, chemin = ligne.split("\t", 1) + tete, _, motif = gauche.rpartition(":") + source, _, numero = tete.rpartition(":") + if not source or not numero.isdigit(): + return None + return {"source": source, "ligne": int(numero), "motif": motif, "chemin": chemin} + + +def source_est_versionnee(source: str, suivis: frozenset[str]) -> bool: + """Une source ne protege le DEPOT que si elle voyage avec lui. + + `.git/info/exclude` et le `core.excludesFile` de l'utilisateur sont locaux : + ils rendent le meme `git status` propre, et ne protegent que ce clone. + """ + return source.replace("\\", "/") in suivis + + +def fichiers_suivis(cwd: Path) -> frozenset[str]: + res = _git(["ls-files"], cwd) + if res.returncode != 0: + raise MesureImpossible(f"`git ls-files` a echoue : {res.stderr.strip()}") + return frozenset(res.stdout.replace("\\", "/").splitlines()) + + +def verdict_chemin(chemin: str, cwd: Path, suivis: frozenset[str]) -> dict: + # `--no-index` mesure les REGLES, pas l'etat de l'index : sans lui, un chemin + # deja suivi serait rendu « non ignore » et masquerait la vraie question. + res = _git(["check-ignore", "-v", "--no-index", "--", chemin], cwd) + if res.returncode == 1 and not res.stdout.strip(): + return {"chemin": chemin, "statut": "NON_IGNORE", "source": None} + if res.returncode not in (0, 1): + raise MesureImpossible(f"`check-ignore` a rendu {res.returncode} sur {chemin}") + parsed = parse_ligne_check_ignore(res.stdout.splitlines()[0]) + if parsed is None: + raise MesureImpossible(f"sortie illisible de `check-ignore` sur {chemin}") + versionnee = source_est_versionnee(parsed["source"], suivis) + return { + "chemin": chemin, + "statut": "VERSIONNEE" if versionnee else "LOCALE", + "source": parsed["source"], + "ligne": parsed["ligne"], + "motif": parsed["motif"], + } + + +def analyser( + cwd: Path, + chemins: tuple[str, ...] = CHEMINS_SENSIBLES, + controles_positifs: tuple[str, ...] = CONTROLES_POSITIFS, +) -> dict: + # Les sondes sont injectables pour que les tests puissent batir un depot + # temoin minimal : un organe qu'on ne peut pas faire echouer a volonte + # n'est pas falsifiable. + suivis = fichiers_suivis(cwd) + sensibles = [verdict_chemin(c, cwd, suivis) for c in chemins] + controles = [verdict_chemin(c, cwd, suivis) for c in controles_positifs] + + defauts = [v for v in sensibles if v["statut"] != "VERSIONNEE"] + # L'instrument se falsifie lui-meme : si un controle positif ressort ignore, + # la mesure ne vaut rien -- et un vert serait pire qu'un rouge. + instrument_casse = [v for v in controles if v["statut"] != "NON_IGNORE"] + + # Un secret deja SUIVI n'est protege par aucune regle : la regle arrive trop tard. + suivis_sensibles = sorted(f for f in suivis if "/.secrets/" in f or f.startswith(".secrets/")) + + return { + "sensibles": sensibles, + "controles_positifs": controles, + "defauts": defauts, + "instrument_casse": instrument_casse, + "chemins_sensibles_deja_suivis": suivis_sensibles, + "verdict": ( + "INSTRUMENT_CASSE" + if instrument_casse + else "SUIVI" if suivis_sensibles else "DEFAUT" if defauts else "CLEAN" + ), + } + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--repo", default=".", help="racine du depot a mesurer") + ap.add_argument("--json", action="store_true", help="verdict machine") + args = ap.parse_args(argv) + + try: + rapport = analyser(Path(args.repo).resolve()) + except MesureImpossible as exc: + if args.json: + print(json.dumps({"verdict": "UNKNOWN", "raison": str(exc)}, ensure_ascii=False)) + else: + print(f"UNKNOWN -- {exc}", file=sys.stderr) + return EXIT_UNREACHABLE + + if args.json: + print(json.dumps(rapport, ensure_ascii=False, indent=2)) + return EXIT_OK if rapport["verdict"] == "CLEAN" else EXIT_DEFECT + + for v in rapport["sensibles"]: + marque = "ok " if v["statut"] == "VERSIONNEE" else "DEFAUT" + origine = f'{v["source"]}:{v.get("ligne")}' if v["source"] else "aucune regle" + print(f' {marque} {v["chemin"]:<58} <- {origine}') + for v in rapport["controles_positifs"]: + etat = "visible" if v["statut"] == "NON_IGNORE" else "IGNORE (!)" + print(f' ctl {v["chemin"]:<58} -> {etat}') + + if rapport["instrument_casse"]: + print("\nINSTRUMENT_CASSE -- un controle positif ressort ignore : mesure sans valeur.") + return EXIT_DEFECT + if rapport["chemins_sensibles_deja_suivis"]: + print("\nSUIVI -- ces fichiers sont deja dans l'index, aucune regle ne les retire :") + for f in rapport["chemins_sensibles_deja_suivis"]: + print(f" {f}") + return EXIT_DEFECT + if rapport["defauts"]: + print("\nDEFAUT -- couverts par une regle LOCALE, donc sur ce clone seulement :") + for v in rapport["defauts"]: + print(f' {v["chemin"]} <- {v["source"] or "aucune regle"}') + print(" Remede : poser la regle dans le .gitignore VERSIONNE.") + return EXIT_DEFECT + + print("\nCLEAN -- tous les chemins sensibles sont couverts par une regle versionnee.") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_secret_paths_ignored.py b/scripts/tests/test_check_secret_paths_ignored.py new file mode 100644 index 0000000000..61abb2c0bd --- /dev/null +++ b/scripts/tests/test_check_secret_paths_ignored.py @@ -0,0 +1,140 @@ +"""Tests de `scripts/ci/check_secret_paths_ignored.py`. + +Deux familles, et les deux sont necessaires : + +* **unitaires** sur le decoupage de `git check-ignore -v` -- dont le cas Windows + `D:/CoursIA/.git/info/exclude:24:...`, ou la source contient elle-meme un `:` ; +* **bout en bout** sur des depots temoins, ou la MEME arborescence est rendue + `DEFAUT` ou `CLEAN` selon que la regle vit dans `.git/info/exclude` (local) ou + dans un `.gitignore` suivi. C'est la paire discriminante : sans elle, un organe + qui refuserait tout passerait aussi le test « rouge avant, vert apres ». +""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +ORGANE = Path(__file__).resolve().parents[1] / "ci" / "check_secret_paths_ignored.py" + + +@pytest.fixture(scope="module") +def mod(): + spec = importlib.util.spec_from_file_location("check_secret_paths_ignored", ORGANE) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def _depot(racine: Path, regle_versionnee: bool, regle_locale: bool) -> Path: + """Bâtit un depot temoin ou `.secrets/` est couvert par l'une, l'autre, ou aucune.""" + subprocess.run(["git", "init", "-q", str(racine)], check=True) + subprocess.run(["git", "config", "user.email", "t@t"], cwd=racine, check=True) + subprocess.run(["git", "config", "user.name", "t"], cwd=racine, check=True) + (racine / "README.md").write_text("temoin\n", encoding="utf-8") + (racine / ".gitignore").write_text( + ".secrets/\n" if regle_versionnee else "# aucune regle de repertoire\n", + encoding="utf-8", + ) + if regle_locale: + excl = racine / ".git" / "info" + excl.mkdir(parents=True, exist_ok=True) + (excl / "exclude").write_text("/.secrets/\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md", ".gitignore"], cwd=racine, check=True) + subprocess.run(["git", "commit", "-qm", "temoin"], cwd=racine, check=True) + return racine + + +SONDES = (".secrets/sonde",) +CONTROLES = ("README.md",) + + +# --------------------------------------------------------------------------- unitaires + +def test_decoupe_une_ligne_standard(mod): + v = mod.parse_ligne_check_ignore(".gitignore:390:.secrets/\t.secrets/master.env") + assert v == { + "source": ".gitignore", + "ligne": 390, + "motif": ".secrets/", + "chemin": ".secrets/master.env", + } + + +def test_decoupe_une_source_qui_contient_deux_points(mod): + """Un chemin Windows porte un `:` -- un split par la GAUCHE tronquerait la source.""" + v = mod.parse_ligne_check_ignore( + "D:/CoursIA/.git/info/exclude:24:/.secrets/\t.secrets/master.env" + ) + assert v["source"] == "D:/CoursIA/.git/info/exclude" + assert v["ligne"] == 24 + + +def test_une_ligne_sans_tabulation_ne_se_decoupe_pas(mod): + assert mod.parse_ligne_check_ignore("pas une sortie de check-ignore") is None + + +def test_une_source_suivie_est_versionnee(mod): + assert mod.source_est_versionnee(".gitignore", frozenset({".gitignore"})) is True + + +def test_une_source_non_suivie_est_locale(mod): + assert ( + mod.source_est_versionnee("D:/CoursIA/.git/info/exclude", frozenset({".gitignore"})) + is False + ) + + +# ------------------------------------------------------------------- bout en bout + +def test_une_regle_locale_seule_est_un_defaut(mod, tmp_path): + r = _depot(tmp_path / "local", regle_versionnee=False, regle_locale=True) + rapport = mod.analyser(r, SONDES, CONTROLES) + assert rapport["verdict"] == "DEFAUT" + assert rapport["sensibles"][0]["statut"] == "LOCALE" + + +def test_une_regle_versionnee_est_clean(mod, tmp_path): + """CONTROLE POSITIF : la meme arborescence doit passer au vert.""" + r = _depot(tmp_path / "versionnee", regle_versionnee=True, regle_locale=True) + rapport = mod.analyser(r, SONDES, CONTROLES) + assert rapport["verdict"] == "CLEAN" + assert rapport["sensibles"][0]["source"] == ".gitignore" + + +def test_aucune_regle_du_tout_est_un_defaut(mod, tmp_path): + r = _depot(tmp_path / "rien", regle_versionnee=False, regle_locale=False) + rapport = mod.analyser(r, SONDES, CONTROLES) + assert rapport["verdict"] == "DEFAUT" + assert rapport["sensibles"][0]["statut"] == "NON_IGNORE" + + +def test_le_controle_positif_doit_rester_visible(mod, tmp_path): + """Si README.md ressort ignore, la mesure ne vaut rien -- et le vert serait pire.""" + r = _depot(tmp_path / "casse", regle_versionnee=True, regle_locale=False) + (r / ".gitignore").write_text(".secrets/\nREADME.md\n", encoding="utf-8") + subprocess.run(["git", "add", ".gitignore"], cwd=r, check=True) + subprocess.run(["git", "commit", "-qm", "casse"], cwd=r, check=True) + rapport = mod.analyser(r, SONDES, CONTROLES) + assert rapport["verdict"] == "INSTRUMENT_CASSE" + + +def test_un_secret_deja_suivi_est_un_defaut(mod, tmp_path): + """Une regle arrivee apres le `git add` ne retire rien de l'index.""" + r = _depot(tmp_path / "suivi", regle_versionnee=True, regle_locale=False) + (r / ".secrets").mkdir() + (r / ".secrets" / "master.env").write_text("K=v\n", encoding="utf-8") + subprocess.run(["git", "add", "-f", ".secrets/master.env"], cwd=r, check=True) + subprocess.run(["git", "commit", "-qm", "oups"], cwd=r, check=True) + rapport = mod.analyser(r, SONDES, CONTROLES) + assert rapport["verdict"] == "SUIVI" + assert ".secrets/master.env" in rapport["chemins_sensibles_deja_suivis"] + + +def test_hors_depot_rend_unknown_pas_un_vert(mod, tmp_path): + """« je n'ai pas pu mesurer » ne se confond jamais avec « rien a signaler ».""" + with pytest.raises(mod.MesureImpossible): + mod.analyser(tmp_path / "pas-un-depot", SONDES, CONTROLES)