- New: auth.py – Role-based access (operator/approver/viewer) - New: llm_router.py – Configurable primary/fallback provider chain - New: audit_log.py + audit_query.py – DSGVO-compliant audit trail - New: sanitize_output() – masks secrets in bot responses - Updated: bot.py – Integrated auth guards, router, audit logging - Updated: ollama_client.py – Router-compatible interface - Updated: CLOWNFISCHSERVER.md – Documentation updated - Updated: README.md – Documentation updated
206 lines
6.8 KiB
Python
206 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
🐠 Clownfischserver – Audit Trail (DSGVO Art. 5 + 32)
|
||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||
License: GPL-3.0
|
||
|
||
Writes JSON-line entries for every privileged action.
|
||
Pseudonymized: stores only numeric Telegram user_ids, never usernames.
|
||
|
||
Compliance:
|
||
- Art. 5(1)(a) lawfulness/transparency: every action is recorded.
|
||
- Art. 5(1)(c) data minimization: numeric IDs only, command/result trimmed.
|
||
- Art. 5(1)(e) storage limitation: prune_old_logs() drops entries older
|
||
than AUDIT_RETENTION_DAYS (default 90).
|
||
- Art. 32 integrity: append-only file, 0600 permissions.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# How much of a command/result we keep – longer payloads are truncated
|
||
# to honour data minimization. Configurable via env.
|
||
_MAX_DETAIL_LEN = int(os.getenv("AUDIT_MAX_DETAIL_LEN", "2000"))
|
||
|
||
# Lock for append writes – multiple coroutines may log concurrently.
|
||
_write_lock = threading.Lock()
|
||
|
||
|
||
def _default_audit_path() -> str:
|
||
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
|
||
|
||
|
||
def _retention_days() -> int:
|
||
try:
|
||
return max(0, int(os.getenv("AUDIT_RETENTION_DAYS", "90")))
|
||
except ValueError:
|
||
return 90
|
||
|
||
|
||
def _trim(value: Any) -> Any:
|
||
if isinstance(value, str) and len(value) > _MAX_DETAIL_LEN:
|
||
return value[:_MAX_DETAIL_LEN] + f"... (truncated, {len(value)} chars total)"
|
||
return value
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
def log_action(
|
||
user_id: str | int | None,
|
||
action: str,
|
||
*,
|
||
role: str | None = None,
|
||
result: str = "ok",
|
||
details: dict[str, Any] | None = None,
|
||
path: str | None = None,
|
||
) -> None:
|
||
"""Append a single audit record. Never raises – audit failure must not
|
||
break the bot."""
|
||
entry: dict[str, Any] = {
|
||
"ts": _now_iso(),
|
||
"user_id": str(user_id) if user_id is not None else None,
|
||
"action": action,
|
||
"result": result,
|
||
}
|
||
if role:
|
||
entry["role"] = role
|
||
if details:
|
||
entry["details"] = {k: _trim(v) for k, v in details.items()}
|
||
|
||
target = path or _default_audit_path()
|
||
try:
|
||
Path(target).parent.mkdir(parents=True, exist_ok=True)
|
||
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
||
with _write_lock:
|
||
with open(target, "a", encoding="utf-8") as fh:
|
||
fh.write(line)
|
||
try:
|
||
os.chmod(target, 0o600)
|
||
except OSError:
|
||
# chmod can fail on non-POSIX FS (e.g. tests on Windows) –
|
||
# the JSONL content is still written.
|
||
pass
|
||
except Exception as e:
|
||
logger.error(f"Audit log write failed: {e}")
|
||
|
||
|
||
def log_shell(user_id, command: str, *, role: str | None = None,
|
||
status: str = "executed", returncode: int | None = None,
|
||
stderr: str | None = None, path: str | None = None) -> None:
|
||
details: dict[str, Any] = {"command": command}
|
||
if returncode is not None:
|
||
details["returncode"] = returncode
|
||
if stderr:
|
||
details["stderr"] = stderr
|
||
log_action(user_id, "shell.execute", role=role, result=status,
|
||
details=details, path=path)
|
||
|
||
|
||
def log_shell_proposed(user_id, description: str, command: str,
|
||
role: str | None = None, dangerous: bool = False,
|
||
path: str | None = None) -> None:
|
||
log_action(
|
||
user_id,
|
||
"shell.proposed",
|
||
role=role,
|
||
result="dangerous" if dangerous else "ok",
|
||
details={"description": description, "command": command},
|
||
path=path,
|
||
)
|
||
|
||
|
||
def log_code(user_id, task: str, *, role: str | None = None,
|
||
status: str = "ok", path: str | None = None) -> None:
|
||
log_action(user_id, "code.run", role=role, result=status,
|
||
details={"task": task}, path=path)
|
||
|
||
|
||
def log_approval(user_id, command: str, *, role: str | None = None,
|
||
approved: bool = True, path: str | None = None) -> None:
|
||
log_action(
|
||
user_id,
|
||
"shell.approval",
|
||
role=role,
|
||
result="approved" if approved else "rejected",
|
||
details={"command": command},
|
||
path=path,
|
||
)
|
||
|
||
|
||
def log_denied(user_id, action: str, *, role: str | None = None,
|
||
reason: str = "insufficient_role",
|
||
path: str | None = None) -> None:
|
||
log_action(user_id, action, role=role, result="denied",
|
||
details={"reason": reason}, path=path)
|
||
|
||
|
||
def prune_old_logs(path: str | None = None,
|
||
retention_days: int | None = None) -> int:
|
||
"""Rewrite the audit file, dropping entries older than retention_days.
|
||
|
||
Returns the number of dropped lines. retention_days=0 disables pruning.
|
||
Uses an atomic temp-file + rename so a crash mid-prune cannot corrupt
|
||
the audit trail.
|
||
"""
|
||
target = path or _default_audit_path()
|
||
days = retention_days if retention_days is not None else _retention_days()
|
||
if days <= 0 or not os.path.exists(target):
|
||
return 0
|
||
|
||
cutoff = time.time() - days * 86400
|
||
tmp_path = target + ".prune.tmp"
|
||
dropped = 0
|
||
kept = 0
|
||
|
||
try:
|
||
with _write_lock:
|
||
with open(target, "r", encoding="utf-8") as src, \
|
||
open(tmp_path, "w", encoding="utf-8") as dst:
|
||
for line in src:
|
||
raw = line.strip()
|
||
if not raw:
|
||
continue
|
||
try:
|
||
record = json.loads(raw)
|
||
ts = record.get("ts", "")
|
||
# ISO 8601 – parse with fromisoformat (UTC suffix safe)
|
||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||
if dt.timestamp() < cutoff:
|
||
dropped += 1
|
||
continue
|
||
except (ValueError, KeyError):
|
||
# Malformed line: keep it rather than silently dropping
|
||
# forensic evidence.
|
||
pass
|
||
dst.write(line if line.endswith("\n") else line + "\n")
|
||
kept += 1
|
||
os.replace(tmp_path, target)
|
||
try:
|
||
os.chmod(target, 0o600)
|
||
except OSError:
|
||
pass
|
||
logger.info(f"Audit prune: kept={kept} dropped={dropped} retention={days}d")
|
||
except Exception as e:
|
||
logger.error(f"Audit prune failed: {e}")
|
||
if os.path.exists(tmp_path):
|
||
try:
|
||
os.remove(tmp_path)
|
||
except OSError:
|
||
pass
|
||
return 0
|
||
|
||
return dropped
|