Repository navigation
fix(secrets,#17441): .secrets/ couvert par une règle versionnée + organe de couverture #17442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` : `<source>:<ligne>:<motif>\t<chemin>`. | ||
|
|
||
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.