From d60357a0176eb9b397667590a31352f79e26d3eb Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Tue, 8 Sep 2026 16:42:59 +0200 Subject: [PATCH] feat(M14): add deployment artifacts and secret check Co-authored-by: Codex/gpt-5.6-terra --- deploy/logrotate/pronote_sync | 9 ++ deploy/systemd/pronote-sync.service | 23 ++++ deploy/systemd/pronote-sync.timer | 10 ++ docs/exploitation.md | 143 ++++++++++++++++++++ scripts/check_secrets.py | 198 ++++++++++++++++++++++++++++ tests/unit/test_check_secrets.py | 146 ++++++++++++++++++++ 6 files changed, 529 insertions(+) create mode 100644 deploy/logrotate/pronote_sync create mode 100644 deploy/systemd/pronote-sync.service create mode 100644 deploy/systemd/pronote-sync.timer create mode 100644 docs/exploitation.md create mode 100644 scripts/check_secrets.py create mode 100644 tests/unit/test_check_secrets.py diff --git a/deploy/logrotate/pronote_sync b/deploy/logrotate/pronote_sync new file mode 100644 index 0000000..952be38 --- /dev/null +++ b/deploy/logrotate/pronote_sync @@ -0,0 +1,9 @@ +/var/log/pronote-sync/pronote-sync.log { + daily + missingok + rotate 7 + compress + delaycompress + notifempty + create 0640 pronote-sync pronote-sync +} diff --git a/deploy/systemd/pronote-sync.service b/deploy/systemd/pronote-sync.service new file mode 100644 index 0000000..a21a315 --- /dev/null +++ b/deploy/systemd/pronote-sync.service @@ -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 diff --git a/deploy/systemd/pronote-sync.timer b/deploy/systemd/pronote-sync.timer new file mode 100644 index 0000000..3342927 --- /dev/null +++ b/deploy/systemd/pronote-sync.timer @@ -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 diff --git a/docs/exploitation.md b/docs/exploitation.md new file mode 100644 index 0000000..5c98729 --- /dev/null +++ b/docs/exploitation.md @@ -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 -g +sudo install -m 0600 -o -g .env +``` + +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é. diff --git a/scripts/check_secrets.py b/scripts/check_secrets.py new file mode 100644 index 0000000..9c354fb --- /dev/null +++ b/scripts/check_secrets.py @@ -0,0 +1,198 @@ +#!/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(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" + r"\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]" +) +_UNQUOTED_SECRET_RE = re.compile( + r"(?ix)\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" + r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{7,}" +) +_URL_SECRET_RE = re.compile( + r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" + r"=([^&#\s]{8,})" +) + + +@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]] + + +def _is_candidate(path: Path) -> bool: + """Indique si un chemin peut être analysé comme fichier texte. + + :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 + ) + + +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 find_secrets(root: Path, files: Iterable[Path]) -> 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. + :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: + content = path.read_text(encoding="utf-8") + 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: + files = ( + _staged_files(repository_root, runner) + if parsed_arguments.staged + else _repository_files(repository_root) + ) + except RuntimeError as error: + print(f"ERREUR: {error}") + return 2 + findings = find_secrets(repository_root, files) + 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()) diff --git a/tests/unit/test_check_secrets.py b/tests/unit/test_check_secrets.py new file mode 100644 index 0000000..1a959b1 --- /dev/null +++ b/tests/unit/test_check_secrets.py @@ -0,0 +1,146 @@ +"""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