277 lines
9.9 KiB
Python
277 lines
9.9 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_main_ignores_documentation_url_placeholders(
|
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
|
) -> None:
|
|
"""Ignore les marqueurs de remplacement utilisés dans une documentation.
|
|
|
|
: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 / "guide.md").write_text(
|
|
"\n".join(
|
|
(
|
|
"https://example.invalid/?icalsecurise={jeton}",
|
|
"https://example.invalid/?icalsecurise=••••••••",
|
|
"https://example.invalid/?icalsecurise=<token>",
|
|
"https://example.invalid/?icalsecurise=...",
|
|
)
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert secret_checker.main([], root=tmp_path) == 0
|
|
assert "OK:" in capsys.readouterr().out
|
|
|
|
|
|
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
|