This commit is contained in:
Mateusz Gruszczyński
2026-08-15 23:43:58 +02:00
parent 71b6c0d86f
commit e8e5515e24
27 changed files with 861 additions and 64 deletions
+45 -4
View File
@@ -6,7 +6,9 @@ import subprocess
import sys
import tempfile
import threading
import re
import time
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
@@ -16,6 +18,7 @@ from .backup import BackupManager
from .config import Config
from .eve import EVEWatcher
from .flow_tracker import FlowTracker
from .forensics import ForensicPcapRing
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
from .maintenance import clear_suricata_logs, storage_info
from .ndr import NDRAnalyzer, ThreatIntelManager
@@ -45,6 +48,27 @@ def _ensure_suricata_state(cfg: Config) -> None:
Path(path).touch(exist_ok=True)
def _prepare_suricata_output_config(cfg: Config) -> Config:
source = Path(cfg.suricata_output_config)
text = source.read_text(encoding="utf-8")
match = re.search(r"(?ms)^ - pcap-log:\n.*?(?=^ - |\Z)", text)
if match is None:
raise RuntimeError("Suricata output profile has no pcap-log section")
block = match.group(0)
enabled = cfg.forensic_pcap_mode in {"alerts", "all"}
conditional = "all" if cfg.forensic_pcap_mode == "all" else "alerts"
block = re.sub(r"(?m)^ enabled: .*?$", f" enabled: {'yes' if enabled else 'no'}", block)
block = re.sub(r"(?m)^ conditional: .*?$", f" conditional: {conditional}", block)
rendered = text[:match.start()] + block + text[match.end():]
runtime = Path("/run/suricata/ids-output.runtime.yaml")
runtime.parent.mkdir(parents=True, exist_ok=True)
runtime.write_text(rendered, encoding="utf-8")
os.environ["SURICATA_OUTPUT_CONFIG"] = str(runtime)
return replace(cfg, suricata_output_config=str(runtime))
def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
return [
"-c",
@@ -75,7 +99,7 @@ def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
def main() -> int:
cfg = Config.from_env()
cfg = _prepare_suricata_output_config(Config.from_env())
stop_event = threading.Event()
stats = RuntimeStats()
started_at = datetime.now(timezone.utc)
@@ -160,6 +184,14 @@ def main() -> int:
cfg.routeros_http_timeout,
)
notifier = WebhookNotifier(cfg.notify_webhook_url, cfg.notify_min_risk, cfg.notify_timeout_seconds)
forensic_pcap = ForensicPcapRing(
cfg.forensic_pcap_mode,
log_dir,
window_seconds=cfg.forensic_pcap_window_seconds,
memory_mb=cfg.forensic_pcap_memory_mb,
max_files=cfg.forensic_pcap_max_files,
max_total_mb=cfg.forensic_pcap_max_total_mb,
)
ndr_analyzer = NDRAnalyzer(
store, threat_intel, routeros, cfg.monitored_networks, cfg.never_block, cfg.block_timeout,
enabled=cfg.ndr_enabled,
@@ -168,6 +200,7 @@ def main() -> int:
auto_block=cfg.ndr_auto_block,
auto_block_risk=cfg.ndr_auto_block_risk,
notifier=notifier,
block_evidence_callback=lambda target, label: forensic_pcap.capture_target(target, label=label),
)
redis_supervisor = RedisSupervisor(
cfg.redis_managed,
@@ -203,8 +236,12 @@ def main() -> int:
normalizer = TrafficNormalizer(cfg.monitored_networks)
flow_tracker = FlowTracker(normalizer, live_pipeline, update_interval_seconds=cfg.live_flow_update_seconds)
def observe_frame(frame: bytes) -> None:
forensic_pcap.observe(frame)
flow_tracker.observe(frame)
receiver = TZSPReceiver(
cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event, frame_observer=flow_tracker.observe
cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event, frame_observer=observe_frame
)
watcher = EVEWatcher(
cfg.eve_path,
@@ -219,6 +256,7 @@ def main() -> int:
normalizer=normalizer,
live_pipeline=live_pipeline,
ndr_analyzer=ndr_analyzer,
forensic_pcap=forensic_pcap,
)
rule_manager = RuleManager(
cfg,
@@ -237,6 +275,7 @@ def main() -> int:
storage = storage_info(cfg.db_path, cfg.eve_path)
rules = rule_manager.status()
runtime = stats.snapshot()
redis_status = redis_supervisor.status()
suri_stats = runtime.get("suricata") or {}
kernel_packets = int(suri_stats.get("capture.kernel_packets", 0) or 0)
kernel_drops = int(suri_stats.get("capture.kernel_drops", 0) or 0)
@@ -263,6 +302,7 @@ def main() -> int:
"database": db,
"storage": storage,
"rules": rules,
"redis": redis_status,
"services": {
"web": {
"name": "Web UI / API",
@@ -332,12 +372,12 @@ def main() -> int:
"redis": {
"name": "Managed Redis",
"status": (
"up" if redis_supervisor.status().get("running")
"up" if redis_status.get("ready")
else "disabled" if not cfg.redis_managed
else "degraded"
),
"details": (
f"{cfg.redis_data_dir}; maxmemory=unlimited; persistence={redis_supervisor.status().get('persistence')}"
f"{cfg.redis_data_dir}; maxmemory=unlimited; persistence={redis_status.get('persistence')}"
if cfg.redis_managed
else "Managed Redis disabled; REDIS_URL may point to an external server"
),
@@ -396,6 +436,7 @@ def main() -> int:
threat_intel=threat_intel,
ndr_analyzer=ndr_analyzer,
backup_manager=backup_manager,
forensic_pcap=forensic_pcap,
)
def housekeeping() -> None: