Compare commits

..

3 Commits

Author SHA1 Message Date
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
e6e4b10047 Merge branch 'main' into feature/m14-deployment 2026-09-08 16:43:15 +02:00
d60357a017 feat(M14): add deployment artifacts and secret check
Co-authored-by: Codex/gpt-5.6-terra <codex-gpt-5.6-terra@agents.invalid>
2026-09-08 16:42:59 +02:00
7 changed files with 684 additions and 5 deletions

10
TODO.md
View File

@@ -277,11 +277,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets). Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne). - [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress). - [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement. - [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ). - [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production. - [x] Vérifier `pip check` et tester le dry-run avant mise en production.
### Critères d'acceptation ### Critères d'acceptation
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement. - Le service/timer systemd (ou cron) lance le pipeline quotidiennement.

View File

@@ -0,0 +1,9 @@
/var/log/pronote-sync/pronote-sync.log {
daily
missingok
rotate 7
compress
delaycompress
notifempty
create 0640 pronote-sync pronote-sync
}

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Synchronisation Pronote vers CalDAV et XMPP
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=pronote-sync
Group=pronote-sync
WorkingDirectory=/var/lib/pronote-sync
EnvironmentFile=/etc/pronote-sync/pronote-sync.env
Environment=PYTHONUNBUFFERED=1
StateDirectory=pronote-sync
LogsDirectory=pronote-sync
ExecStartPre=/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
ExecStart=/opt/pronote-sync/.venv/bin/pronote-sync
StandardOutput=append:/var/log/pronote-sync/pronote-sync.log
StandardError=append:/var/log/pronote-sync/pronote-sync.log
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/pronote-sync /var/log/pronote-sync

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Exécution quotidienne de pronote-sync
[Timer]
OnCalendar=*-*-* 18:00:00
Persistent=true
Unit=pronote-sync.service
[Install]
WantedBy=timers.target

143
docs/exploitation.md Normal file
View File

@@ -0,0 +1,143 @@
# Exploitation de `pronote-sync`
Ce guide décrit l'installation et l'exploitation des artefacts de déploiement
fournis par le projet. Les paramètres de l'unité systemd fournie sont des
exemples d'installation : adaptez-les à l'hôte cible avant son installation.
Ne placez jamais de secret dans une unité systemd, une commande shell, un
journal ou ce document.
## Préparer l'hôte
Installez le projet et ses dépendances dans le répertoire choisi, puis créez le
fichier d'environnement référencé par l'unité à partir de `.env.example`. Il
doit rester local et lisible uniquement par le compte de service :
```bash
sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire-configuration>
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
```
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
les installer, vérifiez que la version installée contient bien ce point
d'entrée :
```bash
.venv/bin/pronote-sync --help
```
Avant toute activation ou mise à jour, exécutez les contrôles depuis la racine
du projet :
```bash
.venv/bin/python scripts/check_secrets.py
.venv/bin/python -m pip check
.venv/bin/pronote-sync --dry-run
```
Le contrôle des secrets doit réussir avant le déploiement. Il inspecte les
fichiers textuels de l'artefact, en excluant volontairement `.env`, les
environnements virtuels, les répertoires générés, `tests/` et
`GUIDE_DEV_PYTHON.md` ; les sentinelles et exemples de ces deux derniers ne
bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permissions
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
commit, utilisez `scripts/check_secrets.py --staged`.
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
il ne remplace pas une vérification des paramètres réellement chargés.
## Installation systemd
Les fichiers versionnés sont :
- `deploy/systemd/pronote-sync.service` ;
- `deploy/systemd/pronote-sync.timer`.
Copiez-les dans le répertoire d'unités systemd de l'hôte. Avant de les activer,
adaptez `User`, `Group`, `WorkingDirectory`, `EnvironmentFile`, les chemins des
exécutables dans `ExecStartPre` et `ExecStart`, ainsi que les chemins de
`StateDirectory`, `LogsDirectory` et `ReadWritePaths`. L'artefact fourni prend
pour exemple le compte `pronote-sync`, le code dans `/opt/pronote-sync`, l'état
dans `/var/lib/pronote-sync`, les logs dans `/var/log/pronote-sync` et le fichier
d'environnement `/etc/pronote-sync/pronote-sync.env`. Ne copiez pas de valeur
secrète dans l'unité.
```bash
sudo install -m 0644 deploy/systemd/pronote-sync.service /etc/systemd/system/
sudo install -m 0644 deploy/systemd/pronote-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pronote-sync.timer
systemctl list-timers pronote-sync.timer
```
Pour tester une exécution sans attendre la prochaine échéance :
```bash
sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service
```
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
de supervision) relève de l'exploitation locale.
## Journaux et alertes
La configuration systemd redirige la sortie standard et la sortie d'erreur vers
`/var/log/pronote-sync/pronote-sync.log`. Consultez ce fichier ou, selon la
configuration de l'hôte, le journal de l'unité :
```bash
sudo tail -f /var/log/pronote-sync/pronote-sync.log
sudo journalctl -u pronote-sync.service --since today
sudo journalctl -u pronote-sync.service -f
systemctl status pronote-sync.timer
```
Traitez un statut non nul ou une unité `failed` comme un échec à investiguer.
Les logs applicatifs masquent les secrets configurés, mais évitez tout de même
de partager sans relecture un export de journal : une donnée sensible issue de
l'environnement ou d'un outil tiers ne doit pas être supposée sûre par défaut.
## Rotation des journaux
L'artefact `deploy/logrotate/pronote_sync` cible le fichier
`/var/log/pronote-sync/pronote-sync.log` utilisé par l'unité fournie. Installez-
le puis validez sa syntaxe avant activation :
```bash
sudo install -m 0644 deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
sudo logrotate --debug /etc/logrotate.d/pronote_sync
```
La rotation configurée est quotidienne, conserve sept archives et utilise
`compress` avec `delaycompress`. Elle recrée le fichier avec les droits `0640`
pour le compte de service. Si vous modifiez le chemin de journal dans l'unité,
mettez aussi à jour la règle logrotate correspondante.
## Mise à jour et retour au service
Avant de remplacer les dépendances ou le code, conservez une copie protégée du
fichier d'environnement local, sans l'ajouter au dépôt. Après la mise à jour,
réexécutez, dans cet ordre, les contrôles de secrets, de cohérence des paquets
et le dry-run :
```bash
.venv/bin/python scripts/check_secrets.py
.venv/bin/python -m pip check
.venv/bin/pronote-sync --dry-run
```
Rechargez ensuite les unités si leurs fichiers ont changé, puis vérifiez une
exécution et son journal :
```bash
sudo systemctl daemon-reload
sudo systemctl restart pronote-sync.timer
sudo systemctl start pronote-sync.service
journalctl -u pronote-sync.service -n 100 --no-pager
```
En cas d'échec, ne relancez pas automatiquement après avoir modifié des
identifiants : corrigez la configuration locale, repassez le contrôle des
secrets et le dry-run, puis consultez le journal expurgé.

245
scripts/check_secrets.py Normal file
View File

@@ -0,0 +1,245 @@
#!/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())

View File

@@ -0,0 +1,249 @@
"""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