""" SkillSpector Web – schlanker Wrapper um die skillspector-CLI. Sicherheitsprinzipien: - Führt NIEMALS den gescannten Skill aus (das macht skillspector selbst auch nicht). - Nur http/https-URLs werden als Ziel akzeptiert (kein file://, keine lokalen Pfade). - Läuft standardmäßig mit --no-llm, damit keine Dateiinhalte nach außen gehen. - Jeder Scan läuft in einem eigenen, danach gelöschten Temp-Verzeichnis. - Zugriff nur mit Basic-Auth (Benutzername/Passwort aus Umgebungsvariablen). - Harte Zeit- und Upload-Größenbegrenzung, damit ein Scan den Container nicht blockieren kann. """ import json import os import re import shutil import subprocess import tempfile from functools import wraps from pathlib import Path 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) WEBUI_USER = os.environ.get("WEBUI_USER", "admin") WEBUI_PASSWORD = os.environ.get("WEBUI_PASSWORD") # MUSS über die Umgebung gesetzt werden SCAN_TIMEOUT = int(os.environ.get("SCAN_TIMEOUT_SECONDS", "300")) ALLOW_LLM = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")) GIT_URL_RE = re.compile(r"^https?://[^\s]+$") 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 WEBUI_PASSWORD: return Response( "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 PAGE = """
Scannt einen Skill (Git-Repo oder ZIP) auf Sicherheitsrisiken, bevor er installiert wird.
{{ result.risk_assessment.score }}/100 — {{ result.risk_assessment.severity }} — {{ result.risk_assessment.recommendation }}
{{ result_json }}
{% elif error %}
{{ error }}
{% endif %} """ @app.route("/", methods=["GET", "POST"]) @requires_auth def index(): if request.method == "GET": return render_template_string(PAGE, allow_llm=ALLOW_LLM, result=None, error=None) use_llm = ALLOW_LLM and request.form.get("use_llm") == "on" git_url = (request.form.get("git_url") or "").strip() zip_file = request.files.get("zip_file") work_dir = tempfile.mkdtemp(dir=SCAN_TMP_DIR) try: if zip_file and zip_file.filename: zip_path = Path(work_dir) / "upload.zip" zip_file.save(zip_path) target = str(zip_path) elif git_url: if not GIT_URL_RE.match(git_url): return render_template_string( PAGE, allow_llm=ALLOW_LLM, result=None, error="Nur http(s)-URLs sind erlaubt (kein file://, kein lokaler Pfad).", ) target = git_url else: return render_template_string( PAGE, allow_llm=ALLOW_LLM, result=None, error="Bitte eine Git-URL angeben oder eine ZIP-Datei hochladen.", ) report_path = Path(work_dir) / "report.json" cmd = ["skillspector", "scan", target, "--format", "json", "-o", str(report_path)] if not use_llm: cmd.append("--no-llm") proc = subprocess.run( cmd, cwd=work_dir, timeout=SCAN_TIMEOUT, capture_output=True, text=True, ) if report_path.exists(): result = json.loads(report_path.read_text()) return render_template_string( PAGE, allow_llm=ALLOW_LLM, result=result, result_json=json.dumps(result, indent=2, ensure_ascii=False), error=None, ) return render_template_string( PAGE, allow_llm=ALLOW_LLM, result=None, error=f"Scan fehlgeschlagen (Exit {proc.returncode}):\n{proc.stderr[-2000:]}", ) except subprocess.TimeoutExpired: return render_template_string( PAGE, allow_llm=ALLOW_LLM, result=None, error=f"Scan abgebrochen: Zeitlimit von {SCAN_TIMEOUT}s überschritten.", ) finally: shutil.rmtree(work_dir, ignore_errors=True) @app.route("/healthz") def healthz(): return {"status": "ok"}