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
20 changes: 19 additions & 1 deletion .github/workflows/always-on-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ;
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
193 changes: 193 additions & 0 deletions scripts/ci/check_secret_paths_ignored.py
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}")
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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())
140 changes: 140 additions & 0 deletions scripts/tests/test_check_secret_paths_ignored.py
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)
Loading