Add test and security baseline

- make quick/check/mutation from cj/security-baseline (Makefile unchanged,
  settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint
  security rules, Forgejo workflow
- tests/security: generic baseline tests plus app config and role tests
  against createApp() with an in-memory database
- extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests
- fix gaps found by the tests: reject unknown fields on project create,
  answer malformed JSON with 400 instead of 500, set nosniff on the API
- npm audit fix (next 16.4.1 and others, no high/critical left)
- clear ESLint errors, safeFileName without control-character regex
- per-project ownership stays an open, documented gap (idorGap -> TODO)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-10-15 09:40:33 +02:00
parent 67476ea59b
commit a7a1595cbf
38 changed files with 5852 additions and 442 deletions

View file

@ -0,0 +1,83 @@
#!/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 subprocess # nosec B404 - Tests starten ist der Zweck des Skripts
import sys
import tomllib
from pathlib import Path
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) # 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:
target.write_text(original.replace(mutation["old"], mutation["new"]), encoding="utf-8")
result = subprocess.run(test_cmd, cwd=workdir, capture_output=True, text=True) # noqa: S603 # nosec B603
finally:
target.write_text(original, encoding="utf-8")
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()))

View file

@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Prüft neue Pakete vor dem Einbau anhand der Registry-Metadaten.
Aufruf: vet_deps.py pypi:fastapi npm:zod npm:@scope/name
Exit: 0 = alles OK, 1 = mindestens eine Warnung / nicht prüfbar, 2 = Paket existiert nicht
Nur Standardbibliothek, damit es ohne venv und in jeder CI läuft.
"""
from __future__ import annotations
import difflib
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
MIN_AGE_DAYS = 90
MIN_RELEASES = 3
MIN_MONTHLY_DOWNLOADS = 1000
TIMEOUT = 15
# Bekannte Pakete als Referenz für Tippfehler-Namen (Typosquatting).
POPULAR = {
"pypi": [
"requests",
"fastapi",
"pydantic",
"uvicorn",
"starlette",
"httpx",
"sqlalchemy",
"django",
"flask",
"numpy",
"pandas",
"scipy",
"pytest",
"boto3",
"urllib3",
"cryptography",
"pyjwt",
"python-jose",
"passlib",
"bcrypt",
"jinja2",
"pyyaml",
"python-dotenv",
"alembic",
"psycopg2",
"psycopg",
"redis",
"celery",
"aiohttp",
"beautifulsoup4",
"lxml",
"pillow",
"openai",
"anthropic",
"langchain",
"rich",
"typer",
"click",
"setuptools",
"wheel",
"pip",
"matplotlib",
"selenium",
"playwright",
"paramiko",
"python-multipart",
"itsdangerous",
"authlib",
],
"npm": [
"react",
"react-dom",
"next",
"express",
"vue",
"axios",
"lodash",
"zod",
"typescript",
"vite",
"vitest",
"jest",
"eslint",
"prettier",
"webpack",
"dotenv",
"cors",
"helmet",
"jsonwebtoken",
"bcrypt",
"bcryptjs",
"mongoose",
"pg",
"mysql2",
"better-sqlite3",
"drizzle-orm",
"prisma",
"@prisma/client",
"tailwindcss",
"date-fns",
"dayjs",
"moment",
"uuid",
"nanoid",
"chalk",
"commander",
"yargs",
"ws",
"socket.io",
"node-fetch",
"cross-env",
"nodemon",
"tsx",
"esbuild",
"rollup",
"pino",
"winston",
"express-rate-limit",
"openid-client",
"passport",
"multer",
"sharp",
"playwright",
],
}
def fetch_json(url: str) -> dict | None:
"""Liefert JSON oder None bei 404. Andere Fehler werden weitergereicht."""
if not url.startswith("https://"):
raise ValueError(f"nur https erlaubt: {url}")
# Schema oben auf https begrenzt, Hosts stehen fest im Code.
req = urllib.request.Request(url, headers={"User-Agent": "vet-deps/1.0"}) # noqa: S310 # nosec B310
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: # noqa: S310 # nosec B310
return json.load(resp)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise
def parse_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def similar_to(ecosystem: str, name: str) -> str | None:
base = name.lower()
popular = POPULAR[ecosystem]
if base in popular:
return None
match = difflib.get_close_matches(base, popular, n=1, cutoff=0.85)
return match[0] if match else None
def check_pypi(name: str) -> tuple[str, list[str]]:
data = fetch_json(f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json")
if data is None:
return "EXISTIERT NICHT", ["kein Paket mit diesem Namen auf PyPI"]
warnings: list[str] = []
releases = {v: files for v, files in data.get("releases", {}).items() if files}
uploads = [parse_time(f["upload_time_iso_8601"]) for files in releases.values() for f in files]
if uploads:
age = (datetime.now(UTC) - min(uploads)).days
if age < MIN_AGE_DAYS:
warnings.append(f"erstes Release vor {age} Tagen")
else:
warnings.append("keine hochgeladenen Dateien")
if len(releases) < MIN_RELEASES:
warnings.append(f"nur {len(releases)} Release(s)")
info = data.get("info", {})
urls = info.get("project_urls") or {}
if not urls and not info.get("home_page"):
warnings.append("kein Repository-/Homepage-Link")
if info.get("yanked"):
warnings.append("aktuelle Version ist yanked")
try:
stats = fetch_json(f"https://pypistats.org/api/packages/{urllib.parse.quote(name.lower())}/recent")
monthly = (stats or {}).get("data", {}).get("last_month")
if monthly is not None and monthly < MIN_MONTHLY_DOWNLOADS:
warnings.append(f"nur {monthly} Downloads im letzten Monat")
except (urllib.error.URLError, TimeoutError, ValueError):
warnings.append("Download-Zahlen nicht abrufbar")
return ("WARNUNG" if warnings else "OK"), warnings
def check_npm(name: str) -> tuple[str, list[str]]:
encoded = urllib.parse.quote(name, safe="@")
data = fetch_json(f"https://registry.npmjs.org/{encoded}")
if data is None or "versions" not in data:
return "EXISTIERT NICHT", ["kein Paket mit diesem Namen auf npm"]
warnings: list[str] = []
created = data.get("time", {}).get("created")
if created:
age = (datetime.now(UTC) - parse_time(created)).days
if age < MIN_AGE_DAYS:
warnings.append(f"angelegt vor {age} Tagen")
versions = data.get("versions", {})
if len(versions) < MIN_RELEASES:
warnings.append(f"nur {len(versions)} Version(en)")
latest = data.get("dist-tags", {}).get("latest")
latest_meta = versions.get(latest, {}) if latest else {}
if latest_meta.get("deprecated"):
warnings.append("aktuelle Version ist deprecated")
if not data.get("repository") and not latest_meta.get("repository"):
warnings.append("kein Repository-Link")
scripts = latest_meta.get("scripts", {})
hooks = [s for s in ("preinstall", "install", "postinstall") if s in scripts]
if hooks:
warnings.append("Install-Skripte: " + ", ".join(hooks))
try:
dl = fetch_json(f"https://api.npmjs.org/downloads/point/last-month/{encoded}")
monthly = (dl or {}).get("downloads")
if monthly is not None and monthly < MIN_MONTHLY_DOWNLOADS:
warnings.append(f"nur {monthly} Downloads im letzten Monat")
except (urllib.error.URLError, TimeoutError, ValueError):
warnings.append("Download-Zahlen nicht abrufbar")
return ("WARNUNG" if warnings else "OK"), warnings
CHECKS = {"pypi": check_pypi, "npm": check_npm}
def main(argv: list[str]) -> int:
if not argv:
print(__doc__)
return 2
worst = 0
for spec in argv:
ecosystem, _, name = spec.partition(":")
if ecosystem not in CHECKS or not name:
print(f"{spec}: ungültig, Format ist pypi:<name> oder npm:<name>")
worst = max(worst, 2)
continue
try:
verdict, reasons = CHECKS[ecosystem](name)
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
verdict, reasons = "NICHT PRÜFBAR", [f"Registry nicht erreichbar: {exc}"]
lookalike = similar_to(ecosystem, name)
if lookalike:
reasons.append(f"Name ähnelt '{lookalike}' (Typosquat?)")
if verdict == "OK":
verdict = "WARNUNG"
print(f"{ecosystem}:{name}: {verdict}")
for reason in reasons:
print(f" - {reason}")
worst = max(
worst,
{"OK": 0, "WARNUNG": 1, "NICHT PRÜFBAR": 1, "EXISTIERT NICHT": 2}[verdict],
)
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))