From c3f76ab91b7b5939533a8a387a48ebcfa535a537 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sat, 5 Sep 2026 22:52:07 +0200 Subject: [PATCH] feat(M2): configuration, gestion des secrets et logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 12 ++- .pre-commit-config.yaml | 2 +- TODO.md | 14 ++-- pronote_sync/config/env.py | 13 ++++ pronote_sync/config/settings.py | 132 ++++++++++++++++++++++++++++++++ pronote_sync/utils/logging.py | 67 ++++++++++++++++ pronote_sync/utils/redaction.py | 65 ++++++++++++++++ pronote_sync/utils/uid.py | 67 ++++++++++++++++ 8 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 pronote_sync/config/env.py create mode 100644 pronote_sync/config/settings.py create mode 100644 pronote_sync/utils/logging.py create mode 100644 pronote_sync/utils/redaction.py create mode 100644 pronote_sync/utils/uid.py diff --git a/.env.example b/.env.example index 3465b97..ea8ea88 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,15 @@ SYNC_FUTURE_DAYS=30 THEORETICAL_AGENDA_PATH=./data/theoretical.ics # --- XMPP --- +XMPP_ENABLED=false XMPP_JID=user@example.com XMPP_PASSWORD=your_xmpp_password -XMPP_RECIPIENT=parent@example.com +XMPP_HOST=example.com +XMPP_PORT=5222 +XMPP_TO=parent@example.com +XMPP_RESOURCE=pronote-sync +XMPP_USE_TLS=true +XMPP_TIMEOUT=30 # --- IA (optionnelle) --- AI_ENABLED=true @@ -32,6 +38,10 @@ AI_BASE_URL=https://api.openai.com/v1 AI_API_KEY=your_ai_api_key AI_MODEL=gpt-4o-mini +# --- Blog --- +BLOG_ENABLED=false +BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 + # --- Divers --- DRY_RUN=false LOG_LEVEL=INFO diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e461b4b..56e3820 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: name: mypy entry: mypy language: python - additional_dependencies: ["mypy>=1.10.0"] + additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"] types: [python] pass_filenames: true diff --git a/TODO.md b/TODO.md index 7c9b2e3..28b8178 100644 --- a/TODO.md +++ b/TODO.md @@ -31,13 +31,13 @@ Mettre en place le dépôt, l'environnement, l'arborescence du package et la cha Implémenter la configuration Pydantic Settings, le masquage des secrets et la journalisation sûre. -- [ ] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env. -- [ ] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`). -- [ ] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2). -- [ ] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1). -- [ ] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2). -- [ ] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote). -- [ ] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`. +- [x] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env. +- [x] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`). +- [x] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2). +- [x] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1). +- [x] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2). +- [x] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote). +- [x] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`. ### Critères d'acceptation - `from pronote_sync.config.settings import settings` fonctionne et charge `.env`. diff --git a/pronote_sync/config/env.py b/pronote_sync/config/env.py new file mode 100644 index 0000000..b755a7a --- /dev/null +++ b/pronote_sync/config/env.py @@ -0,0 +1,13 @@ +"""Chargement de la configuration depuis l'environnement.""" + +from __future__ import annotations + +from pronote_sync.config.settings import Settings + + +def load_settings() -> Settings: + """Charge et valide la configuration depuis le fichier ``.env`` et les variables d'environnement. + + :return: Instance de configuration validée. + """ + return Settings() diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py new file mode 100644 index 0000000..7edcc39 --- /dev/null +++ b/pronote_sync/config/settings.py @@ -0,0 +1,132 @@ +"""Configuration de l'application via ``pydantic-settings``. + +Chaque sous-groupe de configuration est un modèle ``BaseSettings`` dédié, chargé +depuis les variables d'environnement (préfixées par groupe) et le fichier +``.env``. Tous les champs disposent de valeurs par défaut sûres afin que +``Settings()`` fonctionne même sans fichier de configuration présent. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class PronoteSettings(BaseSettings): + """Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``). + + Les variables d'environnement correspondantes sont préfixées par + ``PRONOTE_``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_") + + ical_url: str | None = None + username: str | None = None + password: SecretStr | None = None + ent: str | None = None + agenda_source: Literal["auto", "ical", "pronotepy"] = "auto" + homework_source: Literal["auto", "ical", "pronotepy"] = "auto" + messages_source: Literal["pronotepy"] = "pronotepy" + + +class CalDAVSettings(BaseSettings): + """Paramètres d'accès au serveur CalDAV de destination. + + Les variables d'environnement correspondantes sont préfixées par + ``CALDAV_``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_") + + url: str | None = None + username: str | None = None + password: SecretStr | None = None + calendar_path: str = "/pronote-sync/" + + +class XmppSettings(BaseSettings): + """Paramètres du canal de notifications XMPP (désactivé par défaut). + + Tous les champs ont des valeurs par défaut afin que le canal XMPP reste + inactif tant qu'il n'est pas explicitement activé. Les variables + d'environnement correspondantes sont préfixées par ``XMPP_``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="XMPP_") + + enabled: bool = False + jid: str | None = None + password: SecretStr | None = None + host: str = "" + port: int = 5222 + to: str | None = None + resource: str = "pronote-sync" + use_tls: bool = True + timeout: int = 30 + + +class AISettings(BaseSettings): + """Paramètres de la synthèse par IA (désactivée par défaut). + + Les variables d'environnement correspondantes sont préfixées par ``AI_``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_") + + enabled: bool = False + provider: Literal["openai", "litellm"] = "openai" + base_url: str | None = None + api_key: SecretStr | None = None + model: str | None = None + + +class BlogSettings(BaseSettings): + """Paramètres de la source RSS du blog du collège (désactivée par défaut). + + Les variables d'environnement correspondantes sont préfixées par + ``BLOG_``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_") + + enabled: bool = False + rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2" + + +class AppSettings(BaseSettings): + """Paramètres généraux de l'application, sans préfixe d'environnement. + + Contient notamment la fenêtre de synchronisation en jours + (``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``). + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + dry_run: bool = False + log_level: str = "INFO" + theoretical_agenda_path: str | None = None + sync_past_days: int = 7 + sync_future_days: int = 30 + + +class Settings(BaseSettings): + """Configuration racine du pipeline ``pronote-sync``. + + Agrège les sous-groupes de configuration : Pronote, CalDAV, XMPP, IA, + blog et paramètres généraux de l'application. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + pronote: PronoteSettings = PronoteSettings() + caldav: CalDAVSettings = CalDAVSettings() + xmpp: XmppSettings = XmppSettings() + ai: AISettings = AISettings() + blog: BlogSettings = BlogSettings() + app: AppSettings = AppSettings() + + +settings = Settings() diff --git a/pronote_sync/utils/logging.py b/pronote_sync/utils/logging.py new file mode 100644 index 0000000..5ffbad3 --- /dev/null +++ b/pronote_sync/utils/logging.py @@ -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) diff --git a/pronote_sync/utils/redaction.py b/pronote_sync/utils/redaction.py new file mode 100644 index 0000000..21724d0 --- /dev/null +++ b/pronote_sync/utils/redaction.py @@ -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)) diff --git a/pronote_sync/utils/uid.py b/pronote_sync/utils/uid.py new file mode 100644 index 0000000..358fa1a --- /dev/null +++ b/pronote_sync/utils/uid.py @@ -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]