Dateien nach "/" hochladen
This commit is contained in:
1 parent
74087727ad
commit
0d53c9a3e2
5 files changed
+330
-103
No files matched your search
+3
-1
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 MB)</label>
|
<label>...oder ZIP-Datei hochladen (max. 50 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
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user