Add agent security skills for Codex and Claude Code
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>
This commit is contained in:
parent
431f9ff059
commit
67476ea59b
68 changed files with 6600 additions and 0 deletions
262
.agents/skills/dependency-vetting/scripts/vet_deps.py
Normal file
262
.agents/skills/dependency-vetting/scripts/vet_deps.py
Normal 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:]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue