Author SHA1 Message Date
Codex 5bd97402bf fix(cli): exposer les executions degradees 2026-09-12 15:37:11 +02:00
Codex 6bb8ad1ed1 docs(deploiement) : imposer les permissions de la configuration
Aligne la documentation d'exploitation et le wiki sur les permissions protegees des secrets et de l'etat d'authentification.

Co-authored-by: Codex <codex@antoineve.me>
2026-09-12 15:26:57 +02:00
5 changed files with 84 additions and 101 deletions
+37 -11
View File
@@ -17,6 +17,24 @@ sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement> sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
``` ```
Pour créer directement la configuration à partir du modèle versionné, utilisez
la même installation protégée puis éditez le fichier avec un compte autorisé :
```bash
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> \
/opt/pronote-sync/.env.example <fichier-environnement>
```
Contrôlez ensuite uniquement les métadonnées du fichier, sans afficher son
contenu :
```bash
stat -c 'mode=%a proprietaire=%U groupe=%G chemin=%n' <fichier-environnement>
```
Le résultat attendu est le mode `600` et le propriétaire/groupe du compte de
service. Le répertoire de configuration doit rester en `0700`.
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de 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 les installer, vérifiez que la version installée contient bien ce point
d'entrée : d'entrée :
@@ -42,19 +60,20 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
commit, utilisez `scripts/check_secrets.py --staged`. commit, utilisez `scripts/check_secrets.py --staged`.
Dans une URL, le contrôle exempte uniquement les placeholders documentaires
explicitement reconnus : `{token}`/`{jeton}` (et les quelques noms documentaires
équivalents), leur forme `<token>`, les suites de trois points ou les caractères
de masquage `•`/`…`. Les valeurs entre accolades ou chevrons au contenu arbitraire,
les astérisques et toute valeur réelle restent signalés. Un même paramètre URL est
contrôlé même si un autre paramètre sensible de la ligne est déjà un placeholder.
Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du
processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés. Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
`0600`. Ils contiennent des credentials vivantes et ne doivent être ni copiés,
ni versionnés, ni affichés. Le PIN du QR code et, si nécessaire, le PIN de
second facteur du compte sont également des secrets ; configurez-les uniquement
dans le fichier d'environnement protégé.
## Installation systemd ## Installation systemd
Les fichiers versionnés sont : Les fichiers versionnés sont :
@@ -86,10 +105,17 @@ sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service sudo systemctl status pronote-sync.service
``` ```
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`. La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un `2` une exécution dégradée (les données Pronote sont disponibles mais une étape
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
de supervision) relève de l'exploitation locale. nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit
donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de
distinguer automatiquement une alerte dégradée d'une panne critique, sans lire
les journaux.
Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le
même contrat de codes : `0` si la simulation est complète, `2` si elle est
dégradée et `1` si elle est critique.
## Journaux et alertes ## Journaux et alertes
+23 -4
View File
@@ -11,6 +11,7 @@ from pydantic import SecretStr
from pronote_sync.config.env import load_settings from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings from pronote_sync.config.settings import Settings
from pronote_sync.errors import ErrorSeverity, PipelineError
from pronote_sync.pipeline.run import PipelineRunner from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets from pronote_sync.utils.redaction import redact_secrets
@@ -19,6 +20,26 @@ logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
# Contrat stable pour systemd et les outils de supervision.
EXIT_SUCCESS = 0
EXIT_CRITICAL = 1
EXIT_DEGRADED = 2
def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int:
"""Convertit le résultat du pipeline en code de sortie supervisable.
:param data: Données normalisées produites, ou ``None`` en cas d'échec critique.
:param errors: Erreurs et avertissements de l'exécution.
:return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique.
:rtype: int
"""
if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors):
return EXIT_CRITICAL
if errors:
return EXIT_DEGRADED
return EXIT_SUCCESS
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace: def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme. """Analyse les options de lancement du programme.
@@ -120,7 +141,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
bruts afin de préserver le diagnostic sans exposer de secret. bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques. :param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments). :return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
:rtype: int :rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2). :raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
""" """
@@ -147,9 +168,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
secrets = _settings_secrets(settings) secrets = _settings_secrets(settings)
for error in errors: for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets)) logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
if data is None: return _pipeline_exit_code(data, errors)
return 1
return 0
if __name__ == "__main__": if __name__ == "__main__":
+1 -14
View File
@@ -37,15 +37,6 @@ _URL_SECRET_RE = re.compile(
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"=([^&#\s]{3,})" r"=([^&#\s]{3,})"
) )
_URL_PLACEHOLDER_RE = re.compile(
r"(?ix)^(?:"
r"\{(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|"
r"etablissement|hote|host)\}|"
r"<(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|"
r"etablissement|hote|host)>|"
r"\.{3,}|[•…]+"
r")$"
)
_EXTRA_NAMES = frozenset({"pronote_sync"}) _EXTRA_NAMES = frozenset({"pronote_sync"})
@@ -194,11 +185,7 @@ def find_secrets(
) )
if is_literal_secret: if is_literal_secret:
findings.append(SecretFinding(relative_path, number, "affectation-litterale")) findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
has_real_url_secret = any( if _URL_SECRET_RE.search(line):
not _URL_PLACEHOLDER_RE.fullmatch(match.group(1))
for match in _URL_SECRET_RE.finditer(line)
)
if has_real_url_secret:
findings.append(SecretFinding(relative_path, number, "parametre-url")) findings.append(SecretFinding(relative_path, number, "parametre-url"))
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule)) return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
+23 -3
View File
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
runner.run.assert_called_once_with() runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings( def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements.""" """Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
from pronote_sync.cli.main import main from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING")) settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
@@ -72,12 +72,32 @@ def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
exit_code = main([]) exit_code = main([])
assert exit_code == 0 assert exit_code == 2
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")] assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None) composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with() runner.run.assert_called_once_with()
@pytest.mark.parametrize("step", ["caldav_sync", "send"])
def test_main_returns_degraded_code_for_caldav_or_xmpp_failure(
mocker: MockerFixture,
step: str,
) -> None:
"""Les échecs récupérables CalDAV et XMPP sont observables par le code 2."""
from pronote_sync.cli.main import main
settings = Settings()
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning(f"Échec récupérable de {step}", step=step)],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
assert main([]) == 2
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level( def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture, mocker: MockerFixture,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
-69
View File
@@ -118,75 +118,6 @@ def test_main_detects_sensitive_url_parameter(
assert sentinel not 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_main_detects_a_real_url_secret_after_a_placeholder(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Détecte un secret réel placé après un placeholder sur la même URL.
: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-after-placeholder-sentinel"
(tmp_path / "guide.md").write_text(
f"https://example.invalid/?token={{jeton}}&api_key={sentinel}\n",
encoding="utf-8",
)
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "guide.md:1 (parametre-url)" in output
assert sentinel not in output
@pytest.mark.parametrize("value", ["<ghp_…>", "***"])
def test_main_rejects_ambiguous_url_placeholders(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str], value: str
) -> None:
"""Refuse les valeurs qui ne sont pas des placeholders documentaires fermé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.
:param value: Valeur ambiguë à ne pas exempter.
:return: None
"""
(tmp_path / "guide.md").write_text(
f"https://example.invalid/?token={value}\n", encoding="utf-8"
)
assert secret_checker.main([], root=tmp_path) == 1
assert "guide.md:1 (parametre-url)" in capsys.readouterr().out
def test_staged_mode_inspects_only_paths_provided_by_git( def test_staged_mode_inspects_only_paths_provided_by_git(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str] secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None: ) -> None: