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:
67
pronote_sync/utils/logging.py
Normal file
67
pronote_sync/utils/logging.py
Normal 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)
|
||||
65
pronote_sync/utils/redaction.py
Normal file
65
pronote_sync/utils/redaction.py
Normal 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
67
pronote_sync/utils/uid.py
Normal 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]
|
||||
Reference in New Issue
Block a user