fix(security): neutraliser le contexte des erreurs expurgées
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Régressions sur le contexte des exceptions expurgées."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pronote_sync.errors import PipelineCriticalError
|
||||
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.message import Message
|
||||
from pronote_sync.pipeline.steps.fetch import fetch_step
|
||||
|
||||
|
||||
class _FailingFetcher:
|
||||
"""Fetcher minimal qui expose une erreur externe porteuse d'un secret."""
|
||||
|
||||
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||
"""Déclenche une erreur externe pendant la récupération critique."""
|
||||
raise RuntimeError("password=fetch-context-secret")
|
||||
|
||||
def fetch_homework(self, target_date: date) -> list[Homework]:
|
||||
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||
return []
|
||||
|
||||
def fetch_messages(self) -> list[Message]:
|
||||
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||
return []
|
||||
|
||||
def fetch_informations(self) -> list[Message]:
|
||||
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||
return []
|
||||
|
||||
|
||||
def test_fetch_step_does_not_retain_external_exception_context() -> None:
|
||||
"""Vérifie qu'une erreur critique ne conserve ni secret ni contexte externe."""
|
||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||
fetch_step(_FailingFetcher())
|
||||
|
||||
error = exc_info.value
|
||||
formatted = "".join(traceback.format_exception(error))
|
||||
assert "fetch-context-secret" not in str(error)
|
||||
assert "fetch-context-secret" not in formatted
|
||||
assert error.__cause__ is None
|
||||
assert error.__context__ is None
|
||||
|
||||
|
||||
def test_production_raise_from_none_is_never_inside_except() -> None:
|
||||
"""Vérifie structurellement que les exceptions expurgées sont levées hors des handlers."""
|
||||
root = Path(__file__).parents[2] / "pronote_sync"
|
||||
violations: list[str] = []
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
"""Collecte les levées ``from None`` imbriquées dans un handler."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._inside_except = False
|
||||
|
||||
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
||||
"""Visite un handler en marquant son périmètre lexical."""
|
||||
previous = self._inside_except
|
||||
self._inside_except = True
|
||||
self.generic_visit(node)
|
||||
self._inside_except = previous
|
||||
|
||||
def visit_Raise(self, node: ast.Raise) -> None:
|
||||
"""Signale une levée ``from None`` dans un handler."""
|
||||
if (
|
||||
self._inside_except
|
||||
and isinstance(node.cause, ast.Constant)
|
||||
and node.cause.value is None
|
||||
):
|
||||
violations.append(f"{path}:{node.lineno}")
|
||||
self.generic_visit(node)
|
||||
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
Visitor().visit(ast.parse(path.read_text(encoding="utf-8")))
|
||||
|
||||
assert violations == []
|
||||
Reference in New Issue
Block a user