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>
115 lines
4.1 KiB
Python
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()))
|