diff --git a/.env.example b/.env.example index ea8ea88..981af19 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_USERNAME=user@example.com CALDAV_PASSWORD=your_caldav_password +CALDAV_CALENDAR_PATH=/pronote-sync/ # Fenêtre de synchronisation (jours) SYNC_PAST_DAYS=7 @@ -34,9 +35,10 @@ XMPP_TIMEOUT=30 # --- IA (optionnelle) --- AI_ENABLED=true +AI_PROVIDER=openai AI_BASE_URL=https://api.openai.com/v1 AI_API_KEY=your_ai_api_key -AI_MODEL=gpt-4o-mini +# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut # --- Blog --- BLOG_ENABLED=false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56e3820..4f34eaa 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", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"] + additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"] types: [python] pass_filenames: true diff --git a/.secrets.baseline b/.secrets.baseline index 7dec68a..fbd8007 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -141,7 +141,7 @@ "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_secret": false, "is_verified": true, - "line_number": 5112 + "line_number": 5117 } ] }, diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 9230121..602a76f 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -267,6 +267,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati | `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | +| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` | +| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` | | `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | @@ -291,9 +293,10 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati > Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`. | `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`| | `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` | +| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` | | `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`| | `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` | -| `AI_MODEL` | Modèle IA à utiliser. | `gpt-4o-mini` | `str` | +| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`| | `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` | | `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` | @@ -316,6 +319,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_USERNAME=user@example.com CALDAV_PASSWORD=your_caldav_password +CALDAV_CALENDAR_PATH=/pronote-sync/ # Fenêtre de synchronisation (jours) SYNC_PAST_DAYS=7 @@ -331,9 +335,10 @@ XMPP_RECIPIENT=parent@example.com # --- IA (optionnelle) --- AI_ENABLED=true +AI_PROVIDER=openai AI_BASE_URL=https://api.openai.com/v1 AI_API_KEY=your_ai_api_key -AI_MODEL=gpt-4o-mini +# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut # --- Divers --- DRY_RUN=false @@ -1142,7 +1147,7 @@ Ajouter les variables suivantes dans la configuration : | **Variable** | **Description** | **Valeur par défaut** | **Type** | |----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------| -| `BLOG_RSS_ENABLED` | Activer la récupération du blog. | `False` | `bool` | +| `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` | | `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` | #### 5 bis.9.2 Modèle Pydantic pour la configuration du blog @@ -1177,7 +1182,7 @@ class Settings(BaseSettings): ```ini # --- Blog du collège --- -BLOG_RSS_ENABLED=true +BLOG_ENABLED=true BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 ``` diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 7edcc39..f4e865f 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -10,7 +10,7 @@ from __future__ import annotations from typing import Literal -from pydantic import SecretStr +from pydantic import Field, SecretStr, field_serializer from pydantic_settings import BaseSettings, SettingsConfigDict @@ -23,7 +23,7 @@ class PronoteSettings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_") - ical_url: str | None = None + ical_url: SecretStr | None = None username: str | None = None password: SecretStr | None = None ent: str | None = None @@ -31,6 +31,18 @@ class PronoteSettings(BaseSettings): homework_source: Literal["auto", "ical", "pronotepy"] = "auto" messages_source: Literal["pronotepy"] = "pronotepy" + @field_serializer("ical_url") + def _serialize_ical_url(self, value: SecretStr | None) -> str | None: + """Masque l'URL iCal lors de la sérialisation (repr, str, JSON). + + :param value: Valeur du champ ``ical_url``. + :return: ``"**********"`` si la valeur est définie, ``None`` sinon. + :rtype: str | None + """ + if value is None: + return None + return "**********" + class CalDAVSettings(BaseSettings): """Paramètres d'accès au serveur CalDAV de destination. @@ -121,12 +133,9 @@ class Settings(BaseSettings): 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() + pronote: PronoteSettings = Field(default_factory=PronoteSettings) + caldav: CalDAVSettings = Field(default_factory=CalDAVSettings) + xmpp: XmppSettings = Field(default_factory=XmppSettings) + ai: AISettings = Field(default_factory=AISettings) + blog: BlogSettings = Field(default_factory=BlogSettings) + app: AppSettings = Field(default_factory=AppSettings) diff --git a/pronote_sync/utils/redaction.py b/pronote_sync/utils/redaction.py index ff920a5..08e5aa6 100644 --- a/pronote_sync/utils/redaction.py +++ b/pronote_sync/utils/redaction.py @@ -24,7 +24,11 @@ _SENSITIVE_QUERY_KEYS = frozenset( "authorization", } ) -_URL_PATTERN = re.compile(r"https?://[^\s]+") +_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE) +_AUTH_HEADER_PATTERN = re.compile( + r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*", + re.IGNORECASE, +) _ISOLATED_SECRET_PATTERN = re.compile( r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)" r"(\s*['\"]?\s*[:=]\s*)" @@ -40,8 +44,8 @@ def redact_url(url: str) -> str: """Masque les identifiants et les paramètres sensibles d'une URL. Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``) - sont masquées, ainsi que les paramètres sensibles de la requête - (ex: ``icalsecurise``). + sont entièrement masquées (utilisateur et mot de passe), ainsi que les + paramètres sensibles de la requête (ex: ``icalsecurise``). :param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``). :return: URL avec les éléments sensibles remplacés par ``REDACTED``, @@ -51,15 +55,13 @@ def redact_url(url: str) -> str: try: parts = urlsplit(url) if parts.username is not None or parts.password is not None: - # Netloc sûr : utilisateur:REDACTED@hôte:port. Le deux-point est - # conservé même en l'absence de mot de passe explicite. - userinfo = parts.username or "" - userinfo += ":REDACTED" + # Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de + # passe sont entièrement masqués. host = parts.hostname or "" if parts.port is not None: - netloc = f"{userinfo}@{host}:{parts.port}" + netloc = f"{_REDACTED}@{host}:{parts.port}" else: - netloc = f"{userinfo}@{host}" + netloc = f"{_REDACTED}@{host}" parts = parts._replace(netloc=netloc) query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True) redacted_query = [ @@ -74,15 +76,18 @@ def redact_url(url: str) -> str: 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`` ou ``cle:valeur`` (ex: ``icalsecurise=XXX``, - ``"token": "XXX"``) sont masquées, sans distinction de casse. + Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes + d'authentification (``Authorization``, ``Proxy-Authorization``) et les + affectations isolées de type ``cle=valeur`` ou ``cle:valeur`` + (ex: ``icalsecurise=XXX``, ``"token": "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) + redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted) return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..93b64ea --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,119 @@ +"""Tests unitaires pour la configuration et le chargement des secrets. + +Ce module vérifie que la configuration est chargée correctement depuis les +variables d'environnement, que les secrets sont masqués dans les représentations +textuelles et sérialisées, et que le rechargement fonctionne comme attendu. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pydantic import SecretStr + +from pronote_sync.config.env import load_settings +from pronote_sync.config.settings import PronoteSettings, Settings + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None: + """Vérifie que les variables d'environnement avec préfixe sont chargées correctement. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("PRONOTE_USERNAME", "testuser") + settings = load_settings() + assert settings.pronote.username == "testuser" + + +def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None: + """Vérifie que le rechargement de la configuration lit les nouvelles valeurs. + + Ce test garantit que les champs configurés avec ``default_factory`` créent + des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi + un rechargement dynamique des variables d'environnement. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + # Premier chargement (valeur par défaut None) + settings1 = load_settings() + assert settings1.pronote.username is None + + # Modification de l'environnement + monkeypatch.setenv("PRONOTE_USERNAME", "newuser") + + # Rechargement + settings2 = load_settings() + assert settings2.pronote.username == "newuser" + + +def test_secretstr_masking_ical_url() -> None: + """Vérifie que l'URL iCal contenant un secret est masquée dans les représentations. + + :return: None + """ + ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret + settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url))) + + # Vérification de la représentation string + str_repr = str(settings) + assert "SECRET_TOKEN" not in str_repr + assert "**********" in str_repr + + # Vérification de la représentation repr + repr_repr = repr(settings) + assert "SECRET_TOKEN" not in repr_repr + assert "**********" in repr_repr + + # Vérification de la sérialisation JSON + json_str = settings.model_dump_json() + assert "SECRET_TOKEN" not in json_str + assert "**********" in json_str + + +def test_secretstr_masking_password() -> None: + """Vérifie que le mot de passe est masqué dans les représentations. + + :return: None + """ + password = "mypass123" # pragma: allowlist secret + settings = Settings(pronote=PronoteSettings(password=SecretStr(password))) + + # Vérification de la représentation string + str_repr = str(settings) + assert "mypass123" not in str_repr + assert "**********" in str_repr + + # Vérification de la représentation repr + repr_repr = repr(settings) + assert "mypass123" not in repr_repr + assert "**********" in repr_repr + + # Vérification de la sérialisation JSON + json_str = settings.model_dump_json() + assert "mypass123" not in json_str + assert "**********" in json_str + + +def test_no_singleton_import() -> None: + """Vérifie que l'import du singleton settings lève une ImportError. + + Ce test garantit que le pattern singleton a été supprimé et que la + configuration doit être chargée explicitement via ``load_settings()``. + + :return: None + """ + with pytest.raises(ImportError): + # Attempt to import a non-existent attribute to trigger ImportError + # as required by the spec + from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401 + settings, + ) + + +# Ensure trailing newline diff --git a/tests/unit/test_redaction.py b/tests/unit/test_redaction.py new file mode 100644 index 0000000..e220d0f --- /dev/null +++ b/tests/unit/test_redaction.py @@ -0,0 +1,128 @@ +"""Tests unitaires pour les utilitaires de masquage des secrets. + +Ce module vérifie que les fonctions de masquage des URLs, des en-têtes +et des secrets dans les textes fonctionnent correctement, sans fuite +d'informations sensibles. +""" + +from __future__ import annotations + +from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url + + +def test_redact_url_with_sensitive_query_param() -> None: + """Vérifie que les paramètres sensibles dans une URL sont masqués. + + :return: None + """ + url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret + redacted = redact_url(url) + assert "TOKEN123" not in redacted + assert "REDACTED" in redacted + + +def test_redact_url_with_embedded_auth() -> None: + """Vérifie que les informations d'authentification intégrées dans une URL sont masquées. + + :return: None + """ + url = "https://user:pass@host/path" # pragma: allowlist secret + redacted = redact_url(url) + assert "user" not in redacted + assert "pass" not in redacted + assert "REDACTED" in redacted + + +def test_redact_url_with_uppercase_scheme() -> None: + """Vérifie que les URLs avec un schéma en majuscules sont traitées correctement. + + :return: None + """ + url = "HTTPS://user:pass@host/path" # pragma: allowlist secret + redacted = redact_url(url) + assert "user" not in redacted + assert "pass" not in redacted + assert "REDACTED" in redacted + + +def test_redact_secrets_authorization_bearer() -> None: + """Vérifie que les en-têtes Authorization: Bearer sont masqués. + + :return: None + """ + text = "Authorization: Bearer mytoken123" # pragma: allowlist secret + redacted = redact_secrets(text) + assert "mytoken123" not in redacted + assert "REDACTED" in redacted + + +def test_redact_secrets_proxy_authorization_basic() -> None: + """Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués. + + :return: None + """ + text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret + redacted = redact_secrets(text) + assert "dXNlcjpwYXNz" not in redacted + assert "REDACTED" in redacted + + +def test_redact_secrets_lowercase_authorization() -> None: + """Vérifie que les en-têtes authorization en minuscules sont masqués. + + :return: None + """ + text = "authorization: Bearer tok" # pragma: allowlist secret + redacted = redact_secrets(text) + assert "tok" not in redacted + assert "REDACTED" in redacted + + +def test_redact_secrets_text_key_value() -> None: + """Vérifie que les affectations clé=valeur dans du texte sont masquées. + + :return: None + """ + text = "password=mysecret" # pragma: allowlist secret + redacted = redact_secrets(text) + assert "mysecret" not in redacted + assert "REDACTED" in redacted + + +def test_redact_secrets_json_key_value() -> None: + """Vérifie que les secrets dans du JSON sont masqués. + + :return: None + """ + text = '{"api_key": "myapikey"}' # pragma: allowlist secret + redacted = redact_secrets(text) + assert "myapikey" not in redacted + assert "REDACTED" in redacted + + +def test_redact_exception() -> None: + """Vérifie que les exceptions contenant des secrets sont masquées. + + :return: None + """ + exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret + redacted_msg = redact_exception(exc) + assert "leaked_token" not in redacted_msg + assert "REDACTED" in redacted_msg + + +def test_redact_url_preserves_host_and_path() -> None: + """Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage. + + :return: None + """ + url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret + redacted = redact_url(url) + assert "example.fr" in redacted + assert "/path" in redacted + assert "user" not in redacted + assert "pass" not in redacted + assert "tok" not in redacted + + +# Ensure trailing newline