188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
"""
|
||
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 = """
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>SkillSpector</title>
|
||
<style>
|
||
body{font-family:sans-serif;max-width:820px;margin:40px auto;padding:0 16px;background:#111;color:#eee}
|
||
input,button{width:100%;padding:9px;margin:6px 0;box-sizing:border-box;font-size:1em}
|
||
input[type=checkbox]{width:auto;display:inline}
|
||
button{background:#2ecc71;color:#000;border:none;cursor:pointer;font-weight:bold;padding:10px}
|
||
pre{background:#000;padding:12px;overflow-x:auto;white-space:pre-wrap;border-radius:6px}
|
||
.score{font-size:1.8em;font-weight:bold;margin:12px 0}
|
||
.SAFE{color:#4caf50}.CAUTION{color:#ffb300}.DO_NOT_INSTALL{color:#f44336}
|
||
label{display:block;margin-top:14px;font-size:.9em;color:#bbb}
|
||
.err{color:#f44336;font-weight:bold}
|
||
hr{border-color:#333}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>🔍 SkillSpector</h1>
|
||
<p>Scannt einen Skill (Git-Repo oder ZIP) auf Sicherheitsrisiken, <strong>bevor</strong> er installiert wird.</p>
|
||
<form method="post" enctype="multipart/form-data">
|
||
<label>Git-Repository-URL (nur http/https)</label>
|
||
<input type="text" name="git_url" placeholder="https://git.obraasch.de/olli1986/mein-skill">
|
||
<label>...oder ZIP-Datei hochladen (max. 50 MB)</label>
|
||
<input type="file" name="zip_file" accept=".zip">
|
||
{% if allow_llm %}
|
||
<label><input type="checkbox" name="use_llm"> LLM-Analyse aktivieren (sendet Dateiinhalte an den konfigurierten Provider – genauer, aber nicht mehr rein lokal)</label>
|
||
{% endif %}
|
||
<button type="submit">Scan starten</button>
|
||
</form>
|
||
<hr>
|
||
{% if result %}
|
||
<h2>Ergebnis</h2>
|
||
<p class="score {{ result.risk_assessment.recommendation }}">
|
||
{{ result.risk_assessment.score }}/100 — {{ result.risk_assessment.severity }} — {{ result.risk_assessment.recommendation }}
|
||
</p>
|
||
<pre>{{ result_json }}</pre>
|
||
{% elif error %}
|
||
<p class="err">{{ error }}</p>
|
||
{% endif %}
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
@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"}
|