diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b915d18 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 744a2a5..0937fac 100644 --- a/README.md +++ b/README.md @@ -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://: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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..b468ef0 --- /dev/null +++ b/app.py @@ -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 = """ + + + + +SkillSpector + + + +

🔍 SkillSpector

+

Scannt einen Skill (Git-Repo oder ZIP) auf Sicherheitsrisiken, bevor er installiert wird.

+
+ + + + + {% if allow_llm %} + + {% endif %} + +
+
+{% if result %} +

Ergebnis

+

+ {{ result.risk_assessment.score }}/100 — {{ result.risk_assessment.severity }} — {{ result.risk_assessment.recommendation }} +

+
{{ result_json }}
+{% elif error %} +

{{ error }}

+{% endif %} + + +""" + + +@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"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0b1bc5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b733f8f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.3 +gunicorn==22.0.0