Same skills in .agents/skills (Codex) and .claude/skills (Claude Code): codeguard, security-review, dependency-vetting, pre-release-gate, authz-idor-audit, threat-model-light. Source: cj/claude-skills. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
7.8 KiB
Python
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:]))
|