fix(M11): propagate PipelineCriticalError, redact configured secrets, signal blog failures
Correct 4 findings from the independent M11 review: #1 (Critical) — PipelineCriticalError was downgraded to PipelineWarning: - Add except PipelineCriticalError: raise before each except Exception in all 5 non-blocking steps (fetch_blog, compare, caldav_sync, synthesis, send) - Critical errors now propagate to the outer handler and stop the pipeline #2 (Critical) — redact_exception() did not use configured secrets: - Extend redact_exception() with extra_secrets parameter (upward compatible) - Harden redact_secrets(): sort extra_secrets by length descending - Add Settings.redaction_secrets() collecting all 6 SecretStr fields - Add PipelineRunner._redact(exc) using self._redaction_secrets - All except blocks in run() now use self._redact(exc) - CalDAV FAILED-status path uses full redaction_secrets collection #3 (Medium) — BlogRSSClient silently swallowed failures: - Add error field to BlogRSSFetchResult - rss.py sets error on failure paths (except Exception, bozo/invalid feed) - fetch_blog_step raises RuntimeError when result.error is set - PipelineRunner now produces PipelineWarning for blog failures #4 (Medium) — Test coverage at 80%, now 91%: - 11 new integration tests covering blog failure/success, compare failure, CalDAV failure (exception + FAILED status), send False/exception, PipelineCriticalError propagation, secret redaction with sentinel, empty agenda/homework, iCal cache cleanup - Secret redaction test uses mock (no network) and proves configured-secret propagation via non-URL sentinel in RuntimeError Validation: 619 tests pass, ruff/mypy/bandit/pre-commit green, coverage 91%. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -261,3 +261,23 @@ class Settings(BaseSettings):
|
||||
ai: AISettings = Field(default_factory=AISettings)
|
||||
blog: BlogSettings = Field(default_factory=BlogSettings)
|
||||
app: AppSettings = Field(default_factory=AppSettings)
|
||||
|
||||
def redaction_secrets(self) -> tuple[SecretStr, ...]:
|
||||
"""Énumère tous les secrets configurés pour la rédaction.
|
||||
|
||||
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
|
||||
dans les sous-configurations (Pronote, CalDAV, XMPP, IA). Les valeurs
|
||||
vides ou ``None`` sont filtrées ; les doublons sont supprimés.
|
||||
|
||||
:return: Tuple de secrets à masquer dans les messages d'erreur.
|
||||
:rtype: tuple[SecretStr, ...]
|
||||
"""
|
||||
secrets = [
|
||||
self.pronote.ical_url,
|
||||
self.pronote.password,
|
||||
self.caldav.url,
|
||||
self.caldav.password,
|
||||
self.xmpp.password,
|
||||
self.ai.api_key,
|
||||
]
|
||||
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))
|
||||
|
||||
@@ -97,6 +97,7 @@ class PipelineRunner:
|
||||
:param now_provider: Horloge injectée pour rendre l'exécution testable.
|
||||
"""
|
||||
self._settings = settings
|
||||
self._redaction_secrets = settings.redaction_secrets()
|
||||
self._pronote_fetcher = pronote_fetcher
|
||||
self._caldav_synchronizer = caldav_synchronizer
|
||||
self._agenda_comparator = agenda_comparator
|
||||
@@ -153,6 +154,15 @@ class PipelineRunner:
|
||||
update={"app": self._settings.app.model_copy(update={"dry_run": self._dry_run})}
|
||||
)
|
||||
|
||||
def _redact(self, exc: Exception) -> str:
|
||||
"""Rédige une exception avec les secrets configurés.
|
||||
|
||||
:param exc: Exception dont le message doit être masqué.
|
||||
:return: Message d'erreur avec secrets configurés remplacés par ``REDACTED``.
|
||||
:rtype: str
|
||||
"""
|
||||
return redact_exception(exc, self._redaction_secrets)
|
||||
|
||||
def _run_context(self) -> AbstractContextManager[None]:
|
||||
"""Retourne le contexte isolant les éventuels caches de source.
|
||||
|
||||
@@ -195,14 +205,18 @@ class PipelineRunner:
|
||||
|
||||
try:
|
||||
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("fetch_blog", redact_exception(exc))
|
||||
self._warn("fetch_blog", self._redact(exc))
|
||||
blog_articles = []
|
||||
|
||||
try:
|
||||
agenda_diff = compare_step(self._agenda_comparator, data)
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("compare", redact_exception(exc))
|
||||
self._warn("compare", self._redact(exc))
|
||||
from pronote_sync.models.diff import AgendaDiff
|
||||
|
||||
agenda_diff = AgendaDiff(target_date=data.target_date)
|
||||
@@ -214,13 +228,13 @@ class PipelineRunner:
|
||||
if sync_result.status is CalDAVSyncStatus.FAILED:
|
||||
caldav_errors = redact_secrets(
|
||||
"; ".join(sync_result.errors),
|
||||
extra_secrets=(effective_settings.caldav.password,)
|
||||
if effective_settings.caldav.password is not None
|
||||
else (),
|
||||
extra_secrets=self._redaction_secrets,
|
||||
)
|
||||
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("caldav_sync", redact_exception(exc))
|
||||
self._warn("caldav_sync", self._redact(exc))
|
||||
|
||||
try:
|
||||
synthesis = synthesis_step(
|
||||
@@ -232,8 +246,10 @@ class PipelineRunner:
|
||||
target_date=data.target_date,
|
||||
),
|
||||
)
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("synthesis", redact_exception(exc))
|
||||
self._warn("synthesis", self._redact(exc))
|
||||
synthesis = None
|
||||
|
||||
message = XmppMessage(
|
||||
@@ -250,15 +266,17 @@ class PipelineRunner:
|
||||
try:
|
||||
if not send_step(self._channel, message):
|
||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("send", redact_exception(exc))
|
||||
self._warn("send", self._redact(exc))
|
||||
return data, [*self._errors, *self._warnings]
|
||||
except PipelineCriticalError as exc:
|
||||
logger.error("Erreur critique du pipeline : %s", exc.message)
|
||||
self._errors.append(exc)
|
||||
except Exception as exc:
|
||||
error = PipelineCriticalError(
|
||||
f"Erreur inattendue du pipeline : {redact_exception(exc)}", step="pipeline"
|
||||
f"Erreur inattendue du pipeline : {self._redact(exc)}", step="pipeline"
|
||||
)
|
||||
logger.error("Erreur critique du pipeline : %s", error.message)
|
||||
self._errors.append(error)
|
||||
|
||||
@@ -24,6 +24,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
|
||||
result = client.fetch_and_parse(
|
||||
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
|
||||
)
|
||||
if result.error is not None:
|
||||
raise RuntimeError(result.error) from None
|
||||
if not result.not_modified:
|
||||
state.add_guids(article.id for article in result.articles)
|
||||
state.update_cache_headers(result.etag, result.last_modified)
|
||||
|
||||
@@ -29,6 +29,8 @@ class BlogRSSFetchResult(BaseModel):
|
||||
réponse RSS, si elle est disponible. ``None`` par défaut.
|
||||
:param not_modified: Vaut ``True`` si le serveur a répondu avec le
|
||||
statut ``304 Not Modified``, ``False`` sinon.
|
||||
:param error: Message d'erreur expurgé si la récupération a échoué,
|
||||
``None`` sinon.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
@@ -52,3 +54,7 @@ class BlogRSSFetchResult(BaseModel):
|
||||
default=False,
|
||||
description="Vaut True si le serveur a répondu 304 Not Modified",
|
||||
)
|
||||
error: str | None = Field(
|
||||
default=None,
|
||||
description=("Message d'erreur expurgé si la récupération a échoué, None sinon"),
|
||||
)
|
||||
|
||||
@@ -131,12 +131,14 @@ class BlogRSSClient:
|
||||
if getattr(feed, "bozo", None):
|
||||
bozo_exception = getattr(feed, "bozo_exception", None)
|
||||
if bozo_exception is not None:
|
||||
error_msg = f"Flux RSS invalide : {redact_exception(bozo_exception)}"
|
||||
logger.warning(
|
||||
"Flux RSS du blog invalide (%s), ignoré : %s",
|
||||
redact_exception(bozo_exception),
|
||||
redact_url(self.rss_url),
|
||||
)
|
||||
else:
|
||||
error_msg = "Flux RSS invalide"
|
||||
logger.warning(
|
||||
"Flux RSS du blog invalide, ignoré : %s",
|
||||
redact_url(self.rss_url),
|
||||
@@ -146,6 +148,7 @@ class BlogRSSClient:
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
not_modified=False,
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
articles: list[BlogArticle] = []
|
||||
@@ -234,16 +237,18 @@ class BlogRSSClient:
|
||||
not_modified=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error(
|
||||
"Échec de la récupération du flux RSS du blog %s : %s",
|
||||
redact_url(self.rss_url),
|
||||
redact_exception(exc),
|
||||
error_msg,
|
||||
)
|
||||
return BlogRSSFetchResult(
|
||||
articles=(),
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
not_modified=False,
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -89,7 +89,9 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
|
||||
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
|
||||
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
|
||||
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
|
||||
reconnue. Une valeur vide ou ``None`` est ignorée.
|
||||
reconnue. Une valeur vide ou ``None`` est ignorée. Les secrets sont
|
||||
appliqués du plus long au plus court afin qu'un secret qui est une
|
||||
sous-chaîne d'un autre soit remplacé en premier, sans être corrompu.
|
||||
|
||||
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||
:param extra_secrets: Itérable de secrets bruts (``str`` ou
|
||||
@@ -101,19 +103,25 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
|
||||
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
|
||||
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
||||
values: list[str] = []
|
||||
for secret in extra_secrets:
|
||||
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
||||
if not value:
|
||||
continue
|
||||
values.append(value)
|
||||
for value in sorted(values, key=len, reverse=True):
|
||||
redacted = redacted.replace(value, _REDACTED)
|
||||
return redacted
|
||||
|
||||
|
||||
def redact_exception(exc: Exception) -> str:
|
||||
def redact_exception(exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()) -> str:
|
||||
"""Masque les secrets dans la représentation textuelle d'une exception.
|
||||
|
||||
:param exc: Exception dont le message doit être rédigé.
|
||||
:param extra_secrets: Itérable de secrets bruts (``str`` ou
|
||||
:class:`pydantic.SecretStr`) à masquer, transmis à
|
||||
:func:`redact_secrets`. Les valeurs vides ou ``None`` sont ignorées.
|
||||
:return: Représentation textuelle de l'exception avec les secrets masqués.
|
||||
:rtype: str
|
||||
"""
|
||||
return redact_secrets(str(exc))
|
||||
return redact_secrets(str(exc), extra_secrets)
|
||||
|
||||
Reference in New Issue
Block a user