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,67 @@
"""Configuration de la journalisation avec masquage automatique des secrets.
Ce module fournit un formateur de logs qui rédige les secrets (tokens, mots
de passe, URLs sensibles) ainsi que l'initialisation du logging global du
pipeline ``pronote-sync``.
"""
from __future__ import annotations
import logging
import sys
from pronote_sync.utils.redaction import redact_secrets
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
class RedactingFormatter(logging.Formatter):
"""Formateur de logs qui masque les secrets des messages et des arguments."""
def format(self, record: logging.LogRecord) -> str:
"""Formate un enregistrement de log en masquant les secrets.
Le message et chaque argument textuel de l'enregistrement sont rédigés
avant le formatage final effectué par :class:`logging.Formatter`.
:param record: Enregistrement de log à formater.
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
:rtype: str
"""
record.msg = redact_secrets(str(record.msg))
args = record.args
if args:
if isinstance(args, tuple):
record.args = tuple(
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
)
else:
record.args = {
key: redact_secrets(value) if isinstance(value, str) else value
for key, value in args.items()
}
return super().format(record)
def setup_logging(level: str = "INFO") -> None:
"""Configure la journalisation globale avec masquage des secrets.
Les gestionnaires existants du logger racine sont supprimés, puis un
gestionnaire unique écrivant sur ``sys.stdout`` est installé. Les loggers
tiers ``urllib3`` et ``slixmpp`` sont ramenés au niveau ``WARNING``.
:param level: Nom du niveau de log (ex: ``"DEBUG"``, ``"INFO"``) ;
les noms inconnus sont ignorés au profit de ``INFO``.
:rtype: None
"""
numeric_level: int = logging.getLevelNamesMapping().get(level.upper(), logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(RedactingFormatter(fmt=_LOG_FORMAT, datefmt=_DATE_FORMAT))
root = logging.getLogger()
for existing in list(root.handlers):
root.removeHandler(existing)
root.setLevel(numeric_level)
root.addHandler(handler)
for noisy_name in ("urllib3", "slixmpp"):
logging.getLogger(noisy_name).setLevel(logging.WARNING)

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))

67
pronote_sync/utils/uid.py Normal file
View File

@@ -0,0 +1,67 @@
"""Utilitaires de gestion des identifiants uniques (UID) des événements.
Ce module fournit la normalisation des UIDs Pronote (suppression des
suffixes temporels) et la génération d'UIDs déterministes par hachage
des champs clés d'un événement, garantissant l'idempotence de la
synchronisation.
"""
from __future__ import annotations
import hashlib
import re
from datetime import datetime
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
def normalize_pronote_uid(uid: str) -> str:
"""Normalise un UID Pronote en supprimant ses suffixes temporels.
Les suffixes de type ``-AAAAMMJJTHHMMSSZ-Index-Education`` puis
``-Index-Education`` sont retirés. La fonction est idempotente :
appliquée à un UID déjà normalisé, elle retourne la même valeur.
:param uid: UID brut provenant de Pronote (ex: ``L-1234-20250901T080000Z-Index-Education``).
:return: UID normalisé, sans suffixe temporel ni marque ``Index-Education``.
:rtype: str
"""
normalized = _TEMPORAL_SUFFIX_PATTERN.sub("", uid)
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
def generate_deterministic_uid(
start: datetime,
end: datetime,
subject: str,
teachers: list[str],
rooms: list[str],
group: str | None = None,
) -> str:
"""Génère un UID déterministe par hachage des champs clés d'un événement.
Utilisé lorsqu'aucun UID exploitable n'est disponible : deux appels avec
des champs identiques produisent le même identifiant, ce qui garantit
l'idempotence de la synchronisation.
:param start: Début de l'événement.
:param end: Fin de l'événement.
:param subject: Intitulé de la matière.
:param teachers: Liste des enseignants (triée avant hachage).
:param rooms: Liste des salles (triée avant hachage).
:param group: Groupe éventuel ; traité comme chaîne vide si absent.
:return: Identifiant déterministe : 12 premiers caractères hexadécimaux
du SHA-1 des champs clés joints par ``|``.
:rtype: str
"""
parts = [
start.isoformat(),
end.isoformat(),
subject,
",".join(sorted(teachers)),
",".join(sorted(rooms)),
group or "",
]
payload = "|".join(parts).encode("utf-8")
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]