fix(M2): configuration, secrets et rédaction — correction des écarts FIXME_M2

Sécurité :
- ical_url : str → SecretStr | None (masquage dans str/repr/model_dump_json)
- En-têtes Authorization/Proxy-Authorization : masquage complet de la valeur
- _URL_PATTERN : insensible à la casse (HTTPS:// reconnu)
- redact_url() : masquage du nom d'utilisateur (userinfo complet)

Configuration :
- Sous-configs : Field(default_factory=...) pour rechargement à chaque appel
- Suppression du singleton settings (injection de dépendances)
- .env.example : ajout AI_PROVIDER et CALDAV_CALENDAR_PATH
- Guide : BLOG_RSS_ENABLED → BLOG_ENABLED, AI_MODEL=None par défaut
- .secrets.baseline : ligne décalée 5112 → 5117 (faux positif audité)
- pre-commit : ajout de pytest aux additional_dependencies du hook mypy

Tests : 15 tests (config + redaction) couvrant rechargement, masquage
SecretStr, en-têtes Authorization, URL auth intégrée et casse variable.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 12:35:55 +02:00
parent a0ac75cec6
commit 2f6184df0b
8 changed files with 298 additions and 30 deletions

View File

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

View File

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