leistungsbilanz-ts/scripts/security/mutation_check.py
Grovy311 a20b6230db fix(security): mutation_check ohne veralteten Bytecode
Gleich lange Mutationen (z. B. extra="forbid" -> "ignore") liefen mit altem .pyc, weil
Python den Cache nur über mtime in Sekunden und Größe prüft. Jetzt PYTHONDONTWRITEBYTECODE
und Cache-Löschung nach jedem Schreiben (cj/security-baseline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:31:47 +02:00

96 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Mutationsprüfung für Sicherheitstests.
Baut gezielt je eine Schutzfunktion aus (Textersetzung), lässt die Tests laufen und
erwartet ROT. Danach wird die Datei immer wiederhergestellt. Überlebt eine Mutation
(Tests bleiben grün), prüfen die Tests diese Schutzfunktion nicht wirklich.
Aufruf: mutation_check.py <mutations.toml>
Exit: 0 = jede Mutation hat mindestens einen Test rot gemacht, 1 = sonst
Format (Pfade relativ zur TOML-Datei):
test_cmd = [".venv/bin/python", "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider"]
workdir = "backend"
[[mutation]]
name = "Objektberechtigung entfernt"
file = "backend/app/main.py"
old = "if project is None or not can_access(user, project):"
new = "if project is None:"
"""
from __future__ import annotations
import os
import subprocess # nosec B404 - Tests starten ist der Zweck des Skripts
import sys
import tomllib
from pathlib import Path
# Python prüft .pyc nur über Quell-mtime (Sekunden) und Größe. Eine gleich lange Mutation,
# die in derselben Sekunde geschrieben oder zurückgesetzt wird, liefe sonst mit dem alten
# Bytecode: Mutation wirkt nicht bzw. bleibt nach dem Lauf aktiv (llm-server, 2026-10-17).
TEST_ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
def _write(target: Path, text: str) -> None:
target.write_text(text, encoding="utf-8")
if target.suffix == ".py":
for pyc in (target.parent / "__pycache__").glob(f"{target.stem}.*.pyc"):
pyc.unlink(missing_ok=True)
def run(config_path: Path) -> int:
base = config_path.parent
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
test_cmd = config["test_cmd"]
workdir = base / config.get("workdir", ".")
mutations = config.get("mutation", [])
if not mutations:
print("Keine Mutationen definiert.")
return 1
# Vorbedingung: ohne Mutation muss alles grün sein, sonst beweist Rot nichts.
baseline = subprocess.run(test_cmd, cwd=workdir, capture_output=True, text=True, env=TEST_ENV) # noqa: S603 # nosec B603
if baseline.returncode != 0:
print("Tests sind schon ohne Mutation rot – erst reparieren.\n")
print(baseline.stdout[-3000:])
return 1
survivors = []
for mutation in mutations:
target = base / mutation["file"]
original = target.read_text(encoding="utf-8")
count = original.count(mutation["old"])
if count != 1:
print(f"[FEHLER] {mutation['name']}: Suchtext {count}x gefunden (erwartet 1) – Mutation veraltet")
survivors.append(mutation["name"])
continue
try:
_write(target, original.replace(mutation["old"], mutation["new"]))
result = subprocess.run(test_cmd, cwd=workdir, capture_output=True, text=True, env=TEST_ENV) # noqa: S603 # nosec B603
finally:
_write(target, original)
if result.returncode == 0:
print(f"[ÜBERLEBT] {mutation['name']}: Tests bleiben grün")
survivors.append(mutation["name"])
else:
# pytest: "FAILED ...", node:test: "✖ <Testname>" (Suiten und Zusammenfassung ausblenden)
failed = [
line.strip()
for line in result.stdout.splitlines()
if line.startswith("FAILED") or (line.startswith(" ✖") and "failing tests" not in line)
]
reason = failed[0] if failed else "Tests rot – Ursache prüfen (Syntaxfehler?):\n" + result.stdout[-800:]
print(f"[GETÖTET] {mutation['name']}: {reason}")
print(f"\n{len(mutations) - len(survivors)}/{len(mutations)} Mutationen erkannt.")
return 1 if survivors else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
sys.exit(run(Path(sys.argv[1]).resolve()))