leistungsbilanz-ts/scripts/security/mutation_check.py
Grovy311 b0e8161918
Some checks are pending
security-baseline / secrets (push) Waiting to run
security-baseline / node (push) Waiting to run
security-baseline / semgrep (push) Waiting to run
security-baseline / trivy (push) Waiting to run
fix(security): mutation_check ohne veralteten Bytecode, ruff-fest
Stand cj/security-baseline: PYTHONDONTWRITEBYTECODE und Cache-Löschung nach jedem
Schreiben (gleich lange Mutationen liefen sonst mit altem .pyc), Testaufruf so
umbrochen, dass noqa/nosec bei 88 Zeichen auf der Aufrufzeile bleiben.

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

115 lines
4.1 KiB
Python

#!/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:"
""" # noqa: E501, RUF100
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, in derselben Sekunde geschrieben oder zurückgesetzt, liefe sonst mit altem
# Bytecode: sie wirkt nicht oder bleibt nach dem Lauf aktiv (llm-server, 2026-10-17).
TEST_ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
def _run_tests(test_cmd: list[str], workdir: Path) -> subprocess.CompletedProcess:
# Startet die Tests der App ohne Shell, Kommando aus deren Mutations-TOML.
return subprocess.run( # noqa: S603, RUF100 # nosec B603
test_cmd,
cwd=workdir,
capture_output=True,
text=True,
env=TEST_ENV,
check=False,
)
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 _failed_lines(stdout: str) -> list[str]:
# pytest: "FAILED ...", node:test: "✖ <Testname>" (ohne Suiten und Zusammenfassung)
lines = []
for line in stdout.splitlines():
node_fail = line.startswith(" ✖") and "failing tests" not in line
if line.startswith("FAILED") or node_fail:
lines.append(line.strip())
return lines
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 = _run_tests(test_cmd, workdir)
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:
name = mutation["name"]
target = base / mutation["file"]
original = target.read_text(encoding="utf-8")
count = original.count(mutation["old"])
if count != 1:
print(f"[FEHLER] {name}: Suchtext {count}x gefunden (erwartet 1), veraltet")
survivors.append(name)
continue
try:
_write(target, original.replace(mutation["old"], mutation["new"]))
result = _run_tests(test_cmd, workdir)
finally:
_write(target, original)
if result.returncode == 0:
print(f"[ÜBERLEBT] {name}: Tests bleiben grün")
survivors.append(name)
else:
failed = _failed_lines(result.stdout)
hint = "Tests rot, Ursache prüfen (Syntaxfehler?):\n"
reason = failed[0] if failed else hint + result.stdout[-800:]
print(f"[GETÖTET] {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()))