Files
clownfischserver/bot/audit_log.py
T
root f2fdf95897 v0.5.0: Add RBAC auth, LLM router, audit logging, output sanitization
- 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
2026-06-26 20:41:35 +02:00

206 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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