"""
SkillSpector Web – Wrapper um die skillspector-CLI, geeignet für öffentlichen Betrieb.
Sicherheitsprinzipien:
- Führt NIEMALS den gescannten Skill aus (das macht skillspector selbst auch nicht).
- Nur http/https-URLs werden akzeptiert; Ziele, die auf private/interne Adressen
auflösen (SSRF-Schutz: localhost, 127.x, 10.x, 192.168.x, 172.16-31.x, link-local,
IPv6-Loopback/ULA), werden abgelehnt.
- Läuft standardmäßig mit --no-llm; LLM-Analyse ist nur nutzbar, wenn der Admin sie
explizit über ENABLE_LLM_OPTION freischaltet UND ein API-Key konfiguriert ist.
- Zugriff optional per Basic-Auth (REQUIRE_AUTH); bei öffentlichem Betrieb ohne Auth
greifen automatisch Rate-Limit und Concurrency-Limit.
- Jeder Scan läuft in einem eigenen, danach gelöschten Temp-Verzeichnis.
- Harte Zeit- und Upload-Größenbegrenzung.
"""
import ipaddress
import json
import os
import re
import shutil
import socket
import subprocess
import tempfile
import threading
import time
import urllib.request
from datetime import datetime
from functools import wraps
from pathlib import Path
from urllib.parse import urlparse
from flask import Flask, Response, render_template_string, request
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB Upload-Limit
SCAN_TMP_DIR = os.environ.get("SCAN_TMP_DIR", "/tmp/skillspector")
Path(SCAN_TMP_DIR).mkdir(parents=True, exist_ok=True)
REQUIRE_AUTH = os.environ.get("REQUIRE_AUTH", "true").lower() == "true"
WEBUI_USER = os.environ.get("WEBUI_USER", "admin")
WEBUI_PASSWORD = os.environ.get("WEBUI_PASSWORD")
SCAN_TIMEOUT = int(os.environ.get("SCAN_TIMEOUT_SECONDS", "300"))
MAX_CONCURRENT_SCANS = int(os.environ.get("MAX_CONCURRENT_SCANS", "2"))
MAX_SCANS_PER_HOUR_PER_IP = int(os.environ.get("MAX_SCANS_PER_HOUR_PER_IP", "10"))
LLM_KEY_PRESENT = bool(os.environ.get("OPENAI_API_KEY") or os.environ.get("ANTHROPIC_API_KEY"))
ENABLE_LLM_OPTION = os.environ.get("ENABLE_LLM_OPTION", "false").lower() == "true"
ALLOW_LLM = LLM_KEY_PRESENT and ENABLE_LLM_OPTION
# Für die deutsche Klartext-Zusammenfassung wird direkt die OpenRouter-kompatible
# Chat-API angesprochen (unabhängig von SkillSpectors eigener --llm-Option).
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1")
SUMMARY_MODEL = os.environ.get("SKILLSPECTOR_MODEL") or "openai/gpt-4o-mini"
GIT_URL_RE = re.compile(r"^https?://[^\s]+$")
_scan_semaphore = threading.Semaphore(MAX_CONCURRENT_SCANS)
_rate_lock = threading.Lock()
_rate_log: dict[str, list[float]] = {}
# --------------------------------------------------------------------------- #
# Auth
# --------------------------------------------------------------------------- #
def check_auth(username: str, password: str) -> bool:
return bool(WEBUI_PASSWORD) and username == WEBUI_USER and password == WEBUI_PASSWORD
def authenticate():
return Response(
"Zugriff verweigert.",
401,
{"WWW-Authenticate": 'Basic realm="SkillSpector"'},
)
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if not REQUIRE_AUTH:
return f(*args, **kwargs)
if not WEBUI_PASSWORD:
return Response(
"REQUIRE_AUTH=true, aber WEBUI_PASSWORD ist nicht gesetzt – "
"Zugriff aus Sicherheitsgründen gesperrt.",
500,
)
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
return decorated
# --------------------------------------------------------------------------- #
# Rate-Limiting (pro Client-IP, in-memory – reicht für einen einzelnen Prozess)
# --------------------------------------------------------------------------- #
def client_ip() -> str:
fwd = request.headers.get("X-Forwarded-For")
return fwd.split(",")[0].strip() if fwd else request.remote_addr or "unknown"
def rate_limited(ip: str) -> bool:
now = time.time()
with _rate_lock:
entries = [t for t in _rate_log.get(ip, []) if now - t < 3600]
if len(entries) >= MAX_SCANS_PER_HOUR_PER_IP:
_rate_log[ip] = entries
return True
entries.append(now)
_rate_log[ip] = entries
return False
# --------------------------------------------------------------------------- #
# SSRF-Schutz: nur öffentliche http(s)-Ziele erlauben
# --------------------------------------------------------------------------- #
def is_public_target(url: str) -> bool:
if not GIT_URL_RE.match(url):
return False
host = urlparse(url).hostname
if not host:
return False
if host.lower() == "localhost":
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
):
return False
return True
# --------------------------------------------------------------------------- #
# Rohreport lesbar aufbereiten: nach Severity gruppieren, Feldnamen
# vereinheitlichen (SkillSpector nennt Detail-Felder je Version leicht anders).
# --------------------------------------------------------------------------- #
SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
SEVERITY_LABELS_DE = {
"CRITICAL": "Kritisch",
"HIGH": "Hoch",
"MEDIUM": "Mittel",
"LOW": "Niedrig",
"INFO": "Info",
}
def normalize_issues(raw_issues: list) -> dict:
grouped: dict[str, list] = {}
for issue in raw_issues or []:
severity = str(issue.get("severity", "INFO")).upper()
location = issue.get("location") or {}
confidence = issue.get("confidence")
if isinstance(confidence, (int, float)) and confidence <= 1:
confidence_display = f"{round(confidence * 100)}%"
else:
confidence_display = confidence
grouped.setdefault(severity, []).append(
{
"id": issue.get("id") or issue.get("rule_id") or "?",
"category": issue.get("category") or issue.get("type") or "",
"message": (
issue.get("finding")
or issue.get("message")
or issue.get("description")
or issue.get("title")
or ""
),
"explanation": issue.get("explanation") or issue.get("rationale") or "",
"file": location.get("file") or location.get("path") or "",
"line": location.get("start_line") or location.get("line") or "",
"confidence": confidence_display,
}
)
ordered = {lvl: grouped[lvl] for lvl in SEVERITY_ORDER if lvl in grouped}
for lvl, items in grouped.items():
if lvl not in ordered:
ordered[lvl] = items
return ordered
def normalize_components(raw_components: list) -> list:
normalized = []
for c in raw_components or []:
normalized.append(
{
"file": c.get("path") or c.get("file") or c.get("name") or "-",
"type": c.get("type") or c.get("kind") or "-",
"lines": c.get("lines") or c.get("line_count") or "-",
"executable": bool(c.get("executable")),
}
)
return normalized
# --------------------------------------------------------------------------- #
# Deutsche Klartext-Zusammenfassung des Reports per LLM (OpenRouter-kompatibel)
# --------------------------------------------------------------------------- #
def generate_report_summary(result: dict) -> str | None:
if not OPENAI_API_KEY:
return None
risk = result.get("risk_assessment") or {}
issues = result.get("issues") or result.get("findings") or []
components = result.get("components") or []
other_fields = {
k: v
for k, v in result.items()
if k not in ("issues", "findings", "components", "risk_assessment")
}
compact = {
"score": risk.get("score"),
"severity": risk.get("severity"),
"recommendation": risk.get("recommendation"),
"total_issues": len(issues),
"scanned_files": [c.get("path") or c.get("file") for c in components][:15],
"other_metadata": other_fields,
"top_issues": [
{
"severity": i.get("severity"),
"category": i.get("category") or i.get("type"),
"message": i.get("finding") or i.get("message") or i.get("description"),
"file": (i.get("location") or {}).get("file") or (i.get("location") or {}).get("path"),
}
for i in issues[:20]
],
}
prompt = (
"Du bekommst das JSON-Ergebnis eines automatisierten Sicherheits-Scans für einen "
"AI-Agent-Skill. Schreibe auf Deutsch einen kurzen, laienverständlichen Bericht für "
"jemanden ohne Security-Hintergrund, als Fließtext in 4 kurzen Absätzen, getrennt "
"durch eine Leerzeile (keine Überschriften, keine Code-Blöcke, keine Aufzählungszeichen):\n"
"Absatz 1: Wofür dieser Skill laut den vorliegenden Daten (Dateinamen, Metadaten, "
"Fundtexte) gedacht ist – 1-2 Sätze. Falls der Zweck nicht erkennbar ist, schreibe "
"das ehrlich so.\n"
"Absatz 2: Gesamteinschätzung des Risikos in 1-2 Sätzen.\n"
"Absatz 3: Die wichtigsten Funde in einfachen Worten, ohne unerklärte Fachbegriffe.\n"
"Absatz 4: Konkrete Handlungsempfehlung (bedenkenlos nutzbar / mit Vorsicht "
"nutzbar / besser nicht installieren) und eine kurze Begründung warum.\n\n"
f"Scan-Ergebnis:\n{json.dumps(compact, ensure_ascii=False)}"
)
payload = json.dumps(
{
"model": SUMMARY_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 700,
}
).encode("utf-8")
req = urllib.request.Request(
f"{OPENAI_BASE_URL.rstrip('/')}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {OPENAI_API_KEY}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data["choices"][0]["message"]["content"].strip()
except Exception:
return None
# --------------------------------------------------------------------------- #
# Design im Stil von software.obraasch.de: dunkler Header, orange Akzente,
# helle Cards.
# --------------------------------------------------------------------------- #
PAGE = """
SkillSpector
SKILLSPECTOR
Sicherheits-Scanner für AI-Agent-Skills – vor dem Installieren prüfen
Die deutsche Zusammenfassung konnte nicht erstellt werden (KI-Anbieter nicht erreichbar oder Fehler). Der technische Bericht unten ist trotzdem vollständig.