Dateien nach "/" hochladen

This commit is contained in:
Oliver Braasch committed 2026-09-22 12:07:20 +02:00
1 parent 74087727ad
commit 0d53c9a3e2
5 files changed
+330 -103

No files matched your search

+3 -1
View File
@@ -28,4 +28,6 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=3)" CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=3)"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "310", "app:app"] # Ein Worker mit mehreren Threads: Rate-Limit, Concurrency-Semaphore und der
# Scan-Zähler in app.py sind In-Memory-Zustand und müssen sich einen Prozess teilen.
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "4", "--timeout", "310", "app:app"]
+16 -13
View File
@@ -1,18 +1,21 @@
MIT License MIT License
Copyright (c) 2026 olli1986 Copyright (c) 2026 Oliver Braasch
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy
associated documentation files (the "Software"), to deal in the Software without restriction, including of this software and associated documentation files (the "Software"), to deal
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell in the Software without restriction, including without limitation the rights
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
following conditions: copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all
portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
USE OR OTHER DEALINGS IN THE SOFTWARE. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+177 -72
View File
@@ -1,81 +1,186 @@
# SkillSpector Web – Setup-Anleitung # SkillSpector Web
Web-Oberfläche für [SkillSpector](https://github.com/NVIDIA/SkillSpector), gehärtet für Eine schlanke Web-Oberfläche für [NVIDIA SkillSpector](https://github.com/NVIDIA/SkillSpector) –
den Betrieb auf einem eigenen Server via Portainer. prüft AI-Agent-Skills (Claude Code, Codex, MCP) auf Sicherheitsrisiken wie
Prompt-Injection, Daten-Exfiltration, Supply-Chain-Probleme oder gefährlichen
Code, **bevor** sie installiert werden.
## 1. Repository in Gitea anlegen **Live:** _(hier deine Subdomain eintragen, z. B. https://skillspector.obraasch.de/)_
1. Neues Repo z. B. unter `git.obraasch.de/olli1986/skillspector-web` anlegen. ---
2. Diese vier Dateien hochladen/pushen: `Dockerfile`, `requirements.txt`, `app.py`,
`docker-compose.yml`.
3. **Nicht committen:** Passwörter oder API-Keys – die kommen später als
Umgebungsvariablen in Portainer, nie ins Repo.
## 2. Stack in Portainer anlegen ## Was macht das Tool?
1. **Stacks → Add stack** 1. Du gibst die URL eines Git-Repositorys (oder eine ZIP-Datei) mit einem
2. **Build method: Repository** AI-Agent-Skill an.
- Repository URL: `https://git.obraasch.de/olli1986/skillspector-web` 2. Im Hintergrund läuft `skillspector scan` – reine statische Analyse
- Reference: `refs/heads/main` (Regex, Python-AST, YARA-Signaturen), optional ergänzt durch eine
- Compose path: `docker-compose.yml` LLM-Bewertung der Dateiinhalte.
3. **Environment variables** (im Portainer-UI, nicht im Repo): 3. Du bekommst einen Risk Score (0–100) mit Einstufung `SAFE` / `CAUTION` /
- `WEBUI_USER` = z. B. `admin` `DO_NOT_INSTALL` sowie eine Liste der gefundenen Probleme.
- `WEBUI_PASSWORD` = ein starkes Passwort
- optional `ANTHROPIC_API_KEY`, falls du später LLM-Analyse willst
4. **Deploy the stack**
Ohne gesetztes `WEBUI_PASSWORD` verweigert die WebUI jeden Zugriff (Absicherung Der gescannte Skill wird dabei **zu keinem Zeitpunkt ausgeführt**.
gegen offen erreichbare Instanzen).
## 3. Automatische Updates einrichten
Der Trick: Im `Dockerfile` ist SkillSpector **ohne Versions-Pin** installiert
(`git+https://github.com/NVIDIA/skillspector.git`). Jeder Rebuild des Images holt
also automatisch den aktuellen `main`-Branch. Damit das regelmäßig passiert, ohne
dass du selbst etwas anfassen musst:
1. Stack öffnen → Abschnitt **GitOps updates**
2. **Enable GitOps updates** aktivieren
3. Mechanismus: **Polling**, Intervall z. B. `24h`
4. **Force redeployment** UND **Re-pull image / rebuild** aktivieren
→ Auch wenn sich in *deinem* Repo nichts ändert, baut Portainer das Image alle
24h neu und zieht damit automatisch die neueste SkillSpector-Version.
5. Speichern.
Falls deine Portainer-Version "Force redeployment" beim reinen Repository-Build
nicht anbietet: alternativ einfach den Stack alle paar Wochen manuell über
**"Pull and redeploy"** neu bauen – dauert 10 Sekunden und holt ebenfalls die
neueste Version.
## 4. Öffentlich erreichbar machen (optional)
Der Container bindet bewusst nur an `127.0.0.1:8000`, ist also von außen gar nicht
erreichbar. Wenn du ihn z. B. unter `skillspector.obraasch.de` nutzen willst,
richte in CloudPanel einen Reverse Proxy auf `127.0.0.1:8000` ein – mit TLS
(Let's Encrypt) und idealerweise IP-Beschränkung, da die App zusätzlich zur
Basic-Auth Angriffsfläche für einen Web-Client darstellt (auch wenn sie selbst
sehr klein ist).
## 5. Warum das deinen Server nicht gefährden kann
- **Kein Docker-Socket-Mount** – der Container kann keine anderen Container
oder den Host steuern.
- **`read_only: true`** – das Root-Dateisystem im Container ist unveränderlich,
nur `/tmp` und das Scan-Verzeichnis sind (flüchtiger) RAM-Speicher (`tmpfs`).
- **`cap_drop: ALL`** und **`no-new-privileges`** – keine erweiterten
Linux-Rechte.
- **Ressourcenlimits** (`mem_limit`, `cpus`) – ein hängender oder böswilliger
Scan kann den restlichen Server nicht in die Knie zwingen.
- **SkillSpector führt den gescannten Skill nie aus** – nur statische Analyse
(Regex/AST/YARA) plus optionale LLM-Bewertung von Dateiinhalten.
- **`--no-llm` ist Standard** – ohne dein explizites Häkchen verlässt kein
gescannter Dateiinhalt den Container.
- Jeder Scan läuft in einem eigenen Temp-Ordner, der danach gelöscht wird.
## Nutzung ## Nutzung
WebUI öffnen (z. B. `http://<server>:8000` lokal oder deine Subdomain), Basic-Auth Formular öffnen → Git-URL eintragen oder ZIP hochladen → "Scan starten".
eingeben, Git-URL des Skills eintragen oder ZIP hochladen, "Scan starten" klicken. Fertig. Kein Account nötig (sofern der Betreiber den öffentlichen Modus
Das Ergebnis zeigt Risk Score, Severity und Empfehlung (`SAFE` / `CAUTION` / aktiviert hat, siehe unten).
`DO_NOT_INSTALL`) sowie den vollständigen JSON-Report.
---
## Selbst betreiben: Setup mit Gitea + Portainer
### 1. Repository klonen/forken
Dieses Repository enthält alles, was für ein eigenständiges Deployment
nötig ist: `Dockerfile`, `requirements.txt`, `app.py`, `docker-compose.yml`.
### 2. Stack in Portainer anlegen
1. **Stacks → Add stack → Build method: Repository**
- Repository URL: die URL dieses Repos
- Reference: `refs/heads/main`
- Compose path: `docker-compose.yml`
2. **Environment variables** im Portainer-UI setzen (niemals ins Repo
committen):
| Variable | Zweck | Beispiel |
|---|---|---|
| `REQUIRE_AUTH` | `true` = privat mit Login, `false` = öffentlich ohne Login | `false` |
| `WEBUI_USER` / `WEBUI_PASSWORD` | Nur relevant wenn `REQUIRE_AUTH=true` | – |
| `HOST_PORT` | Host-Port, falls der Standard (8010) belegt ist | `8011` |
| `MAX_SCANS_PER_HOUR_PER_IP` | Rate-Limit pro IP | `10` |
| `MAX_CONCURRENT_SCANS` | Wie viele Scans gleichzeitig laufen dürfen | `2` |
| `ENABLE_LLM_OPTION` | Schaltet die LLM-Checkbox für Nutzer frei | `false` |
| `OPENROUTER_API_KEY` | Nur wenn `ENABLE_LLM_OPTION=true` | `sk-or-...` |
| `OPENROUTER_MODEL` | Modell-Slug bei OpenRouter | `anthropic/claude-sonnet-4.5` |
3. **Deploy the stack**.
### 3. Port-Konflikt beheben ("bind: address already in use")
Diese Fehlermeldung heißt: Ein anderer Container/Prozess auf deinem Server
belegt den Port bereits. Zwei Wege:
- **Anderen Port wählen** (empfohlen): Umgebungsvariable `HOST_PORT` in
Portainer setzen, z. B. `8010`, `8011` … Der Compose-Datei ist das schon
vorbereitet (`127.0.0.1:${HOST_PORT:-8010}:8000`).
- **Belegenden Prozess finden**: auf dem Server `sudo ss -tlnp | grep 8000`
(oder `docker ps` – oft ist es ein anderer Stack, der ebenfalls Port 8000
nutzt).
### 4. Automatische Updates
Im `Dockerfile` ist SkillSpector **ohne Versions-Pin** installiert
(`git+https://github.com/NVIDIA/skillspector.git`). Jeder Image-Rebuild holt
also automatisch die aktuellste Version. Damit das regelmäßig passiert:
1. Stack öffnen → **GitOps updates** aktivieren
2. Mechanismus **Polling**, Intervall z. B. `24h`
3. **Force redeployment** und **Re-pull image** aktivieren
→ Portainer baut das Image alle 24 h neu, auch ohne Änderung an diesem
Repo, und zieht damit automatisch die neueste SkillSpector-Version.
Falls deine Portainer-Version das nicht anbietet: den Stack gelegentlich
manuell über "Pull and redeploy" neu bauen.
---
## LLM-Analyse über OpenRouter einrichten
SkillSpector unterstützt LLM-Analyse über jeden OpenAI-kompatiblen Endpunkt.
[OpenRouter](https://openrouter.ai) ist genau das – daher funktioniert es mit:
```
SKILLSPECTOR_PROVIDER=openai
OPENAI_API_KEY=<dein-openrouter-key>
OPENAI_BASE_URL=https://openrouter.ai/api/v1
SKILLSPECTOR_MODEL=anthropic/claude-sonnet-4.5 # oder ein anderes Modell-Slug
```
Das ist im `docker-compose.yml` bereits vorbereitet (`OPENROUTER_API_KEY` /
`OPENROUTER_MODEL` setzen, `ENABLE_LLM_OPTION=true`). Zwei Dinge zu beachten:
- **Kosten/Abuse**: Ohne `ENABLE_LLM_OPTION=true` ist die LLM-Checkbox für
Nutzer gar nicht sichtbar – dein OpenRouter-Guthaben ist also standardmäßig
nicht angreifbar, selbst im öffentlichen Modus. Das Rate-Limit
(`MAX_SCANS_PER_HOUR_PER_IP`) begrenzt zusätzlich, wie oft ein einzelner
Nutzer die LLM-Analyse überhaupt anstoßen kann.
- **Nicht jedes Modell liefert zuverlässig strukturierte JSON-Antworten.**
Falls die LLM-Analyse mit einem Fehler abbricht, zuerst ein bekanntes,
instruction-following-starkes Modell testen (z. B. ein aktuelles
Claude- oder GPT-Modell über OpenRouter); im Zweifel bleibt `--no-llm`
(Standard) immer als zuverlässiger Fallback.
---
## Sicherheitsarchitektur
**Container-Ebene** (schützt deinen Server):
- Kein Docker-Socket-Mount – der Container kann weder andere Container noch
den Host steuern.
- `read_only: true` – Root-Dateisystem unveränderlich, nur `/tmp` und das
Scan-Verzeichnis sind flüchtiger RAM-Speicher (`tmpfs`).
- `cap_drop: ALL` und `no-new-privileges` – keine erweiterten Linux-Rechte.
- Ressourcenlimits (`mem_limit`, `cpus`) – ein hängender oder böswilliger
Scan kann den restlichen Server nicht belasten.
- Port nur auf `127.0.0.1` gebunden – öffentliche Erreichbarkeit ausschließlich
über deinen Reverse Proxy (TLS, eigene Subdomain).
**Anwendungs-Ebene** (schützt vor Missbrauch durch Fremdnutzer, relevant seit
öffentlichem Betrieb):
- **SSRF-Schutz**: Ziel-URLs werden aufgelöst und abgelehnt, wenn sie auf
private/interne/Loopback-Adressen zeigen (verhindert, dass jemand über das
Formular dein internes Netzwerk abscannt).
- **Rate-Limiting**: maximal `MAX_SCANS_PER_HOUR_PER_IP` Scans pro IP und
Stunde.
- **Concurrency-Limit**: maximal `MAX_CONCURRENT_SCANS` Scans gleichzeitig,
weitere Anfragen werden abgewiesen statt den Server zu überlasten.
- **Zeitlimit** je Scan (`SCAN_TIMEOUT_SECONDS`).
- **LLM-Analyse ist ein bewusstes Opt-in** des Betreibers, nicht der Nutzer.
- SkillSpector führt den gescannten Skill nie aus – nur statische Analyse
plus optionale LLM-Bewertung von Dateiinhalten.
- Jeder Scan läuft in einem eigenen, danach gelöschten Temp-Ordner. Nichts
wird dauerhaft gespeichert.
---
## Rechtliches für den öffentlichen Betrieb
Ein öffentlich erreichbares Angebot in Deutschland braucht ein Impressum
(§ 5 DDG) und eine Datenschutzerklärung. Falls du dieses Tool wie deine
anderen Werkzeuge über `software.obraasch.de` einbindest, kannst du die dort
vorhandenen Vorlagen (`impressum.html`, `datenschutz.html` aus dem
[Software-Uebersicht](https://git.obraasch.de/olli1986/Software-Uebersicht)-Repo)
weiterverwenden und um einen Hinweis ergänzen, dass bei aktivierter
LLM-Analyse Nutzereingaben an einen externen Anbieter (OpenRouter bzw. das
dort gewählte Modell) übertragen werden. Die WebUI selbst weist Nutzer direkt
im Formular darauf hin.
Diese Angaben sind nach bestem Wissen erstellt, ersetzen aber keine
Rechtsberatung.
---
## Lizenz
Dieser Web-Wrapper (`Dockerfile`, `app.py`, `requirements.txt`,
`docker-compose.yml`, diese README) steht unter der **MIT-Lizenz**, siehe
[`LICENSE`](./LICENSE).
Er installiert beim Bauen des Images das externe Paket **SkillSpector**
(NVIDIA, Apache License 2.0) – siehe [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
SkillSpector selbst ist nicht Teil dieses Repositories und bleibt vollständig
unter der Lizenz des Originalprojekts.
## Haftungsausschluss
Diese Software wird "wie sie ist" bereitgestellt, ohne jegliche Garantie.
Ein niedriger Risk Score ist keine Sicherheitsgarantie – SkillSpector ist ein
Hilfsmittel zur Risikoeinschätzung, keine vollständige Sicherheitsprüfung.
Für gescannte Inhalte, die von Nutzern eingereicht werden, wird keine
Verantwortung übernommen.
+116 -13
View File
@@ -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: Sicherheitsprinzipien:
- Führt NIEMALS den gescannten Skill aus (das macht skillspector selbst auch nicht). - 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). - Nur http/https-URLs werden akzeptiert; Ziele, die auf private/interne Adressen
- Läuft standardmäßig mit --no-llm, damit keine Dateiinhalte nach außen gehen. 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. - 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.
- Harte Zeit- und Upload-Größenbegrenzung, damit ein Scan den Container nicht blockieren kann.
""" """
import ipaddress
import json import json
import os import os
import re import re
import shutil import shutil
import socket
import subprocess import subprocess
import tempfile import tempfile
import threading
import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
from flask import Flask, Response, render_template_string, request 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") SCAN_TMP_DIR = os.environ.get("SCAN_TMP_DIR", "/tmp/skillspector")
Path(SCAN_TMP_DIR).mkdir(parents=True, exist_ok=True) 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_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")) 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]+$") 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: def check_auth(username: str, password: str) -> bool:
return bool(WEBUI_PASSWORD) and username == WEBUI_USER and password == WEBUI_PASSWORD return bool(WEBUI_PASSWORD) and username == WEBUI_USER and password == WEBUI_PASSWORD
@@ -50,9 +73,12 @@ def authenticate():
def requires_auth(f): def requires_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
if not REQUIRE_AUTH:
return f(*args, **kwargs)
if not WEBUI_PASSWORD: if not WEBUI_PASSWORD:
return Response( 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, 500,
) )
auth = request.authorization auth = request.authorization
@@ -63,6 +89,56 @@ def requires_auth(f):
return decorated 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 = """ PAGE = """
<!doctype html> <!doctype html>
<html lang="de"> <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} .SAFE{color:#4caf50}.CAUTION{color:#ffb300}.DO_NOT_INSTALL{color:#f44336}
label{display:block;margin-top:14px;font-size:.9em;color:#bbb} label{display:block;margin-top:14px;font-size:.9em;color:#bbb}
.err{color:#f44336;font-weight:bold} .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} hr{border-color:#333}
a{color:#6cf}
</style> </style>
</head> </head>
<body> <body>
<h1>🔍 SkillSpector</h1> <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"> <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"> <input type="text" name="git_url" placeholder="https://git.obraasch.de/olli1986/mein-skill">
<label>...oder ZIP-Datei hochladen (max. 50&nbsp;MB)</label> <label>...oder ZIP-Datei hochladen (max. 50&nbsp;MB)</label>
<input type="file" name="zip_file" accept=".zip"> <input type="file" name="zip_file" accept=".zip">
{% if allow_llm %} {% 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 %} {% endif %}
<button type="submit">Scan starten</button> <button type="submit">Scan starten</button>
</form> </form>
@@ -105,6 +184,12 @@ hr{border-color:#333}
{% elif error %} {% elif error %}
<p class="err">{{ error }}</p> <p class="err">{{ error }}</p>
{% endif %} {% 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> </body>
</html> </html>
""" """
@@ -116,10 +201,27 @@ def index():
if request.method == "GET": if request.method == "GET":
return render_template_string(PAGE, allow_llm=ALLOW_LLM, result=None, error=None) 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" use_llm = ALLOW_LLM and request.form.get("use_llm") == "on"
git_url = (request.form.get("git_url") or "").strip() git_url = (request.form.get("git_url") or "").strip()
zip_file = request.files.get("zip_file") 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) work_dir = tempfile.mkdtemp(dir=SCAN_TMP_DIR)
try: try:
if zip_file and zip_file.filename: if zip_file and zip_file.filename:
@@ -127,12 +229,12 @@ def index():
zip_file.save(zip_path) zip_file.save(zip_path)
target = str(zip_path) target = str(zip_path)
elif git_url: elif git_url:
if not GIT_URL_RE.match(git_url): if not is_public_target(git_url):
return render_template_string( return render_template_string(
PAGE, PAGE,
allow_llm=ALLOW_LLM, allow_llm=ALLOW_LLM,
result=None, 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 target = git_url
else: else:
@@ -180,6 +282,7 @@ def index():
) )
finally: finally:
shutil.rmtree(work_dir, ignore_errors=True) shutil.rmtree(work_dir, ignore_errors=True)
_scan_semaphore.release()
@app.route("/healthz") @app.route("/healthz")
+18 -4
View File
@@ -7,16 +7,30 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# --- Zugriff -------------------------------------------------------
REQUIRE_AUTH: ${REQUIRE_AUTH:-true} # "false" = öffentlich ohne Login
WEBUI_USER: ${WEBUI_USER:-admin} WEBUI_USER: ${WEBUI_USER:-admin}
WEBUI_PASSWORD: ${WEBUI_PASSWORD} WEBUI_PASSWORD: ${WEBUI_PASSWORD}
SCAN_TIMEOUT_SECONDS: "300"
# Nur setzen, wenn LLM-Analyse gewünscht ist (optional, siehe README): # --- Schutz gegen Missbrauch (greift v. a. im Public-Mode) ---------
# ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} MAX_SCANS_PER_HOUR_PER_IP: ${MAX_SCANS_PER_HOUR_PER_IP:-10}
MAX_CONCURRENT_SCANS: ${MAX_CONCURRENT_SCANS:-2}
SCAN_TIMEOUT_SECONDS: ${SCAN_TIMEOUT_SECONDS:-300}
# --- Optionale LLM-Analyse via OpenRouter (OpenAI-kompatible API) --
# Muss zusätzlich per ENABLE_LLM_OPTION freigeschaltet werden, damit
# öffentliche Nutzer nicht unbemerkt dein OpenRouter-Guthaben verbrauchen.
ENABLE_LLM_OPTION: ${ENABLE_LLM_OPTION:-false}
SKILLSPECTOR_PROVIDER: openai
OPENAI_API_KEY: ${OPENROUTER_API_KEY}
OPENAI_BASE_URL: https://openrouter.ai/api/v1
SKILLSPECTOR_MODEL: ${OPENROUTER_MODEL:-anthropic/claude-sonnet-4.5}
# Nur auf localhost binden – Zugriff von außen läuft über deinen # Nur auf localhost binden – Zugriff von außen läuft über deinen
# bestehenden Reverse Proxy (CloudPanel) mit TLS + eigenem Vhost. # bestehenden Reverse Proxy (CloudPanel) mit TLS + eigenem Vhost.
# HOST_PORT frei wählbar, falls 8000 schon belegt ist (siehe README).
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:${HOST_PORT:-8010}:8000"
# Härtung: kein Zugriff auf den Docker-Socket, kein Host-Mount, # Härtung: kein Zugriff auf den Docker-Socket, kein Host-Mount,
# Root-Dateisystem read-only, keine erweiterten Rechte. # Root-Dateisystem read-only, keine erweiterten Rechte.