Files
college-infos/scripts/check_secrets.py
Antoine Van Elstraete b474f02e90 fix(M14): harden secret scanner — prefixed vars, index blobs, extensionless files
Correct five findings from independent review and security audit of the
M14 deployment secret scanner:

scripts/check_secrets.py:
- Regex: \b[a-z0-9_]* prefix before sensitive keywords to detect
  PRONOTE_PASSWORD, CALDAV_PASSWORD, AI_API_KEY and similar prefixed
  variable names (was: \b which doesn't match before underscore)
- Regex: minimum secret value length reduced from {8,} to {3,} for
  literal, unquoted, and URL parameter patterns
- Regex: unquoted pattern {3,} -> {2,} for 3-char total minimum
- --staged: reads Git index blobs via `git show :<path>` instead of
  working-tree files (ContentProvider type alias, _staged_content_provider)
- Extensionless deployment files: _EXTRA_NAMES allowlist for pronote_sync
- ContentProvider type alias documented with #: Sphinx comment

tests/unit/test_check_secrets.py (4 new tests, 9 total):
- test_main_detects_prefixed_secret_assignment: PRONOTE_PASSWORD detected
- test_main_detects_short_secret_assignment: 6-char secret detected
- test_staged_mode_reads_index_content_not_working_tree: working-tree
  content set to non-matching value to distinguish index from worktree
- test_main_scans_extensionless_deployment_file: pronote_sync scanned

TODO.md: all 5 M14 checklist items checked

Validation: 636 tests, coverage 95.67%, ruff/mypy/bandit/pre-commit green.

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-08 17:20:08 +02:00

246 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Vérifie l'absence de secrets littéraux avant un déploiement.
Le script inspecte le contenu textuel du dépôt, ou uniquement les fichiers
ajoutés/modifiés dans l'index avec ``--staged``. Il ne transmet jamais la
valeur détectée : les résultats ne contiennent que le chemin, le numéro de
ligne et le type de motif. Les fichiers d'environnement et les répertoires
générés sont exclus, car ils ne doivent pas être versionnés ni déployés.
"""
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
_EXCLUDED_PARTS = frozenset({".git", ".venv", ".worktrees", "__pycache__", ".."})
_EXCLUDED_NAMES = frozenset({".env", ".secrets.baseline", "GUIDE_DEV_PYTHON.md"})
_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
_ALLOWLIST_MARKER = "secret-check: allow"
_UNQUOTED_CONFIG_SUFFIXES = frozenset({".conf", ".ini", ".toml", ".yaml", ".yml"})
_TEXT_SUFFIXES = frozenset(
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
)
_LITERAL_SECRET_RE = re.compile(
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
)
_UNQUOTED_SECRET_RE = re.compile(
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
)
_URL_SECRET_RE = re.compile(
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"=([^&#\s]{3,})"
)
_EXTRA_NAMES = frozenset({"pronote_sync"})
@dataclass(frozen=True)
class SecretFinding:
"""Représente un motif sensible détecté sans exposer sa valeur.
:ivar path: Chemin relatif du fichier concerné.
:ivar line: Numéro de ligne du motif.
:ivar rule: Règle ayant détecté le motif.
"""
path: Path
line: int
rule: str
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
#: Fournisseur de contenu pour un chemin relatif ; retourne ``None`` pour ignorer.
ContentProvider = Callable[[Path], str | None]
def _is_candidate(path: Path) -> bool:
"""Indique si un chemin peut être analysé comme fichier texte.
Les fichiers de déploiement sans extension, nommés explicitement dans
``_EXTRA_NAMES``, sont également retenus.
:param path: Chemin relatif au dépôt.
:return: ``True`` lorsque le fichier est textuel et non exclu.
:rtype: bool
"""
return (
not path.is_absolute()
and path.name not in _EXCLUDED_NAMES
and path.parts[0] not in _EXCLUDED_TOP_LEVEL
and not any(part in _EXCLUDED_PARTS for part in path.parts)
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
)
def _repository_files(root: Path) -> list[Path]:
"""Liste les fichiers textuels présents dans le dépôt de travail.
Les tests et la spécification historique ne font pas partie de l'artefact
déployé : leurs sentinelles et exemples intentionnels ne doivent donc pas
bloquer le déploiement.
:param root: Racine du dépôt à analyser.
:return: Chemins relatifs triés des fichiers analysables.
:rtype: list[Path]
"""
return sorted(
path.relative_to(root)
for path in root.rglob("*")
if path.is_file() and _is_candidate(path.relative_to(root))
)
def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
"""Retourne les fichiers ajoutés ou modifiés actuellement indexés.
:param root: Racine du dépôt Git.
:param runner: Exécuteur de sous-processus injectable pour les tests.
:return: Chemins relatifs triés des fichiers indexés analysables.
:rtype: list[Path]
:raises RuntimeError: Si Git ne peut pas fournir les fichiers indexés.
"""
result = runner(
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError("Impossible de lister les fichiers Git indexés") from None
paths = [Path(value) for value in result.stdout.split("\0") if value]
return sorted(path for path in paths if _is_candidate(path))
def _staged_content_provider(root: Path, runner: CommandRunner) -> ContentProvider:
"""Retourne un lecteur de contenu depuis l'index Git.
Lit le blob indexé via ``git show :<chemin>`` afin de ne pas dépendre de
l'état du working tree, dont la copie de travail peut différer de l'index.
:param root: Racine du dépôt Git.
:param runner: Exécuteur de sous-processus injectable pour les tests.
:return: Fonction de lecture du contenu indexé ; ``None`` si indisponible.
:rtype: ContentProvider
"""
def provider(relative_path: Path) -> str | None:
result = runner(
["git", "show", f":{relative_path}"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
return result.stdout
return provider
def find_secrets(
root: Path,
files: Iterable[Path],
content_provider: ContentProvider | None = None,
) -> list[SecretFinding]:
"""Détecte les motifs de secrets littéraux dans les fichiers désignés.
Les lignes explicitement marquées ``secret-check: allow`` sont exclues :
cette échappatoire doit rester locale à une fixture ou un exemple contrôlé.
:param root: Racine du dépôt analysé.
:param files: Chemins relatifs à inspecter.
:param content_provider: Lecteur optionnel du contenu d'un fichier ; par
défaut le contenu est lu depuis le working tree via ``read_text``.
Si le lecteur retourne ``None`` ou lève une erreur d'encodage, le
fichier est ignoré.
:return: Résultats triés par chemin, ligne et règle.
:rtype: list[SecretFinding]
"""
findings: list[SecretFinding] = []
for relative_path in files:
path = root / relative_path
try:
if content_provider is not None:
content = content_provider(relative_path)
else:
content = path.read_text(encoding="utf-8")
if content is None:
continue
except (OSError, UnicodeDecodeError):
continue
for number, line in enumerate(content.splitlines(), start=1):
if _ALLOWLIST_MARKER in line:
continue
is_literal_secret = _LITERAL_SECRET_RE.search(line) or (
relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
and _UNQUOTED_SECRET_RE.search(line)
)
if is_literal_secret:
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
if _URL_SECRET_RE.search(line):
findings.append(SecretFinding(relative_path, number, "parametre-url"))
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de vérification.
:param arguments: Arguments explicites, ou ``None`` pour ceux du processus.
:return: Options validées.
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description="Vérifie les secrets avant déploiement.")
parser.add_argument(
"--staged",
action="store_true",
help="Analyse uniquement les fichiers ajoutés ou modifiés dans l'index Git.",
)
return parser.parse_args(arguments)
def main(
arguments: Sequence[str] | None = None,
*,
root: Path | None = None,
runner: CommandRunner = subprocess.run,
) -> int:
"""Exécute la vérification de secrets et retourne un code de sortie.
:param arguments: Arguments de ligne de commande.
:param root: Racine à analyser ; le dépôt du script par défaut.
:param runner: Exécuteur Git injectable pour les tests.
:return: ``0`` sans motif, ``1`` si un motif est trouvé, ``2`` si le contrôle échoue.
:rtype: int
"""
parsed_arguments = _parse_arguments(arguments)
repository_root = root or Path(__file__).resolve().parents[1]
try:
if parsed_arguments.staged:
files = _staged_files(repository_root, runner)
content_provider = _staged_content_provider(repository_root, runner)
else:
files = _repository_files(repository_root)
content_provider = None
except RuntimeError as error:
print(f"ERREUR: {error}")
return 2
findings = find_secrets(repository_root, files, content_provider=content_provider)
if not findings:
print("OK: aucun secret littéral détecté.")
return 0
for finding in findings:
print(f"ECHEC: {finding.path}:{finding.line} ({finding.rule})")
return 1
if __name__ == "__main__":
raise SystemExit(main())