fix: plancher pypdf >= 6.16.1 - deux DoS de ressources bloques le job security BUG-093
CI / lint (push) Successful in 2m30s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 15m9s

pip-audit bloquait sur PYSEC-2026-3910 (outlines) et PYSEC-2026-3911 (XForm),
toutes deux atteignables via backend/pdf_reader.py. Le plancher pypdf>=4.0 ne
protégeait rien : l'image Act du runner embarque 6.16.0 dans sa toolcache
Python, donc pip répondait « already satisfied » sans jamais aligner.

Au passage, le garde-fou TestSemgrepStep était en régression depuis la
désactivation de semgrep (v2.39.9) et aurait rougi le job `test` : il vérifie
désormais que l'étape n'exécute que son avertissement et que bandit et
pip-audit restent bloquants. Nouveau TestDependencySecurityFloors pour
verrouiller les planchers de sécurité (contre-preuve : pypdf remis à >=4.0).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-29 13:06:53 -04:00
parent 4de9ee038c
commit 856e654306
13 changed files with 228 additions and 21 deletions
+165 -6
View File
@@ -1,4 +1,4 @@
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083, BUG-091, BUG-093).
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
@@ -9,9 +9,29 @@ import re
from pathlib import Path
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
REQUIREMENTS = Path(__file__).resolve().parent.parent / "backend" / "requirements.txt"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _job_text(job: str) -> str:
"""Corps YAML du job `job` (jusqu'au job suivant ou à la fin du fichier)."""
text = CI_YML.read_text(encoding="utf-8")
start = text.index(f"\n {job}:")
rest = text[start + 1 :]
nxt = re.search(r"\n {2}[A-Za-z][A-Za-z0-9_-]*:\s*\n", rest)
return rest[: nxt.start()] if nxt else rest
def _steps(job: str) -> list[tuple[str, str]]:
"""[(nom d'étape, corps YAML)] pour un job donné."""
chunks = re.split(r"\n {6}- name: ", "\n" + _job_text(job))[1:]
steps = []
for chunk in chunks:
name, _, body = chunk.partition("\n")
steps.append((name.strip(), body))
return steps
def _run_bodies() -> list[tuple[int, str]]:
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
lines = CI_YML.read_text(encoding="utf-8").splitlines()
@@ -62,14 +82,99 @@ class TestRunnerProofScripts:
class TestSemgrepStep:
def test_semgrep_local_rules_enforced(self):
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
text = CI_YML.read_text(encoding="utf-8")
assert "semgrep --config semgrep-rules/ backend/" in text, (
"#87 T7 : étape semgrep locale attendue dans le job security"
"""BUG-091 : semgrep-core est inexécutable sur le runner (exit 127).
L'étape est donc désactivée (avertissement, non bloquante) au lieu d'être
supprimée : elle documente pourquoi, et se réactive telle quelle dès que le
runner dispose d'un CPU x86-64-v2.
"""
SEMGREP_STEP_PREFIX = "Semgrep"
def _semgrep_step(self) -> tuple[str, str]:
matches = [
(n, b) for n, b in _steps("security") if n.startswith(self.SEMGREP_STEP_PREFIX)
]
assert len(matches) == 1, (
"BUG-091 : une unique étape Semgrep (désactivée) attendue dans le "
f"job security, trouvé {len(matches)}"
)
return matches[0]
@staticmethod
def _run_commands(body: str) -> list[str]:
"""Commandes shell du bloc `run:` de l'étape (hors lignes vides)."""
m = re.search(r"^\s*run:\s*\|?\s*$", body, re.M)
assert m, "étape sans bloc `run:`"
rest = body[m.end() :]
lines: list[str] = []
for line in rest.splitlines():
if not line.strip():
continue
# le bloc run: est indenté de 2 spaces de plus que la clef
if len(line) - len(line.lstrip()) <= 8:
break
lines.append(line.strip())
return lines
def test_semgrep_step_does_not_execute_core(self):
"""Le core natif ne doit plus être lancé (exit 127 bloquant le job).
Seule commande admise : l'avertissement d'activation. Le message
mentionne voluntaryirement « semgrep » — c'est l'**exécution** qui
est interdite, pas le mot.
"""
name, body = self._semgrep_step()
commands = self._run_commands(body)
assert commands, f"BUG-091 : l'étape « {name} » n'a plus de commande"
for cmd in commands:
assert cmd.startswith('echo "::warning::'), (
f"BUG-091 : l'étape « {name} » ne doit exécuter qu'un avertissement, "
f"trouvé : {cmd!r}"
)
def test_semgrep_step_is_non_blocking_and_explains_itself(self):
"""Désactivée = `continue-on-error` + avertissement explicite."""
name, body = self._semgrep_step()
assert re.search(r"^\s*continue-on-error:\s*true\s*$", body, re.M), (
f"BUG-091 : l'étape « {name} » doit porter continue-on-error: true"
)
assert "::warning::" in body, (
f"BUG-091 : l'étape « {name} » doit émettre un ::warning:: "
"expliquant la désactivation"
)
assert "BUG-091" in body, (
f"BUG-091 : l'étape « {name} » doit référencer BUG-091"
)
def test_bandit_and_pip_audit_stay_blocking(self):
"""La désactivation de semgrep ne doit rien dégraver d'autre (#87)."""
found = {}
for name, body in _steps("security"):
low = name.lower()
if low.startswith("bandit"):
found["bandit"] = body
elif low.startswith("pip-audit"):
found["pip-audit"] = body
assert set(found) == {"bandit", "pip-audit"}, (
f"étapes Bandit et Pip-audit attendues dans le job security, "
f"trouvé {sorted(found)}"
)
for tool, body in found.items():
assert "continue-on-error: true" not in body, (
f"BUG-091 : l'étape {tool} doit rester bloquante (#87)"
)
def test_semgrep_rules_still_shipped_and_documented(self):
"""Les règles locales restent versionnées et documentées (#87 T7)."""
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
assert rules.exists(), "ruleset semgrep manquant"
text = CI_YML.read_text(encoding="utf-8")
# La commande locale est documentée (commentaire de l'étape), pas exécutée.
assert re.search(r"semgrep --config semgrep-rules/\s*\n?\s*#?\s*backend/", text), (
"#87 T7 : commande locale `semgrep --config semgrep-rules/ backend/` "
"attendue en commentaire dans le workflow"
)
class TestFrontendStepsHaveTheirDeps:
@@ -113,3 +218,57 @@ class TestFrontendStepsHaveTheirDeps:
f"BUG-082 : `{suite}` attendu dans les deux branches de "
"l'étape JSDOM"
)
_SPEC_RE = re.compile(
r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(>=|==|~=|>|<)\s*([0-9][^\s;#]*)"
)
def _floor(pkg: str) -> tuple[int, ...] | None:
"""Plancher `>=` déclaré pour `pkg` dans backend/requirements.txt."""
for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _SPEC_RE.match(line)
if not m or m.group(1).lower() != pkg or m.group(2) != ">=":
continue
return tuple(int(p) for p in re.match(r"[0-9]+(?:\.[0-9]+)*", m.group(3)).group(0).split("."))
return None
class TestDependencySecurityFloors:
"""Planchers de sécurité des dépendances (#87, BUG-091, BUG-093).
`pip-audit` est bloquant dans le job `security`. Comme l'image du runner
(`catthehacker/ubuntu:act-latest`) embarque des paquets *préinstallés* dans
sa toolcache Python, un plancher trop bas est « already satisfied » et
n'est jamais mis à niveau : c'est exactement ce qui a fait échouer le
job sur pypdf 6.16.0 (PYSEC-2026-3910 / PYSEC-2026-3911, DoS de ressources
atteignables via backend/pdf_reader.py).
"""
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
}
def test_security_floors_are_declared(self):
missing = [p for p in self.FLOORS if _floor(p) is None]
assert not missing, (
"plancher `>=` manquant dans backend/requirements.txt pour : "
f"{missing}"
)
def test_security_floors_are_high_enough(self):
too_low = {
p: (_floor(p), minimum)
for p, minimum in self.FLOORS.items()
if (_floor(p) or ()) < minimum
}
assert not too_low, (
"BUG-093 : plancher(s) sous le correctif de sécurité, "
f"le job `security` (pip-audit bloquant) échouerait : {too_low}"
)