Dateien nach "/" hochladen

This commit is contained in:
Oliver Braasch committed 2026-09-22 11:54:02 +02:00
1 parent 101ab8c54d
commit 74087727ad
5 files changed
+339 -1

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
FROM python:3.12-slim-bookworm
# git wird von SkillSpector selbst benötigt, um Git-Repos zu klonen und zu scannen
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*
# SkillSpector wird bei JEDEM Image-Build frisch vom main-Branch installiert.
# Kein Versions-Pin -> ein Rebuild holt automatisch die aktuellste Version.
RUN pip install --no-cache-dir 'skillspector @ git+https://github.com/NVIDIA/skillspector.git'
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Nicht als root laufen
RUN useradd -m -u 1000 scanner \
&& mkdir -p /app/scan-tmp \
&& chown -R scanner:scanner /app
USER scanner
ENV PYTHONUNBUFFERED=1 \
SCAN_TMP_DIR=/app/scan-tmp
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=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"]
+80 -1
View File
@@ -1,2 +1,81 @@
# SkillSpector-Web
# SkillSpector Web – Setup-Anleitung
Web-Oberfläche für [SkillSpector](https://github.com/NVIDIA/SkillSpector), gehärtet für
den Betrieb auf einem eigenen Server via Portainer.
## 1. Repository in Gitea anlegen
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
1. **Stacks → Add stack**
2. **Build method: Repository**
- Repository URL: `https://git.obraasch.de/olli1986/skillspector-web`
- Reference: `refs/heads/main`
- Compose path: `docker-compose.yml`
3. **Environment variables** (im Portainer-UI, nicht im Repo):
- `WEBUI_USER` = z. B. `admin`
- `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
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
WebUI öffnen (z. B. `http://<server>:8000` lokal oder deine Subdomain), Basic-Auth
eingeben, Git-URL des Skills eintragen oder ZIP hochladen, "Scan starten" klicken.
Das Ergebnis zeigt Risk Score, Severity und Empfehlung (`SAFE` / `CAUTION` /
`DO_NOT_INSTALL`) sowie den vollständigen JSON-Report.
+187
View File
@@ -0,0 +1,187 @@
"""
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&nbsp;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"}
+39
View File
@@ -0,0 +1,39 @@
services:
skillspector-web:
build:
context: .
dockerfile: Dockerfile
container_name: skillspector-web
restart: unless-stopped
environment:
WEBUI_USER: ${WEBUI_USER:-admin}
WEBUI_PASSWORD: ${WEBUI_PASSWORD}
SCAN_TIMEOUT_SECONDS: "300"
# Nur setzen, wenn LLM-Analyse gewünscht ist (optional, siehe README):
# ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
# Nur auf localhost binden – Zugriff von außen läuft über deinen
# bestehenden Reverse Proxy (CloudPanel) mit TLS + eigenem Vhost.
ports:
- "127.0.0.1:8000:8000"
# Härtung: kein Zugriff auf den Docker-Socket, kein Host-Mount,
# Root-Dateisystem read-only, keine erweiterten Rechte.
read_only: true
tmpfs:
- /tmp
- /app/scan-tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 1g
cpus: 1.0
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
+2
View File
@@ -0,0 +1,2 @@
flask==3.0.3
gunicorn==22.0.0