""" Forward audit-log events to an external syslog server (SIEM ingestion). Every row inserted into `audit_logs` is mirrored as an RFC-5424 syslog message over UDP or TCP, with a per-event severity so a SIEM can decode/rule/alert on them (login failures, lockouts, access-denied, config changes, ...). Design (ponytail): - One bounded queue + one daemon worker thread. The SQLAlchemy after_insert hook only enqueues (never blocks the request / DB flush). Bursty syncs that write thousands of VULNERABILITY_DETECTED rows drain sequentially through the single worker instead of spawning a thread per event. - Config (`syslog_config` setting) is cached for 30 s so the hot path never hits the DB. TCP keeps a persistent socket and reconnects on failure. - Disabled by default → the hook is a cheap no-op until an admin turns it on. """ from __future__ import annotations import json import logging import queue import socket import threading import time from datetime import datetime from typing import Optional logger = logging.getLogger(__name__) APP_NAME = "truevuln" _QUEUE: "queue.Queue[dict]" = queue.Queue(maxsize=10000) _worker_started = False _worker_lock = threading.Lock() # Config cache _cfg_cache: dict = {"ts": 0.0, "cfg": None} _CFG_TTL = 30.0 # RFC-5424 severities _SEV_EMERG, _SEV_ALERT, _SEV_CRIT, _SEV_ERR, _SEV_WARN, _SEV_NOTICE, _SEV_INFO, _SEV_DEBUG = range(8) def _severity_for(event_type: str) -> int: """Map an AuditEventType name to a syslog severity so a SIEM can prioritise.""" e = (event_type or "").upper() if "SECURITY_ALERT" in e or "PERMISSION_ESCALATION" in e: return _SEV_ALERT if "FAILED" in e or "DENIED" in e or "LOCK" in e: return _SEV_WARN if "DELETED" in e or "DEACTIVATED" in e or "DISABLED" in e or "CONFIG_CHANGE" in e: return _SEV_NOTICE return _SEV_INFO def _load_config() -> Optional[dict]: """Cached read of the `syslog_config` setting. Returns None when disabled/ unset. Shape: {enabled, host, port, protocol('udp'|'tcp'), facility(int)}.""" now = time.monotonic() if now - _cfg_cache["ts"] < _CFG_TTL: return _cfg_cache["cfg"] cfg = None try: from app.database import SessionLocal from app.models.setting import Setting db = SessionLocal() try: row = db.query(Setting).filter(Setting.key == "syslog_config").first() if row and row.value: parsed = json.loads(row.value) if parsed.get("enabled") and parsed.get("host"): cfg = { "host": str(parsed["host"]).strip(), "port": int(parsed.get("port") or 514), "protocol": str(parsed.get("protocol") or "udp").lower(), "facility": int(parsed.get("facility") if parsed.get("facility") is not None else 16), } finally: db.close() except Exception as e: # never let config trouble break the app logger.debug("syslog config load failed: %s", e) cfg = None _cfg_cache["ts"] = now _cfg_cache["cfg"] = cfg return cfg def _build_message(evt: dict, facility: int) -> bytes: sev = _severity_for(evt.get("event_type", "")) pri = facility * 8 + sev ts = (evt.get("timestamp") or datetime.now()).astimezone().isoformat() host = socket.gethostname() or "-" msgid = (evt.get("event_type") or "AUDIT")[:32] # MSG: human-readable + a few key=value fields for easy SIEM extraction. parts = [evt.get("event_description") or ""] if evt.get("user_id") is not None: parts.append(f"user_id={evt['user_id']}") if evt.get("resource_type"): parts.append(f"resource={evt['resource_type']}:{evt.get('resource_id') or ''}") if evt.get("ip_address"): parts.append(f"src_ip={evt['ip_address']}") msg = " ".join(p for p in parts if p) line = f"<{pri}>1 {ts} {host} {APP_NAME} - {msgid} - {msg}" return line.encode("utf-8", "replace") class _Sender: """Holds a persistent TCP socket (reconnect on failure) or a UDP socket.""" def __init__(self): self._sock: Optional[socket.socket] = None self._key: tuple = () def _ensure(self, host: str, port: int, proto: str): key = (host, port, proto) if self._sock is not None and key == self._key: return self.close() self._key = key if proto == "tcp": s = socket.create_connection((host, port), timeout=5) else: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock = s def send(self, data: bytes, host: str, port: int, proto: str): self._ensure(host, port, proto) assert self._sock is not None if proto == "tcp": # RFC 6587 non-transparent (LF) framing — accepted by rsyslog/syslog-ng. self._sock.sendall(data + b"\n") else: self._sock.sendto(data, (host, port)) def close(self): if self._sock is not None: try: self._sock.close() except Exception: pass self._sock = None self._key = () def _worker(): sender = _Sender() while True: evt = _QUEUE.get() try: cfg = _load_config() if not cfg: continue data = _build_message(evt, cfg["facility"]) try: sender.send(data, cfg["host"], cfg["port"], cfg["protocol"]) except Exception as e: sender.close() # force reconnect next time logger.debug("syslog send failed (%s:%s/%s): %s", cfg["host"], cfg["port"], cfg["protocol"], e) finally: _QUEUE.task_done() def _ensure_worker(): global _worker_started if _worker_started: return with _worker_lock: if _worker_started: return threading.Thread(target=_worker, name="syslog-forwarder", daemon=True).start() _worker_started = True def enqueue(evt: dict) -> None: """Best-effort: drop the event rather than block or raise if the queue is full or the worker can't start.""" try: _ensure_worker() _QUEUE.put_nowait(evt) except queue.Full: pass except Exception as e: logger.debug("syslog enqueue failed: %s", e) def send_test(cfg: dict) -> tuple[bool, str]: """Send a one-off test message with an explicit config (admin 'Test' button).""" try: facility = int(cfg.get("facility") if cfg.get("facility") is not None else 16) evt = {"event_type": "SECURITY_ALERT", "event_description": "TrueVuln syslog test message", "timestamp": datetime.now()} data = _build_message(evt, facility) s = _Sender() try: s.send(data, str(cfg["host"]).strip(), int(cfg.get("port") or 514), str(cfg.get("protocol") or "udp").lower()) finally: s.close() return True, "sent" except Exception as e: return False, str(e) def register_audit_listener() -> None: """Hook every AuditLog insert → enqueue. Called once at startup.""" from sqlalchemy import event from app.models.audit_log import AuditLog @event.listens_for(AuditLog, "after_insert") def _after_insert(mapper, connection, target): # noqa: ARG001 # Only scalar columns here — relationships would emit SQL mid-flush. try: enqueue({ "event_type": target.event_type.value if getattr(target, "event_type", None) else None, "event_description": target.event_description, "user_id": target.user_id, "resource_type": target.resource_type, "resource_id": target.resource_id, "ip_address": target.ip_address, "timestamp": target.timestamp, }) except Exception: pass logger.info("syslog: audit-log forwarder registered")