Aus cj/security-baseline a04c20b übernommen: - scripts/security/mutation_check.py: Syntax-/Importfehler aus einer Mutation zählen nicht mehr als getötet ([UNGÜLTIG]); getötet nur bei echtem Testfehler (pytest, unittest, node:test spec und TAP), TODO-/SKIP-Tests sind kein Beleg. - Sicherheitstest-Vorlage: ungültige Eingaben müssen konfiguriert sein (Leerlauf-Sperre). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
6.8 KiB
Python
176 lines
6.8 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.
|
|
|
|
Getötet ist eine Mutation nur, wenn ein Test fehlschlägt (pytest FAILED, unittest FAIL,
|
|
node:test ✖ bzw. TAP "not ok") und im Fehlerbericht kein Ladefehler steht (Syntax-/Importfehler, siehe
|
|
LOAD_ERRORS). Rot durch kaputten Code zählt als UNGÜLTIG.
|
|
|
|
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 re
|
|
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 _tap_fail(stripped: str) -> bool:
|
|
# "not ok ... # TODO|SKIP": erwartet scheiternder Test (z. B. idorGap), macht den Lauf nicht rot
|
|
return stripped.startswith("not ok ") and not re.search(r"#\s*(TODO|SKIP)\b", stripped)
|
|
|
|
|
|
def _failed_lines(output: str) -> list[str]:
|
|
# pytest: "FAILED ...", unittest: "FAIL: ...", node:test: "✖ <Testname>" (ohne Zusammenfassung)
|
|
# bzw. im TAP-Format (ältere Node-Versionen, `tsx --test` ohne Terminal): "not ok <n> - <Testname>"
|
|
lines = []
|
|
for line in output.splitlines():
|
|
stripped = line.strip()
|
|
node_fail = stripped.startswith("✖") and "failing tests" not in line
|
|
node_fail = node_fail or _tap_fail(stripped)
|
|
# "FAILED (errors=1)" ist die Zusammenfassung von unittest, kein fehlgeschlagener Test.
|
|
pytest_fail = line.startswith("FAILED ") and not line.startswith("FAILED (")
|
|
if pytest_fail or line.startswith("FAIL:") or node_fail:
|
|
lines.append(stripped)
|
|
return lines
|
|
|
|
|
|
# Rot durch kaputten Code ist kein Testbeleg (2026-10-27): pytest meldet einen Ladefehler als
|
|
# ERROR ohne FAILED, node:test bricht alle Tests ab und markiert sie trotzdem mit ✖.
|
|
LOAD_ERRORS = (
|
|
"SyntaxError",
|
|
"IndentationError",
|
|
"TabError",
|
|
"ImportError",
|
|
"ModuleNotFoundError",
|
|
"error during collection",
|
|
"TransformError",
|
|
"ERR_MODULE_NOT_FOUND",
|
|
"Cannot find module",
|
|
"Cannot find package",
|
|
)
|
|
|
|
|
|
def _load_error(text: str) -> str | None:
|
|
return next((marker for marker in LOAD_ERRORS if marker in text), None)
|
|
|
|
|
|
def _failure_report(output: str, failed: list[str]) -> str:
|
|
# Nur der Fehlerbericht, nicht der ganze Lauf: Logs der App sind kein Ladefehler (Selftest
|
|
# 2026-10-27: Express loggt den JSON-SyntaxError von body-parser beim Test mit "{kaputt").
|
|
# node:test: Abschnitt "✖ failing tests:", TAP: die YAML-Blöcke unter "not ok" (App-Logs
|
|
# stehen dort als "# ..." außerhalb), pytest/unittest: die Fehlerzeilen selbst.
|
|
_, sep, tail = output.partition("✖ failing tests:")
|
|
if sep:
|
|
return tail
|
|
tap, in_block = [], False
|
|
for line in output.splitlines():
|
|
if _tap_fail(line.strip()):
|
|
in_block = True
|
|
if in_block:
|
|
tap.append(line)
|
|
in_block = line.strip() != "..."
|
|
return "\n".join(tap) if tap else "\n".join(failed)
|
|
|
|
|
|
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)
|
|
output = result.stdout + result.stderr # unittest schreibt auf stderr
|
|
failed = _failed_lines(output)
|
|
load_error = _load_error(_failure_report(output, failed))
|
|
if result.returncode == 0:
|
|
print(f"[ÜBERLEBT] {name}: Tests bleiben grün")
|
|
survivors.append(name)
|
|
elif load_error or not failed:
|
|
# Ohne Fehlerzeile (pytest: nur ERROR beim Sammeln) den Grund aus dem ganzen Lauf nennen.
|
|
load_error = load_error or _load_error(output)
|
|
why = f"Ladefehler ({load_error})" if load_error else "kein Testfehler erkennbar"
|
|
print(f"[UNGÜLTIG] {name}: Tests rot, aber {why}, kein Testbeleg.")
|
|
print(" Mutation so ändern, dass der Code lauffähig bleibt:")
|
|
print(output[-800:])
|
|
survivors.append(name)
|
|
else:
|
|
print(f"[GETÖTET] {name}: {failed[0]}")
|
|
|
|
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()))
|