server-up/server-up/core/audit.py
Ramon 916e54a229 v0.5.00-beta - authenticatie + beveiligingsfixes
Server Up beheerde de Docker-daemon als root zonder enige vorm van
authenticatie: elke /api/*-route was gelijk aan root-toegang op de host.

- Authenticatie toegevoegd (core/auth.py): lokale accounts met scrypt-hash,
  sessiecookie (HttpOnly, SameSite=Strict) en optionele trusted-proxy-header
  SSO die alleen vanaf geconfigureerde proxy-IP's wordt vertrouwd.
- before_request-guard schermt alle API-routes af; loginscherm en
  eerste-account-setup in de UI.
- CSRF-token verplicht op elke mutatie; GET-varianten van state-wijzigende
  routes verwijderd (o.a. /api/docker/restart was via <img> te triggeren).
- Path traversal in /api/store/install gedicht; gedeelde safe_name()-validatie
  voor stack-, instantie- en repo-namen.
- Git-tokens worden niet meer teruggegeven via /api/repos en /api/settings
  (has_token-vlag); settings-PUT wist een bestaand token niet meer en kan AUTH
  niet overschrijven.
- Git-URL's beperkt tot http(s)/ssh/scp-syntax; ext::-transport (voert een
  shell-commando uit) en file:// worden geweigerd.
- Boilerplate-templates renderen in een SandboxedEnvironment (SSTI).
- Automatisch syncen van repo's bij boot standaard uit (AUTO_SYNC_ON_BOOT),
  optionele commit-pinning per repo.
- Waitress in plaats van de Flask-ontwikkelserver, MAX_CONTENT_LENGTH,
  ProxyFix, en CSP/X-Frame-Options/nosniff/Referrer-Policy headers.
- Front-end libraries (Tailwind, Alpine, htmx) lokaal meegeleverd i.p.v. CDN;
  Google Fonts verwijderd. Werkt nu ook offline.
- SSH host-key-verificatie aan (accept-new + /data/known_hosts).
- Lichte /healthz voor de healthcheck i.p.v. `docker info`.
- config.json en secret.key met 0600-rechten.
- Poort standaard op 127.0.0.1 gebonden.
- Audit-log gebruikt één gedeelde SQLite-verbinding (fd-lek per job verholpen);
  joblogs afgekapt op 2000 regels.
- VERSION-bestand is de enige bron voor het versienummer.
- pytest-suite toegevoegd (74 tests) en als stap in beide deploy-workflows.
- fix-config.sh verwijderd (hardgecodeerd intern IP, overschreef config).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 14:40:05 +02:00

87 lines
2.6 KiB
Python

"""Audit log backed by SQLite."""
import sqlite3, time, json, os, threading
from pathlib import Path
_db = Path(os.environ.get("SU_AUDIT", "/data/audit.db"))
# Eén gedeelde verbinding achter een lock. Eerder kreeg elke thread een eigen
# thread-local verbinding die nooit gesloten werd; omdat iedere job een nieuwe
# thread start, lekte dat een file descriptor per job.
_conn_obj: sqlite3.Connection | None = None
_lock = threading.Lock()
def init():
_db.parent.mkdir(parents=True, exist_ok=True)
with _lock:
c = _connect()
with c:
c.execute("""CREATE TABLE IF NOT EXISTS log(
id INTEGER PRIMARY KEY, ts REAL,
src TEXT, action TEXT, status TEXT,
ref TEXT, detail TEXT, ip TEXT)""")
def _connect() -> sqlite3.Connection:
"""Maak (eenmalig) de gedeelde verbinding. Aanroepen onder _lock."""
global _conn_obj
if _conn_obj is None:
_db.parent.mkdir(parents=True, exist_ok=True)
_conn_obj = sqlite3.connect(str(_db), timeout=5, check_same_thread=False)
_conn_obj.row_factory = sqlite3.Row
# WAL laat lezers en schrijvers naast elkaar werken.
_conn_obj.execute("PRAGMA journal_mode=WAL")
return _conn_obj
def close():
"""Sluit de gedeelde verbinding (gebruikt door tests en bij afsluiten)."""
global _conn_obj
with _lock:
if _conn_obj is not None:
try:
_conn_obj.close()
finally:
_conn_obj = None
def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
d = json.dumps(detail) if isinstance(detail, (dict, list)) else str(detail or "")
try:
with _lock:
c = _connect()
with c:
c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) "
"VALUES(?,?,?,?,?,?,?)",
(time.time(), src, action, status, ref, d, ip))
except Exception:
pass
def query(limit=100, offset=0) -> list[dict]:
try:
with _lock:
rows = _connect().execute(
"SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
return [dict(r) for r in rows]
except Exception:
return []
def count() -> int:
try:
with _lock:
return _connect().execute("SELECT count(*) FROM log").fetchone()[0]
except Exception:
return 0
def clear():
try:
with _lock:
c = _connect()
with c:
c.execute("DELETE FROM log")
except Exception:
pass