from __future__ import annotations import json import os import threading import time from typing import Any from .policy import PolicyEngine from .routeros import RouterOSClient from .state import RuntimeStats from .store import AlertStore from .tuning import AlertTuner class EVEWatcher(threading.Thread): def __init__( self, path: str, store: AlertStore, tuner: AlertTuner, policy: PolicyEngine, routeros: RouterOSClient, block_timeout: str, dedup_window_seconds: int, stats: RuntimeStats, stop_event: threading.Event, ) -> None: super().__init__(name="eve-watcher", daemon=True) self.path = path self.store = store self.tuner = tuner self.policy = policy self.routeros = routeros self.block_timeout = block_timeout self.dedup_window_seconds = max(0, int(dedup_window_seconds)) self.stats = stats self.stop_event = stop_event self._initial_seek_done = False def run(self) -> None: while not self.stop_event.is_set(): if not os.path.exists(self.path): time.sleep(0.5) continue try: self._follow_file() except OSError as exc: print(f"[eve] file error: {exc}", flush=True) time.sleep(1.0) def _follow_file(self) -> None: with open(self.path, "r", encoding="utf-8", errors="replace") as handle: # Ignore historical EVE only on the first attach. After rotation or # truncation read the replacement file from byte 0 so alerts that # arrived during the hand-off are not skipped. if not self._initial_seek_done: handle.seek(0, os.SEEK_END) self._initial_seek_done = True inode = os.fstat(handle.fileno()).st_ino print(f"[eve] following {self.path}", flush=True) while not self.stop_event.is_set(): line = handle.readline() if line: self._process_line(line) continue try: stat = os.stat(self.path) if stat.st_ino != inode or stat.st_size < handle.tell(): return except FileNotFoundError: return time.sleep(0.2) def _process_line(self, line: str) -> None: try: event: dict[str, Any] = json.loads(line) except json.JSONDecodeError: self.stats.inc("eve_parse_errors") return self.stats.inc("eve_events") event_type = event.get("event_type") if event_type == "stats": raw_stats = event.get("stats") if isinstance(raw_stats, dict): self.stats.update_suricata(raw_stats, str(event.get("timestamp") or "")) return if event_type != "alert": return self.stats.inc("eve_alerts") self.stats.stamp("last_alert_at") tuning = self.tuner.evaluate(event) if not tuning.keep: self.stats.inc("alerts_filtered") key = f"alerts_filtered_{tuning.reason}" self.stats.inc(key) return duplicate_id = self.store.find_recent_duplicate(event, self.dedup_window_seconds) if duplicate_id is not None: self.store.bump_duplicate(duplicate_id, event) self.stats.inc("alerts_deduplicated") return decision = self.policy.evaluate(event) blocked = False reason = decision.reason if decision.should_block and decision.target: self.stats.inc("block_attempts") alert = event.get("alert") or {} sid = alert.get("signature_id", "unknown") signature = str(alert.get("signature", "Suricata alert")) result = self.routeros.block_ip( decision.target, self.block_timeout, f"Suricata SID {sid}: {signature}", ) blocked = result.success reason = result.message if result.success else f"{decision.reason}; {result.message}" self.stats.inc("block_success" if result.success else "block_errors") self.store.insert_alert(event, blocked, decision.target, reason)