Files
college-infos/tests/unit/test_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

250 lines
9.0 KiB
Python

"""Tests unitaires du contrôle de secrets de déploiement."""
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from _pytest.capture import CaptureFixture
@pytest.fixture
def secret_checker() -> ModuleType:
"""Charge le script de vérification sans l'exécuter comme programme.
:return: Module du script de contrôle de secrets.
:rtype: ModuleType
"""
script_path = Path(__file__).parents[2] / "scripts" / "check_secrets.py"
specification = importlib.util.spec_from_file_location("check_secrets", script_path)
assert specification is not None
assert specification.loader is not None
module = importlib.util.module_from_spec(specification)
sys.modules[specification.name] = module
try:
specification.loader.exec_module(module)
finally:
del sys.modules[specification.name]
return module
def test_main_accepts_clean_files_and_ignores_environment_file(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un dépôt propre réussit sans analyser le fichier d'environnement.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
(tmp_path / "application.py").write_text("value = 'safe'\n", encoding="utf-8")
ignored_environment_secret = 'password = "private-value"\n' # pragma: allowlist secret
(tmp_path / ".env").write_text(
ignored_environment_secret, encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 0
assert "OK:" in capsys.readouterr().out
def test_main_reports_a_literal_secret_without_disclosing_its_value(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret littéral échoue sans fuite de sa valeur.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-literal-sentinel"
(tmp_path / "settings.py").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.py:1 (affectation-litterale)" in output
assert sentinel not in output
def test_main_reports_an_unquoted_configuration_secret_without_disclosing_its_value(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret de configuration non cité échoue sans fuite de sa valeur.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-unquoted-sentinel"
(tmp_path / "settings.yaml").write_text(
f"password: {sentinel}\n", encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.yaml:1 (affectation-litterale)" in output
assert sentinel not in output
def test_main_detects_sensitive_url_parameter(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un paramètre URL sensible déclenche un échec.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-url-sentinel"
(tmp_path / "settings.yaml").write_text(
f"url: https://example.invalid/calendar?icalsecurise={sentinel}\n", encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.yaml:1 (parametre-url)" in output
assert sentinel not in output
def test_staged_mode_inspects_only_paths_provided_by_git(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie que l'option staged ignore les fichiers non indexés.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
(tmp_path / "indexed.py").write_text("answer = 42\n", encoding="utf-8")
untracked_secret = 'api_key = "m14-untracked-sentinel"\n' # pragma: allowlist secret
(tmp_path / "untracked.py").write_text(
untracked_secret, encoding="utf-8"
) # secret-check: allow
def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
"""Simule Git avec un seul fichier indexé.
:return: Résultat Git simulé.
:rtype: subprocess.CompletedProcess[str]
"""
return subprocess.CompletedProcess([], 0, stdout="indexed.py\0", stderr="")
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 0
assert "OK:" in capsys.readouterr().out
def test_main_detects_prefixed_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'une variable préfixée (PRONOTE_PASSWORD) est détectée.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-prefixed-secret"
(tmp_path / "config.py").write_text(
f'PRONOTE_PASSWORD = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_main_detects_short_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret court (< 8 caractères) est détecté.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "s3cr3t"
(tmp_path / "config.py").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_staged_mode_reads_index_content_not_working_tree(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie que --staged lit le contenu indexé, pas le working tree.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
indexed_secret = "m14-indexed-only-secret" # pragma: allowlist secret
(tmp_path / "staged.py").write_text(
f'password = "{indexed_secret}"\n', encoding="utf-8"
) # secret-check: allow
(tmp_path / "staged.py").write_text('value = "safe"\n', encoding="utf-8")
def runner(*args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
"""Simule Git en renvoyant le contenu indexé pour le blob demandé.
:return: Résultat Git simulé.
:rtype: subprocess.CompletedProcess[str]
"""
first_argument = args[0] if args else []
command = (
[str(argument) for argument in first_argument]
if isinstance(first_argument, list)
else []
)
if "show" in command:
return subprocess.CompletedProcess(
command, 0, stdout=f'password = "{indexed_secret}"\n', stderr=""
)
return subprocess.CompletedProcess(command, 0, stdout="staged.py\0", stderr="")
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 1
output = capsys.readouterr().out
assert "staged.py:1" in output
assert indexed_secret not in output
def test_main_scans_extensionless_deployment_file(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un fichier de déploiement sans extension est scanné.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-logrotate-secret"
(tmp_path / "pronote_sync").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "pronote_sync:1" in output
assert sentinel not in output