feat(M2): configuration, gestion des secrets et logging

- config/settings.py : modèles Pydantic Settings (Pronote, CalDAV, XMPP,
  AI, Blog, App) avec SecretStr pour les mots de passe et clés API
- config/env.py : fonction load_settings() pour le chargement du .env
- utils/redaction.py : redact_url, redact_secrets, redact_exception
  (masquage des tokens icalsecurise, mots de passe et URLs sensibles)
- utils/logging.py : setup_logging + RedactingFormatter
  (masquage automatique des secrets dans les logs)
- utils/uid.py : normalize_pronote_uid (suppression suffixes temporels)
  et generate_deterministic_uid (hash SHA-1 usedforsecurity=False)
- .env.example : aligné sur les modèles finaux (XMPP_TO, XMPP_HOST,
  XMPP_ENABLED, BLOG_ENABLED, etc.)
- .pre-commit-config.yaml : ajout pydantic + pydantic-settings aux
  additional_dependencies du hook mypy
- TODO.md : items M2 cochés

Décisions d'architecture (@architect) :
- XmppSettings : modèle complet §10.2.3, tous champs optionnels
- sync_past_days/future_days déplacés vers AppSettings (sans préfixe)
- AISettings.enabled = False par défaut
- BlogSettings inclus dès M2
- redact_exception comme fonction module (pas méthode)
- normalize_pronote_uid (nom du guide et des tests)

Validations :
- ruff check : PASS
- ruff format --check : PASS
- mypy strict : PASS (7 fichiers)
- bandit : PASS (0 issue)
- import settings : OK (toutes valeurs par défaut)
- redact_secrets/icalsecurise : masqué en REDACTED
- logging : secret masqué dans la sortie
- uid normalize : idempotent, deterministic OK
- SecretStr : pas de fuite dans repr
- pytest : 0 test (infrastructure OK)

Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
This commit is contained in:
2026-09-05 22:52:07 +02:00
parent 7746c226d4
commit c3f76ab91b
8 changed files with 363 additions and 9 deletions

View File

@@ -0,0 +1,65 @@
"""Utilitaires de masquage des secrets dans les URLs, textes et exceptions.
Ce module centralise la rédaction des données sensibles (tokens, mots de
passe, clés d'accès) afin qu'aucun secret ne soit exposé dans les logs,
les messages d'erreur ou les traces du pipeline ``pronote-sync``.
"""
from __future__ import annotations
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"})
_URL_PATTERN = re.compile(r"https?://[^\s]+")
_ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+",
re.IGNORECASE,
)
_REDACTED = "REDACTED"
_REDACTED_URL = "REDACTED_URL"
def redact_url(url: str) -> str:
"""Masque les paramètres sensibles dans une URL.
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``).
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``,
ou ``REDACTED_URL`` si le traitement échoue.
:rtype: str
"""
try:
parts = urlsplit(url)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
for key, value in query
]
return urlunsplit(parts._replace(query=urlencode(redacted_query, doseq=True)))
except Exception:
return _REDACTED_URL
def redact_secrets(text: str) -> str:
"""Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées,
sans distinction de casse.
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
:return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str
"""
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted)
def redact_exception(exc: Exception) -> str:
"""Masque les secrets dans la représentation textuelle d'une exception.
:param exc: Exception dont le message doit être rédigé.
:return: Représentation textuelle de l'exception avec les secrets masqués.
:rtype: str
"""
return redact_secrets(str(exc))