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>
246 lines
9.0 KiB
Python
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())
|