Dateien nach "/" hochladen
This commit is contained in:
1 parent
101ab8c54d
commit
74087727ad
5 files changed
+339
-1
No files matched your search
+31
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 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"}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask==3.0.3
|
||||||
|
gunicorn==22.0.0
|
||||||
Reference in new issue
Block a user