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