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>
This commit is contained in:
@@ -144,3 +144,106 @@ def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user