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

@@ -22,9 +22,15 @@ SYNC_FUTURE_DAYS=30
THEORETICAL_AGENDA_PATH=./data/theoretical.ics THEORETICAL_AGENDA_PATH=./data/theoretical.ics
# --- XMPP --- # --- XMPP ---
XMPP_ENABLED=false
XMPP_JID=user@example.com XMPP_JID=user@example.com
XMPP_PASSWORD=your_xmpp_password 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) --- # --- IA (optionnelle) ---
AI_ENABLED=true AI_ENABLED=true
@@ -32,6 +38,10 @@ AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini AI_MODEL=gpt-4o-mini
# --- Blog ---
BLOG_ENABLED=false
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
# --- Divers --- # --- Divers ---
DRY_RUN=false DRY_RUN=false
LOG_LEVEL=INFO LOG_LEVEL=INFO

View File

@@ -26,7 +26,7 @@ repos:
name: mypy name: mypy
entry: mypy entry: mypy
language: python 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] types: [python]
pass_filenames: true pass_filenames: true

14
TODO.md
View File

@@ -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. 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. - [x] 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")`). - [x] 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). - [x] 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). - [x] 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). - [x] 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). - [x] 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] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
### Critères d'acceptation ### Critères d'acceptation
- `from pronote_sync.config.settings import settings` fonctionne et charge `.env`. - `from pronote_sync.config.settings import settings` fonctionne et charge `.env`.

View File

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

View File

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

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]