Dateien nach "/" hochladen
This commit is contained in:
1 parent
74087727ad
commit
0d53c9a3e2
5 files changed
+330
-103
No files matched your search
@@ -1,23 +1,32 @@
|
||||
"""
|
||||
SkillSpector Web – schlanker Wrapper um die skillspector-CLI.
|
||||
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 als Ziel akzeptiert (kein file://, keine lokalen Pfade).
|
||||
- Läuft standardmäßig mit --no-llm, damit keine Dateiinhalte nach außen gehen.
|
||||
- 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.
|
||||
- Zugriff nur mit Basic-Auth (Benutzername/Passwort aus Umgebungsvariablen).
|
||||
- Harte Zeit- und Upload-Größenbegrenzung, damit ein Scan den Container nicht blockieren kann.
|
||||
- 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
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, Response, render_template_string, request
|
||||
|
||||
@@ -27,14 +36,28 @@ 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") # MUSS über die Umgebung gesetzt werden
|
||||
WEBUI_PASSWORD = os.environ.get("WEBUI_PASSWORD")
|
||||
|
||||
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"))
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -50,9 +73,12 @@ def authenticate():
|
||||
def requires_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not REQUIRE_AUTH:
|
||||
return f(*args, **kwargs)
|
||||
if not WEBUI_PASSWORD:
|
||||
return Response(
|
||||
"WEBUI_PASSWORD ist nicht gesetzt – Zugriff aus Sicherheitsgründen gesperrt.",
|
||||
"REQUIRE_AUTH=true, aber WEBUI_PASSWORD ist nicht gesetzt – "
|
||||
"Zugriff aus Sicherheitsgründen gesperrt.",
|
||||
500,
|
||||
)
|
||||
auth = request.authorization
|
||||
@@ -63,6 +89,56 @@ def requires_auth(f):
|
||||
return decorated
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rate-Limiting (pro Client-IP, in-memory – reicht für einen einzelnen Prozess)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def client_ip() -> str:
|
||||
# Wenn hinter einem Reverse Proxy: X-Forwarded-For des Proxys vertrauen.
|
||||
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
|
||||
|
||||
|
||||
PAGE = """
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
@@ -79,19 +155,22 @@ pre{background:#000;padding:12px;overflow-x:auto;white-space:pre-wrap;border-rad
|
||||
.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}
|
||||
.notice{font-size:.85em;color:#999;margin-top:24px;border-top:1px solid #333;padding-top:12px}
|
||||
hr{border-color:#333}
|
||||
a{color:#6cf}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 SkillSpector</h1>
|
||||
<p>Scannt einen Skill (Git-Repo oder ZIP) auf Sicherheitsrisiken, <strong>bevor</strong> er installiert wird.</p>
|
||||
<p>Scannt einen Skill (Git-Repo oder ZIP) auf Sicherheitsrisiken, <strong>bevor</strong> er installiert wird.
|
||||
Basiert auf <a href="https://github.com/NVIDIA/SkillSpector" target="_blank">NVIDIA SkillSpector</a> (Apache-2.0).</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<label>Git-Repository-URL (nur http/https)</label>
|
||||
<label>Git-Repository-URL (nur öffentlich erreichbares 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>
|
||||
<label><input type="checkbox" name="use_llm"> LLM-Analyse aktivieren (sendet Dateiinhalte des Skills an den konfigurierten KI-Anbieter – genauer, aber nicht mehr rein lokal)</label>
|
||||
{% endif %}
|
||||
<button type="submit">Scan starten</button>
|
||||
</form>
|
||||
@@ -105,6 +184,12 @@ hr{border-color:#333}
|
||||
{% elif error %}
|
||||
<p class="err">{{ error }}</p>
|
||||
{% endif %}
|
||||
<p class="notice">
|
||||
Statische Analyse läuft immer lokal. {% if allow_llm %}Bei aktivierter LLM-Analyse werden
|
||||
Dateiinhalte an einen externen KI-Anbieter übertragen.{% endif %}
|
||||
Der gescannte Skill wird zu keinem Zeitpunkt ausgeführt. Kein Ergebnis wird dauerhaft
|
||||
gespeichert – es erscheint nur auf dieser Seite.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -116,10 +201,27 @@ def index():
|
||||
if request.method == "GET":
|
||||
return render_template_string(PAGE, allow_llm=ALLOW_LLM, result=None, error=None)
|
||||
|
||||
ip = client_ip()
|
||||
if rate_limited(ip):
|
||||
return render_template_string(
|
||||
PAGE,
|
||||
allow_llm=ALLOW_LLM,
|
||||
result=None,
|
||||
error=f"Limit erreicht: maximal {MAX_SCANS_PER_HOUR_PER_IP} Scans pro Stunde und IP. Bitte später erneut versuchen.",
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
if not _scan_semaphore.acquire(blocking=False):
|
||||
return render_template_string(
|
||||
PAGE,
|
||||
allow_llm=ALLOW_LLM,
|
||||
result=None,
|
||||
error="Gerade laufen bereits zu viele Scans gleichzeitig. Bitte kurz warten und erneut versuchen.",
|
||||
)
|
||||
|
||||
work_dir = tempfile.mkdtemp(dir=SCAN_TMP_DIR)
|
||||
try:
|
||||
if zip_file and zip_file.filename:
|
||||
@@ -127,12 +229,12 @@ def index():
|
||||
zip_file.save(zip_path)
|
||||
target = str(zip_path)
|
||||
elif git_url:
|
||||
if not GIT_URL_RE.match(git_url):
|
||||
if not is_public_target(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).",
|
||||
error="Diese URL ist nicht erlaubt (nur öffentlich erreichbare http/https-Ziele).",
|
||||
)
|
||||
target = git_url
|
||||
else:
|
||||
@@ -180,6 +282,7 @@ def index():
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
_scan_semaphore.release()
|
||||
|
||||
|
||||
@app.route("/healthz")
|
||||
|
||||
Reference in new issue
Block a user