Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff6e687b6 |
+4
-37
@@ -17,24 +17,6 @@ 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>
|
||||
```
|
||||
|
||||
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 installer, vérifiez que la version installée contient bien ce point
|
||||
d'entrée :
|
||||
@@ -66,14 +48,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Les fichiers versionnés sont :
|
||||
@@ -105,17 +79,10 @@ sudo systemctl start pronote-sync.service
|
||||
sudo systemctl status pronote-sync.service
|
||||
```
|
||||
|
||||
La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
|
||||
`2` une exécution dégradée (les données Pronote sont disponibles mais une étape
|
||||
optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.config.env import load_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.utils.logging import setup_logging
|
||||
from pronote_sync.utils.redaction import redact_secrets
|
||||
@@ -20,26 +19,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_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:
|
||||
"""Analyse les options de lancement du programme.
|
||||
@@ -141,7 +120,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
|
||||
bruts afin de préserver le diagnostic sans exposer de secret.
|
||||
|
||||
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
|
||||
:return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
|
||||
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
|
||||
:rtype: int
|
||||
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
|
||||
"""
|
||||
@@ -168,7 +147,9 @@ def main(arguments: Sequence[str] | None = None) -> int:
|
||||
secrets = _settings_secrets(settings)
|
||||
for error in errors:
|
||||
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
|
||||
return _pipeline_exit_code(data, errors)
|
||||
if data is None:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -37,6 +37,7 @@ _URL_SECRET_RE = re.compile(
|
||||
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||
r"=([^&#\s]{3,})"
|
||||
)
|
||||
_URL_PLACEHOLDER_RE = re.compile(r"(?x)^(?:\{[^{}\r\n]+\}|<[^<>\r\n]+>|\.+|[•…*]+)$")
|
||||
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||
|
||||
|
||||
@@ -185,7 +186,8 @@ def find_secrets(
|
||||
)
|
||||
if is_literal_secret:
|
||||
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
||||
if _URL_SECRET_RE.search(line):
|
||||
url_match = _URL_SECRET_RE.search(line)
|
||||
if url_match and not _URL_PLACEHOLDER_RE.fullmatch(url_match.group(1)):
|
||||
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
||||
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
||||
|
||||
|
||||
+3
-23
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
|
||||
runner.run.assert_called_once_with()
|
||||
|
||||
|
||||
def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
|
||||
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
|
||||
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
|
||||
from pronote_sync.cli.main import main
|
||||
|
||||
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
|
||||
@@ -72,32 +72,12 @@ def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
|
||||
|
||||
exit_code = main([])
|
||||
|
||||
assert exit_code == 2
|
||||
assert exit_code == 0
|
||||
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
|
||||
composition_root.assert_called_once_with(settings, dry_run=None)
|
||||
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(
|
||||
mocker: MockerFixture,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
||||
@@ -118,6 +118,33 @@ def test_main_detects_sensitive_url_parameter(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user