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>
250 lines
9.0 KiB
Python
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
|