leistungsbilanz-ts/scripts/security/vet_deps.py
Grovy311 a7a1595cbf 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>
2026-10-15 09:40:33 +02:00

262 lines
7.8 KiB
Python

#!/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:]))