worked poc

This commit is contained in:
Mateusz Gruszczyński
2026-08-14 11:33:01 +02:00
parent adfdb0b86c
commit fc3a2944b2
94 changed files with 2931 additions and 3412 deletions
+39
View File
@@ -26,12 +26,21 @@ class Config:
tap_mtu: int
suricata_config: str
suricata_home_net: str
suricata_local_rules: str
suricata_extra_rules_glob: str
suricata_custom_rules: str
suricata_threshold_config: str
update_rules_on_start: bool
rule_update_interval_hours: int
web_bind: str
web_port: int
db_path: str
eve_path: str
alert_retention_days: int
alert_max_severity: int
alert_dedup_window_seconds: int
alert_ignore_sids: str
alert_ignore_categories: str
auto_block: bool
auto_block_max_severity: int
monitored_networks: str
@@ -43,6 +52,7 @@ class Config:
routeros_verify_tls: bool
routeros_address_list: str
routeros_http_timeout: int
admin_token: str
@classmethod
def from_env(cls) -> "Config":
@@ -56,12 +66,32 @@ class Config:
"SURICATA_HOME_NET",
"[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]",
),
suricata_local_rules=os.getenv(
"SURICATA_LOCAL_RULES", "/data/suricata/local.rules"
),
suricata_extra_rules_glob=os.getenv(
"SURICATA_EXTRA_RULES_GLOB", "/data/suricata/*.rules"
),
suricata_custom_rules=os.getenv(
"SURICATA_CUSTOM_RULES", "/data/suricata/custom.rules"
),
suricata_threshold_config=os.getenv(
"SURICATA_THRESHOLD_CONFIG", "/data/suricata/threshold.config"
),
update_rules_on_start=_bool("UPDATE_RULES_ON_START", False),
rule_update_interval_hours=_int("RULE_UPDATE_INTERVAL_HOURS", 24),
web_bind=os.getenv("WEB_BIND", "0.0.0.0"),
web_port=_int("WEB_PORT", 8080),
db_path=os.getenv("DB_PATH", "/data/ids.db"),
eve_path=os.getenv("EVE_PATH", "/var/log/suricata/eve.json"),
alert_retention_days=_int("ALERT_RETENTION_DAYS", 14),
# Suricata severity uses 1 as the most important value. Keeping
# 1-2 by default removes low-priority informational noise from the
# incident database while raw EVE remains available on disk.
alert_max_severity=_int("ALERT_MAX_SEVERITY", 2),
alert_dedup_window_seconds=_int("ALERT_DEDUP_WINDOW_SECONDS", 300),
alert_ignore_sids=os.getenv("ALERT_IGNORE_SIDS", "1000001"),
alert_ignore_categories=os.getenv("ALERT_IGNORE_CATEGORIES", ""),
auto_block=_bool("AUTO_BLOCK", False),
auto_block_max_severity=_int("AUTO_BLOCK_MAX_SEVERITY", 1),
monitored_networks=os.getenv("MONITORED_NETWORKS", "192.168.100.0/24"),
@@ -73,6 +103,7 @@ class Config:
routeros_verify_tls=_bool("ROUTEROS_VERIFY_TLS", False),
routeros_address_list=os.getenv("ROUTEROS_ADDRESS_LIST", "IDS-BLOCK"),
routeros_http_timeout=_int("ROUTEROS_HTTP_TIMEOUT", 5),
admin_token=os.getenv("ADMIN_TOKEN", ""),
)
def public_dict(self) -> dict:
@@ -82,7 +113,14 @@ class Config:
"tap_name": self.tap_name,
"tap_mtu": self.tap_mtu,
"suricata_home_net": self.suricata_home_net,
"suricata_extra_rules_glob": self.suricata_extra_rules_glob,
"web_port": self.web_port,
"rule_update_interval_hours": self.rule_update_interval_hours,
"alert_retention_days": self.alert_retention_days,
"alert_max_severity": self.alert_max_severity,
"alert_dedup_window_seconds": self.alert_dedup_window_seconds,
"alert_ignore_sids": self.alert_ignore_sids,
"alert_ignore_categories": self.alert_ignore_categories,
"auto_block": self.auto_block,
"auto_block_max_severity": self.auto_block_max_severity,
"monitored_networks": self.monitored_networks,
@@ -92,4 +130,5 @@ class Config:
"routeros_user": self.routeros_user,
"routeros_verify_tls": self.routeros_verify_tls,
"routeros_address_list": self.routeros_address_list,
"admin_actions_enabled": bool(self.admin_token),
}
+27 -6
View File
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
from urllib.parse import urlparse
from .config import Config
from .maintenance import storage_info
from .rules import RuleManager
from .state import RuntimeStats
from .store import AlertStore
from .webui import WebServer
@@ -33,7 +35,7 @@ def _seed_demo_alert(store: AlertStore) -> None:
"dest_port": 443,
"proto": "TCP",
"alert": {
"signature_id": 1000001,
"signature_id": 1001999,
"signature": "DEV MODE SAMPLE ALERT",
"category": "Development/Test",
"severity": 2,
@@ -64,8 +66,12 @@ def main() -> int:
_seed_demo_alert(store)
routeros_host, routeros_port = _routeros_target(cfg)
rule_manager = RuleManager(cfg, pid_provider=lambda: None, suricata_available=False)
def health() -> dict:
db = store.database_info()
storage = storage_info(cfg.db_path, cfg.eve_path)
rules = rule_manager.status()
return {
"status": "development",
"mode": "web-only-development",
@@ -77,6 +83,9 @@ def main() -> int:
"suricata_pid": None,
"auto_block": False,
"routeros_configured": False,
"database": db,
"storage": storage,
"rules": rules,
"services": {
"web": {
"name": "Web UI / API",
@@ -103,6 +112,21 @@ def main() -> int:
"status": "disabled",
"details": "EVE watcher is not started in web-only development mode",
},
"database": {
"name": "SQLite database",
"status": "up",
"details": f"{db['path']}; {db['rows']} incidents; WAL={db['journal_mode']}",
},
"storage": {
"name": "Persistent storage",
"status": "up",
"details": f"{storage['path']}; {storage['used_percent']}% used",
},
"rules": {
"name": "Managed rules",
"status": "disabled",
"details": "Editors are visible, but Suricata validation/reload requires full mode",
},
"routeros": {
"name": "RouterOS REST integration",
"status": "disabled",
@@ -138,7 +162,7 @@ def main() -> int:
"runtime": stats.snapshot(),
}
web = WebServer(cfg, store, health)
web = WebServer(cfg, store, health, stats=stats, rule_manager=rule_manager)
def request_stop(_signum=None, _frame=None) -> None:
stop_event.set()
@@ -147,10 +171,7 @@ def main() -> int:
signal.signal(signal.SIGINT, request_stop)
web.start()
print(
f"[dev] web-only mode active at http://{cfg.web_bind}:{cfg.web_port}",
flush=True,
)
print(f"[dev] web-only mode active at http://{cfg.web_bind}:{cfg.web_port}", flush=True)
try:
while not stop_event.is_set():
+33 -2
View File
@@ -10,6 +10,7 @@ from .policy import PolicyEngine
from .routeros import RouterOSClient
from .state import RuntimeStats
from .store import AlertStore
from .tuning import AlertTuner
class EVEWatcher(threading.Thread):
@@ -17,20 +18,25 @@ class EVEWatcher(threading.Thread):
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():
@@ -45,7 +51,12 @@ class EVEWatcher(threading.Thread):
def _follow_file(self) -> None:
with open(self.path, "r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
# 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)
@@ -71,11 +82,31 @@ class EVEWatcher(threading.Thread):
return
self.stats.inc("eve_events")
if event.get("event_type") != "alert":
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
+114 -12
View File
@@ -4,18 +4,23 @@ import os
import signal
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from .config import Config
from .eve import EVEWatcher
from .maintenance import storage_info
from .policy import PolicyEngine
from .routeros import RouterOSClient
from .rules import RuleManager
from .state import RuntimeStats
from .store import AlertStore
from .tap import TapDevice
from .tuning import AlertTuner
from .tzsp import TZSPReceiver
from .webui import WebServer
@@ -27,6 +32,29 @@ def _routeros_target(cfg: Config) -> tuple[str, int]:
return host, port
def _ensure_suricata_state(cfg: Config) -> None:
for path in (cfg.suricata_custom_rules, cfg.suricata_threshold_config):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).touch(exist_ok=True)
def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
return [
"-c",
cfg.suricata_config,
"-l",
log_dir,
# Suricata exposes one additive -s signature path; use its supported
# globbing so all persisted local/custom .rules files are loaded.
"-s",
cfg.suricata_extra_rules_glob,
"--set",
f"vars.address-groups.HOME_NET={cfg.suricata_home_net}",
"--set",
f"threshold-file={cfg.suricata_threshold_config}",
]
def main() -> int:
cfg = Config.from_env()
stop_event = threading.Event()
@@ -36,8 +64,12 @@ def main() -> int:
os.makedirs(os.path.dirname(cfg.eve_path) or ".", exist_ok=True)
os.makedirs(os.path.dirname(cfg.db_path) or ".", exist_ok=True)
_ensure_suricata_state(cfg)
store = AlertStore(cfg.db_path)
purged_tests = store.purge_builtin_test_incidents()
if purged_tests:
print(f"[db] removed {purged_tests} legacy pipeline-test incidents", flush=True)
purged = store.purge_older_than(cfg.alert_retention_days)
if purged:
print(f"[db] purged {purged} old alerts", flush=True)
@@ -53,21 +85,31 @@ def main() -> int:
print(f"[tap] {cfg.tap_name} is up, mtu={cfg.tap_mtu}", flush=True)
log_dir = os.path.dirname(cfg.eve_path) or "/var/log/suricata"
suricata_cmd = [
"suricata",
"-c", cfg.suricata_config,
*_suricata_common_args(cfg, log_dir),
f"--af-packet={cfg.tap_name}",
"-l", os.path.dirname(cfg.eve_path) or "/var/log/suricata",
"--user", "suricata",
"--group", "suricata",
"--set", f"vars.address-groups.HOME_NET={cfg.suricata_home_net}",
"--user",
"suricata",
"--group",
"suricata",
# Debian's default unix-command socket is directly under /var/run,
# which is not writable after Suricata drops privileges.
"--set",
"unix-command.filename=suricata/suricata-command.socket",
]
test_cmd = ["suricata", "-T", "-c", cfg.suricata_config, "--set", f"vars.address-groups.HOME_NET={cfg.suricata_home_net}"]
print("[suricata] validating configuration", flush=True)
test = subprocess.run(test_cmd, check=False)
print("[suricata] validating configuration and managed rules", flush=True)
with tempfile.TemporaryDirectory(prefix="suricata-config-test-") as test_log_dir:
test_cmd = ["suricata", "-T", *_suricata_common_args(cfg, test_log_dir)]
test = subprocess.run(test_cmd, check=False)
if test.returncode != 0:
print(f"[fatal] suricata configuration test failed with rc={test.returncode}", file=sys.stderr, flush=True)
print(
f"[fatal] suricata configuration test failed with rc={test.returncode}",
file=sys.stderr,
flush=True,
)
tap.close()
store.close()
return test.returncode or 3
@@ -77,6 +119,11 @@ def main() -> int:
with open("/run/suricata.pid", "w", encoding="ascii") as pid_file:
pid_file.write(str(suricata.pid))
tuner = AlertTuner(
cfg.alert_max_severity,
cfg.alert_ignore_sids,
cfg.alert_ignore_categories,
)
policy = PolicyEngine(
cfg.auto_block,
cfg.auto_block_max_severity,
@@ -93,7 +140,22 @@ def main() -> int:
)
receiver = TZSPReceiver(cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event)
watcher = EVEWatcher(cfg.eve_path, store, policy, routeros, cfg.block_timeout, stats, stop_event)
watcher = EVEWatcher(
cfg.eve_path,
store,
tuner,
policy,
routeros,
cfg.block_timeout,
cfg.alert_dedup_window_seconds,
stats,
stop_event,
)
rule_manager = RuleManager(
cfg,
pid_provider=lambda: suricata.pid if suricata.poll() is None else None,
suricata_available=True,
)
routeros_host, routeros_port = _routeros_target(cfg)
def health() -> dict:
@@ -102,7 +164,10 @@ def main() -> int:
tap_up = tap.fd is not None and os.path.exists(f"/sys/class/net/{cfg.tap_name}")
eve_up = watcher.is_alive()
routeros_status = "configured" if routeros.configured else "disabled"
core_up = suricata_up and tzsp_up and tap_up and eve_up
db = store.database_info()
storage = storage_info(cfg.db_path, cfg.eve_path)
rules = rule_manager.status()
core_up = suricata_up and tzsp_up and tap_up and eve_up and db["ok"]
routeros_required_ok = (not cfg.auto_block) or routeros.configured
operational = core_up and routeros_required_ok
@@ -117,6 +182,9 @@ def main() -> int:
"suricata_pid": suricata.pid,
"auto_block": cfg.auto_block,
"routeros_configured": routeros.configured,
"database": db,
"storage": storage,
"rules": rules,
"services": {
"web": {
"name": "Web UI / API",
@@ -143,6 +211,21 @@ def main() -> int:
"status": "up" if eve_up else "down",
"details": cfg.eve_path,
},
"database": {
"name": "SQLite database",
"status": "up" if db["ok"] else "down",
"details": f"{db['path']}; {db['rows']} incidents; WAL={db['journal_mode']}",
},
"storage": {
"name": "Persistent storage",
"status": "up" if storage["free_bytes"] > 0 else "down",
"details": f"{storage['path']}; {storage['used_percent']}% used",
},
"rules": {
"name": "Managed rules",
"status": "up" if rules["available"] else "disabled",
"details": f"{rules.get('builtin_rule_count', 0)} built-in; {rules['custom_rule_count']} custom; {rules['threshold_entry_count']} threshold/suppress entries",
},
"routeros": {
"name": "RouterOS REST integration",
"status": routeros_status,
@@ -178,7 +261,25 @@ def main() -> int:
"runtime": stats.snapshot(),
}
web = WebServer(cfg, store, health)
web = WebServer(cfg, store, health, stats=stats, rule_manager=rule_manager)
def housekeeping() -> None:
interval_seconds = max(0, cfg.rule_update_interval_hours) * 3600
next_rule_update = time.monotonic() + interval_seconds if interval_seconds else None
while not stop_event.wait(3600):
try:
removed = store.purge_older_than(cfg.alert_retention_days)
if removed:
print(f"[db] purged {removed} expired incidents", flush=True)
except Exception as exc:
print(f"[housekeeping] alert retention failed: {exc}", file=sys.stderr, flush=True)
if next_rule_update is not None and time.monotonic() >= next_rule_update:
result = rule_manager.update_vendor_rules()
stream = sys.stdout if result.ok else sys.stderr
print(f"[rules] scheduled update: {result.message}", file=stream, flush=True)
next_rule_update = time.monotonic() + interval_seconds
housekeeping_thread = threading.Thread(target=housekeeping, name="housekeeping", daemon=True)
def request_stop(_signum=None, _frame=None):
stop_event.set()
@@ -188,6 +289,7 @@ def main() -> int:
receiver.start()
watcher.start()
housekeeping_thread.start()
web.start()
rc = 0
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
def storage_info(data_path: str, log_path: str) -> dict:
data_dir = _existing_parent(data_path)
total, used, free = shutil.disk_usage(data_dir)
log_dir = os.path.dirname(log_path) or "/var/log/suricata"
return {
"path": data_dir,
"total_bytes": int(total),
"used_bytes": int(used),
"free_bytes": int(free),
"used_percent": round((used / total) * 100.0, 2) if total else 0.0,
"suricata_log_bytes": directory_size(log_dir, limit_files=500),
"containerized": _detect_container(),
"hostname": os.uname().nodename,
}
def clear_suricata_logs(eve_path: str) -> dict:
log_dir = os.path.realpath(os.path.dirname(eve_path) or "/var/log/suricata")
allowed_names = {
os.path.basename(eve_path),
"fast.log",
"stats.log",
"suricata.log",
}
cleared: list[dict] = []
for name in sorted(allowed_names):
path = os.path.realpath(os.path.join(log_dir, name))
if os.path.dirname(path) != log_dir:
continue
try:
stat = os.stat(path)
except FileNotFoundError:
continue
if not os.path.isfile(path):
continue
size = int(stat.st_size)
with open(path, "w", encoding="utf-8"):
pass
cleared.append({"name": name, "bytes": size})
return {
"files": cleared,
"bytes_freed": sum(item["bytes"] for item in cleared),
}
def directory_size(path: str, limit_files: int = 500) -> int:
total = 0
count = 0
try:
entries = Path(path).iterdir()
except OSError:
return 0
for item in entries:
if count >= limit_files:
break
count += 1
try:
if item.is_file():
total += int(item.stat().st_size)
except OSError:
continue
return total
def _existing_parent(path: str) -> str:
candidate = os.path.abspath(os.path.dirname(path) or ".")
while not os.path.exists(candidate):
parent = os.path.dirname(candidate)
if parent == candidate:
return "/"
candidate = parent
return candidate
def _detect_container() -> bool:
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return True
try:
text = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="replace").lower()
except OSError:
return False
return any(token in text for token in ("docker", "containerd", "kubepods", "libpod", "lxc"))
+2 -3
View File
@@ -32,9 +32,6 @@ class PolicyEngine:
except (TypeError, ValueError):
return Decision(False, None, "missing or invalid severity")
if severity > self.max_severity:
return Decision(False, None, f"severity {severity} is below block threshold")
src = _ip(event.get("src_ip"))
dst = _ip(event.get("dest_ip"))
if src is None or dst is None:
@@ -52,6 +49,8 @@ class PolicyEngine:
return Decision(False, str(target), "remote endpoint is on NEVER_BLOCK list")
if not self.auto_block:
return Decision(False, str(target), "observation mode: AUTO_BLOCK=false")
if severity > self.max_severity:
return Decision(False, str(target), f"severity {severity} is below block threshold")
return Decision(True, str(target), f"severity {severity} matched automatic block policy")
def _is_monitored(self, address: ipaddress._BaseAddress) -> bool:
+524
View File
@@ -0,0 +1,524 @@
from __future__ import annotations
import glob
import ipaddress
import os
import re
import shutil
import signal
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from .config import Config
@dataclass(frozen=True)
class RuleActionResult:
ok: bool
message: str
class RuleManager:
MAX_RULE_BYTES = 512 * 1024
MAX_THRESHOLD_BYTES = 256 * 1024
SOURCE_INDEX_URL = "https://www.openinfosecfoundation.org/rules/index.yaml"
DEFAULT_SOURCE = "et/open"
SOURCE_NAME_RE = re.compile(r"^[A-Za-z0-9_.+-]+/[A-Za-z0-9_.+-]+$")
def __init__(
self,
config: Config,
pid_provider: Callable[[], int | None],
suricata_available: bool = True,
) -> None:
self.config = config
self.pid_provider = pid_provider
self.suricata_available = suricata_available
self._lock = threading.RLock()
self._operation_lock = threading.RLock()
self._update_lock = threading.Lock()
self._last_result = "not changed"
self._ensure_files()
def _ensure_files(self) -> None:
for path in (self.config.suricata_custom_rules, self.config.suricata_threshold_config):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).touch(exist_ok=True)
def status(self) -> dict:
custom = self._read(self.config.suricata_custom_rules)
builtin = self._read(self.config.suricata_local_rules)
threshold = self._read(self.config.suricata_threshold_config)
with self._lock:
last_result = self._last_result
vendor_rules = "/var/lib/suricata/rules/suricata.rules"
source_index = _first_existing_path(
"/var/lib/suricata/update/cache/index.yaml",
"/var/lib/suricata/rules/cache/index.yaml",
)
return {
"available": self.suricata_available,
"custom_rules_path": self.config.suricata_custom_rules,
"extra_rules_glob": self.config.suricata_extra_rules_glob,
"threshold_config_path": self.config.suricata_threshold_config,
"builtin_rule_count": _count_rules(builtin),
"custom_rule_count": _count_rules(custom),
"managed_rule_files": len(glob.glob(self.config.suricata_extra_rules_glob)),
"threshold_entry_count": _count_config_entries(threshold),
"suppressed_sids": _suppressed_sids(threshold),
"vendor_rules_path": vendor_rules,
"vendor_rules_size_bytes": _file_size(vendor_rules),
"vendor_rules_updated_at": _file_mtime_iso(vendor_rules),
"source_index_updated_at": _file_mtime_iso(source_index) if source_index else None,
"source_index_url": self.SOURCE_INDEX_URL,
"last_result": last_result,
}
def content(self) -> dict:
return {
"custom_rules": self._read(self.config.suricata_custom_rules),
"threshold_config": self._read(self.config.suricata_threshold_config),
"status": self.status(),
}
def replace_custom_rules(self, content: str) -> RuleActionResult:
return self._replace_and_reload(
self.config.suricata_custom_rules,
content,
self.MAX_RULE_BYTES,
"custom rules",
)
def replace_threshold_config(self, content: str) -> RuleActionResult:
return self._replace_and_reload(
self.config.suricata_threshold_config,
content,
self.MAX_THRESHOLD_BYTES,
"threshold configuration",
)
def suppress_sid(
self,
sid: int,
track: str | None = None,
ip: str | None = None,
) -> RuleActionResult:
sid = int(sid)
if sid <= 0:
return RuleActionResult(False, "SID must be a positive integer")
track = (track or "").strip().lower()
if track in {"", "global"}:
line = f"suppress gen_id 1, sig_id {sid}"
label = f"SID {sid}"
elif track in {"by_src", "by_dst"}:
if not ip:
return RuleActionResult(False, "IP is required for scoped suppression")
try:
network = ipaddress.ip_network(str(ip).strip(), strict=False)
except ValueError:
return RuleActionResult(False, "invalid suppression IP/network")
ip_text = str(network.network_address) if network.prefixlen == network.max_prefixlen else str(network)
line = f"suppress gen_id 1, sig_id {sid}, track {track}, ip {ip_text}"
label = f"SID {sid} {track} {ip_text}"
else:
return RuleActionResult(False, "track must be global, by_src or by_dst")
with self._operation_lock:
current = self._read(self.config.suricata_threshold_config)
existing = {item.strip().casefold() for item in current.splitlines() if item.strip()}
if line.casefold() in existing:
return RuleActionResult(True, f"{label} is already suppressed")
if current and not current.endswith("\n"):
current += "\n"
current += line + "\n"
return self.replace_threshold_config(current)
def update_vendor_rules(self) -> RuleActionResult:
if not self.suricata_available:
return RuleActionResult(False, "Suricata rule updates are unavailable in this mode")
if not self._update_lock.acquire(blocking=False):
return RuleActionResult(False, "a Suricata rule-source operation is already running")
try:
return self._run_vendor_update_unlocked()
finally:
self._update_lock.release()
def source_catalog(self) -> dict:
if not self.suricata_available:
return {
"ok": False,
"error": "Suricata rule sources are unavailable in this mode",
"sources": [],
}
catalog = self._run_suricata_update(["list-sources", "--free"], timeout=60)
if catalog.returncode != 0:
return {
"ok": False,
"error": _command_tail(catalog.stdout, "could not list rule sources"),
"sources": [],
}
enabled_proc = self._run_suricata_update(["list-sources", "--enabled"], timeout=30)
enabled = _parse_enabled_sources(enabled_proc.stdout or "") if enabled_proc.returncode == 0 else set()
sources = _parse_source_catalog(catalog.stdout or "")
for source in sources:
source["default"] = source["name"] == self.DEFAULT_SOURCE
source["enabled"] = source["default"] or source["name"] in enabled
source["can_toggle"] = not source["default"] and not bool(source.get("parameters"))
return {
"ok": True,
"catalog": "OISF suricata-update source index",
"catalog_url": self.SOURCE_INDEX_URL,
"free_only": True,
"sources": sources,
"enabled_sources": sorted(
{source["name"] for source in sources if source.get("enabled")}
),
"status": self.status(),
}
def refresh_source_catalog(self) -> RuleActionResult:
if not self.suricata_available:
return RuleActionResult(False, "Suricata rule sources are unavailable in this mode")
if not self._update_lock.acquire(blocking=False):
return RuleActionResult(False, "a Suricata rule-source operation is already running")
try:
proc = self._run_suricata_update(["update-sources"], timeout=120)
if proc.returncode == 0:
result = RuleActionResult(True, _command_tail(proc.stdout, "OISF source catalog refreshed"))
else:
result = RuleActionResult(False, _command_tail(proc.stdout, "OISF source catalog refresh failed"))
with self._lock:
self._last_result = result.message
return result
finally:
self._update_lock.release()
def set_source_enabled(self, source_name: str, enabled: bool) -> RuleActionResult:
source_name = str(source_name or "").strip()
if not self.SOURCE_NAME_RE.fullmatch(source_name):
return RuleActionResult(False, "invalid rule source name")
if source_name == self.DEFAULT_SOURCE:
if enabled:
return RuleActionResult(True, "ET/Open is the default suricata-update source and is already active")
return RuleActionResult(False, "ET/Open is the default source and cannot be disabled from this panel")
if not self.suricata_available:
return RuleActionResult(False, "Suricata rule sources are unavailable in this mode")
if not self._update_lock.acquire(blocking=False):
return RuleActionResult(False, "a Suricata rule-source operation is already running")
try:
catalog = self.source_catalog()
if not catalog.get("ok"):
return RuleActionResult(False, str(catalog.get("error") or "could not read source catalog"))
source = next((item for item in catalog.get("sources", []) if item.get("name") == source_name), None)
if source is None:
return RuleActionResult(False, "source is not present in the current OISF catalog")
if enabled and source.get("parameters"):
params = ", ".join(source["parameters"])
return RuleActionResult(False, f"source requires parameters ({params}); configure it manually with suricata-update")
if bool(source.get("enabled")) == bool(enabled):
return RuleActionResult(True, f"{source_name} is already {'enabled' if enabled else 'disabled'}")
verb = "enable-source" if enabled else "disable-source"
proc = self._run_suricata_update([verb, source_name], timeout=60)
if proc.returncode != 0:
result = RuleActionResult(False, _command_tail(proc.stdout, f"could not {verb} {source_name}"))
else:
updated = self._run_vendor_update_unlocked()
if updated.ok:
result = RuleActionResult(
True,
f"{source_name} {'enabled' if enabled else 'disabled'}; {updated.message}",
)
else:
result = RuleActionResult(
False,
f"{source_name} {'enabled' if enabled else 'disabled'}, but rules were not rebuilt: {updated.message}",
)
with self._lock:
self._last_result = result.message
return result
finally:
self._update_lock.release()
def _run_vendor_update_unlocked(self) -> RuleActionResult:
try:
proc = subprocess.run(
["/opt/ids/scripts/update-rules.sh"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=300,
)
except (OSError, subprocess.TimeoutExpired) as exc:
result = RuleActionResult(False, f"vendor rule update could not run: {exc}")
else:
tail = _command_tail(proc.stdout, "vendor rules updated")
if proc.returncode == 0:
result = RuleActionResult(True, tail)
else:
result = RuleActionResult(False, f"vendor rule update failed: {tail}")
with self._lock:
self._last_result = result.message
return result
@staticmethod
def _run_suricata_update(args: list[str], timeout: int) -> subprocess.CompletedProcess:
try:
return subprocess.run(
["suricata-update", *args],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return subprocess.CompletedProcess(
["suricata-update", *args],
127,
stdout=f"suricata-update could not run: {exc}",
)
def reload(self) -> RuleActionResult:
if not self.suricata_available:
return RuleActionResult(False, "Suricata is not available in this mode")
pid = self.pid_provider()
if not pid:
return RuleActionResult(False, "Suricata process is not running")
try:
os.kill(int(pid), signal.SIGUSR2)
except OSError as exc:
result = RuleActionResult(False, f"reload failed: {exc}")
else:
result = RuleActionResult(True, f"rule reload requested for Suricata PID {pid}")
with self._lock:
self._last_result = result.message
return result
def validate(self, custom_rules: str, threshold_config: str) -> RuleActionResult:
if not self.suricata_available:
return RuleActionResult(False, "Suricata validation is unavailable in web-only development mode")
with tempfile.TemporaryDirectory(prefix="suricata-rules-test-") as td:
rules_dir = os.path.join(td, "rules")
threshold_path = os.path.join(td, "threshold.config")
log_dir = os.path.join(td, "log")
os.mkdir(rules_dir)
os.mkdir(log_dir)
custom_real = os.path.realpath(self.config.suricata_custom_rules)
copied = set()
for source in glob.glob(self.config.suricata_extra_rules_glob):
if os.path.realpath(source) == custom_real or not os.path.isfile(source):
continue
name = os.path.basename(source)
shutil.copyfile(source, os.path.join(rules_dir, name))
copied.add(name)
local_name = os.path.basename(self.config.suricata_local_rules) or "local.rules"
if local_name not in copied and os.path.isfile(self.config.suricata_local_rules):
shutil.copyfile(self.config.suricata_local_rules, os.path.join(rules_dir, local_name))
self._write(os.path.join(rules_dir, os.path.basename(self.config.suricata_custom_rules) or "custom.rules"), custom_rules)
self._write(threshold_path, threshold_config)
cmd = [
"suricata",
"-T",
"-c",
self.config.suricata_config,
"-l",
log_dir,
"-s",
os.path.join(rules_dir, "*.rules"),
"--set",
f"vars.address-groups.HOME_NET={self.config.suricata_home_net}",
"--set",
f"threshold-file={threshold_path}",
]
try:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=45,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return RuleActionResult(False, f"validation could not run: {exc}")
if proc.returncode == 0:
return RuleActionResult(True, "Suricata configuration and rules validated")
output = (proc.stdout or "").strip().splitlines()
tail = " | ".join(output[-8:])
if len(tail) > 1200:
tail = tail[-1200:]
return RuleActionResult(False, f"Suricata validation failed: {tail or 'unknown error'}")
def _replace_and_reload(
self,
path: str,
content: str,
max_bytes: int,
label: str,
) -> RuleActionResult:
if not isinstance(content, str):
return RuleActionResult(False, f"{label} must be text")
if len(content.encode("utf-8")) > max_bytes:
return RuleActionResult(False, f"{label} exceeds {max_bytes} bytes")
with self._operation_lock:
custom = content if path == self.config.suricata_custom_rules else self._read(self.config.suricata_custom_rules)
threshold = content if path == self.config.suricata_threshold_config else self._read(self.config.suricata_threshold_config)
validation = self.validate(custom, threshold)
if not validation.ok:
with self._lock:
self._last_result = validation.message
return validation
self._atomic_write(path, content)
reload_result = self.reload()
if reload_result.ok:
result = RuleActionResult(True, f"{label} saved; {reload_result.message}")
else:
result = RuleActionResult(False, f"{label} saved but {reload_result.message}")
with self._lock:
self._last_result = result.message
return result
@staticmethod
def _read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read()
except FileNotFoundError:
return ""
@staticmethod
def _write(path: str, content: str) -> None:
with open(path, "w", encoding="utf-8") as handle:
handle.write(content)
if content and not content.endswith("\n"):
handle.write("\n")
@classmethod
def _atomic_write(cls, path: str, content: str) -> None:
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".rules-", dir=directory, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(content)
if content and not content.endswith("\n"):
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp, 0o644)
os.replace(tmp, path)
finally:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _strip_ansi(value: str) -> str:
return _ANSI_RE.sub("", value or "")
def _parse_source_catalog(output: str) -> list[dict]:
sources: list[dict] = []
current: dict | None = None
for raw in _strip_ansi(output).splitlines():
line = raw.strip()
if line.startswith("Name:"):
if current and current.get("name"):
sources.append(current)
current = {
"name": line.split(":", 1)[1].strip(),
"vendor": "",
"summary": "",
"license": "",
"tags": [],
"parameters": [],
}
continue
if current is None or ":" not in line:
continue
key, value = (part.strip() for part in line.split(":", 1))
key = key.lower()
if key in {"vendor", "summary", "license", "subscription", "deprecated", "obsolete"}:
current[key] = value
elif key in {"tags", "parameters", "replaces"}:
current[key] = [part.strip() for part in value.split(",") if part.strip()]
if current and current.get("name"):
sources.append(current)
return sources
def _parse_enabled_sources(output: str) -> set[str]:
result: set[str] = set()
for raw in _strip_ansi(output).splitlines():
match = re.match(r"^\s*-\s+([A-Za-z0-9_.+-]+/[A-Za-z0-9_.+-]+)\s*$", raw)
if match:
result.add(match.group(1))
return result
def _command_tail(output: str | None, fallback: str) -> str:
lines = [line.strip() for line in _strip_ansi(output or "").splitlines() if line.strip()]
tail = " | ".join(lines[-8:])
if len(tail) > 1400:
tail = tail[-1400:]
return tail or fallback
def _first_existing_path(*paths: str) -> str | None:
return next((path for path in paths if os.path.isfile(path)), None)
def _file_size(path: str) -> int:
try:
return os.path.getsize(path)
except OSError:
return 0
def _file_mtime_iso(path: str | None) -> str | None:
if not path:
return None
try:
timestamp = os.path.getmtime(path)
except OSError:
return None
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
def _count_rules(content: str) -> int:
return sum(
1
for line in content.splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
def _count_config_entries(content: str) -> int:
return sum(
1
for line in content.splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
def _suppressed_sids(content: str) -> list[int]:
result: set[int] = set()
for match in re.finditer(r"^\s*suppress\s+gen_id\s+1\s*,\s*sig_id\s+(\d+)", content, re.I | re.M):
result.add(int(match.group(1)))
return sorted(result)
+56 -2
View File
@@ -2,12 +2,19 @@ from __future__ import annotations
import threading
from datetime import datetime, timezone
from typing import Any
class RuntimeStats:
def __init__(self) -> None:
self._lock = threading.Lock()
self._data = {
self._data = self._new_counters()
self._suricata: dict[str, int | float] = {}
self._suricata_timestamp: str | None = None
@staticmethod
def _new_counters() -> dict[str, Any]:
return {
"tzsp_datagrams": 0,
"tzsp_decode_errors": 0,
"tzsp_unsupported": 0,
@@ -16,6 +23,11 @@ class RuntimeStats:
"eve_events": 0,
"eve_alerts": 0,
"eve_parse_errors": 0,
"alerts_filtered": 0,
"alerts_filtered_low_priority": 0,
"alerts_filtered_ignored_sid": 0,
"alerts_filtered_ignored_category": 0,
"alerts_deduplicated": 0,
"block_attempts": 0,
"block_success": 0,
"block_errors": 0,
@@ -31,6 +43,48 @@ class RuntimeStats:
with self._lock:
self._data[key] = datetime.now(timezone.utc).isoformat()
def update_suricata(self, stats: dict[str, Any], timestamp: str | None = None) -> None:
flattened: dict[str, int | float] = {}
_flatten_numeric("", stats, flattened, 240)
with self._lock:
self._suricata = flattened
self._suricata_timestamp = timestamp or datetime.now(timezone.utc).isoformat()
def reset(self) -> None:
with self._lock:
last_packet = self._data.get("last_packet_at")
last_alert = self._data.get("last_alert_at")
self._data = self._new_counters()
self._data["last_packet_at"] = last_packet
self._data["last_alert_at"] = last_alert
def snapshot(self) -> dict:
with self._lock:
return dict(self._data)
data = dict(self._data)
data["suricata"] = dict(self._suricata)
data["suricata_stats_at"] = self._suricata_timestamp
datagrams = int(data.get("tzsp_datagrams", 0))
frames = int(data.get("frames_injected", 0))
data["tzsp_to_tap_loss"] = max(datagrams - frames, 0)
attempts = int(data.get("block_attempts", 0))
success = int(data.get("block_success", 0))
data["block_success_rate"] = round((success / attempts) * 100.0, 2) if attempts else None
return data
def _flatten_numeric(
prefix: str,
value: Any,
output: dict[str, int | float],
limit: int,
) -> None:
if len(output) >= limit:
return
if isinstance(value, dict):
for key, child in value.items():
name = f"{prefix}.{key}" if prefix else str(key)
_flatten_numeric(name, child, output, limit)
if len(output) >= limit:
return
elif isinstance(value, (int, float)) and not isinstance(value, bool):
output[prefix] = value
+346 -16
View File
@@ -9,23 +9,30 @@ from typing import Any
class AlertStore:
SCHEMA_VERSION = 4
def __init__(self, path: str) -> None:
self.path = path
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
self._lock = threading.Lock()
self._lock = threading.RLock()
self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._init_schema()
def _init_schema(self) -> None:
with self._lock:
previous_version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
self._conn.executescript(
"""
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
first_seen TEXT,
last_seen TEXT,
hit_count INTEGER NOT NULL DEFAULT 1,
flow_id TEXT,
src_ip TEXT,
src_port INTEGER,
@@ -42,13 +49,143 @@ class AlertStore:
block_reason TEXT,
raw_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_alerts_signature_id ON alerts(signature_id);
CREATE INDEX IF NOT EXISTS idx_alerts_blocked ON alerts(blocked);
"""
)
# Existing 0.3.x databases do not have last_seen/hit_count. Add
# columns before creating indexes that reference the new schema.
self._migrate_columns()
self._conn.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_alerts_last_seen ON alerts(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_alerts_signature_id ON alerts(signature_id);
CREATE INDEX IF NOT EXISTS idx_alerts_blocked ON alerts(blocked);
CREATE INDEX IF NOT EXISTS idx_alerts_src_ip ON alerts(src_ip);
CREATE INDEX IF NOT EXISTS idx_alerts_dest_ip ON alerts(dest_ip);
"""
)
self._conn.execute(
"UPDATE alerts SET first_seen=COALESCE(first_seen,timestamp), "
"last_seen=COALESCE(last_seen,timestamp), hit_count=COALESCE(hit_count,1)"
)
self._normalise_existing_timestamps()
if previous_version < self.SCHEMA_VERSION:
self._compact_existing_incidents(300)
self._conn.execute(f"PRAGMA user_version={self.SCHEMA_VERSION}")
self._conn.commit()
def _migrate_columns(self) -> None:
columns = {
str(row["name"])
for row in self._conn.execute("PRAGMA table_info(alerts)").fetchall()
}
additions = {
"first_seen": "TEXT",
"last_seen": "TEXT",
"hit_count": "INTEGER NOT NULL DEFAULT 1",
}
for name, definition in additions.items():
if name not in columns:
self._conn.execute(f"ALTER TABLE alerts ADD COLUMN {name} {definition}")
def _normalise_existing_timestamps(self) -> None:
rows = self._conn.execute(
"SELECT id, timestamp, first_seen, last_seen FROM alerts"
).fetchall()
for row in rows:
timestamp = _normalise_timestamp(row["timestamp"])
first_seen = _normalise_timestamp(row["first_seen"] or row["timestamp"])
last_seen = _normalise_timestamp(row["last_seen"] or row["timestamp"])
if (
timestamp != row["timestamp"]
or first_seen != row["first_seen"]
or last_seen != row["last_seen"]
):
self._conn.execute(
"UPDATE alerts SET timestamp=?, first_seen=?, last_seen=? WHERE id=?",
(timestamp, first_seen, last_seen, int(row["id"])),
)
def _compact_existing_incidents(self, window_seconds: int) -> int:
"""Merge legacy duplicate rows created before incident aggregation existed."""
rows = self._conn.execute(
"""
SELECT id, timestamp, first_seen, last_seen, hit_count,
src_ip, dest_ip, dest_port, proto, signature_id,
blocked, block_target, block_reason, raw_json
FROM alerts
ORDER BY signature_id, src_ip, dest_ip, dest_port, proto,
COALESCE(first_seen,timestamp), id
"""
).fetchall()
groups: dict[tuple[Any, ...], list[sqlite3.Row]] = {}
for row in rows:
key = (
row["signature_id"], row["src_ip"], row["dest_ip"],
row["dest_port"], row["proto"],
)
groups.setdefault(key, []).append(row)
merged_rows = 0
for group_rows in groups.values():
current: list[sqlite3.Row] = []
current_start: datetime | None = None
for row in group_rows:
row_first = _parse_timestamp(row["first_seen"] or row["timestamp"])
if (
current
and current_start is not None
and (row_first - current_start).total_seconds() > window_seconds
):
merged_rows += self._merge_row_group(current)
current = []
current_start = None
if current_start is None:
current_start = row_first
current.append(row)
if current:
merged_rows += self._merge_row_group(current)
return merged_rows
def _merge_row_group(self, rows: list[sqlite3.Row]) -> int:
if len(rows) < 2:
return 0
keep = rows[0]
latest = max(rows, key=lambda row: _parse_timestamp(row["last_seen"] or row["timestamp"]))
first_seen = min(_parse_timestamp(row["first_seen"] or row["timestamp"]) for row in rows).isoformat()
last_seen = max(_parse_timestamp(row["last_seen"] or row["timestamp"]) for row in rows).isoformat()
hit_count = sum(max(1, int(row["hit_count"] or 1)) for row in rows)
blocked_rows = [row for row in rows if int(row["blocked"] or 0)]
block_row = blocked_rows[-1] if blocked_rows else latest
self._conn.execute(
"""
UPDATE alerts
SET timestamp=?, first_seen=?, last_seen=?, hit_count=?,
blocked=?, block_target=?, block_reason=?, raw_json=?
WHERE id=?
""",
(
last_seen, first_seen, last_seen, hit_count,
1 if blocked_rows else 0,
block_row["block_target"], block_row["block_reason"], latest["raw_json"],
int(keep["id"]),
),
)
ids = [int(row["id"]) for row in rows[1:]]
placeholders = ",".join("?" for _ in ids)
self._conn.execute(f"DELETE FROM alerts WHERE id IN ({placeholders})", ids)
return len(ids)
def purge_builtin_test_incidents(self) -> int:
with self._lock:
# SID 1000001 is reserved by this project for the deterministic
# TZSP self-test and should never become a production incident.
cursor = self._conn.execute(
"DELETE FROM alerts WHERE signature_id=1000001"
)
self._conn.commit()
return int(cursor.rowcount)
def insert_alert(
self,
event: dict[str, Any],
@@ -57,8 +194,12 @@ class AlertStore:
block_reason: str,
) -> int:
alert = event.get("alert") or {}
timestamp = _normalise_timestamp(event.get("timestamp"))
values = (
str(event.get("timestamp") or datetime.now(timezone.utc).isoformat()),
timestamp,
timestamp,
timestamp,
1,
str(event.get("flow_id") or ""),
event.get("src_ip"),
event.get("src_port"),
@@ -79,25 +220,86 @@ class AlertStore:
cursor = self._conn.execute(
"""
INSERT INTO alerts (
timestamp, flow_id, src_ip, src_port, dest_ip, dest_port, proto,
timestamp, first_seen, last_seen, hit_count, flow_id,
src_ip, src_port, dest_ip, dest_port, proto,
signature_id, signature, category, severity, action,
blocked, block_target, block_reason, raw_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values,
)
self._conn.commit()
return int(cursor.lastrowid)
def find_recent_duplicate(self, event: dict[str, Any], window_seconds: int) -> int | None:
if window_seconds <= 0:
return None
alert = event.get("alert") or {}
sid = _as_int(alert.get("signature_id"))
if sid is None:
return None
# Use the event timestamp instead of wall-clock time. EVE timestamps can
# arrive with different UTC offsets and may be delayed slightly by log
# rotation. Comparing normalized event time keeps aggregation stable.
event_time = datetime.fromisoformat(
_normalise_timestamp(event.get("timestamp")).replace("Z", "+00:00")
)
cutoff = (event_time - timedelta(seconds=window_seconds)).isoformat()
upper = (event_time + timedelta(seconds=window_seconds)).isoformat()
values = (
sid,
event.get("src_ip"),
event.get("dest_ip"),
event.get("dest_port"),
event.get("proto"),
cutoff,
upper,
)
with self._lock:
row = self._conn.execute(
"""
SELECT id FROM alerts
WHERE signature_id=?
AND src_ip IS ?
AND dest_ip IS ?
AND dest_port IS ?
AND proto IS ?
AND COALESCE(first_seen,timestamp) BETWEEN ? AND ?
ORDER BY COALESCE(first_seen,timestamp) DESC, id DESC LIMIT 1
""",
values,
).fetchone()
return int(row["id"]) if row else None
def bump_duplicate(self, alert_id: int, event: dict[str, Any]) -> None:
timestamp = _normalise_timestamp(event.get("timestamp"))
raw = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
with self._lock:
self._conn.execute(
"""
UPDATE alerts
SET timestamp=MAX(timestamp, ?),
first_seen=MIN(COALESCE(first_seen,timestamp), ?),
last_seen=MAX(COALESCE(last_seen,timestamp), ?),
hit_count=COALESCE(hit_count,1)+1,
raw_json=?
WHERE id=?
""",
(timestamp, timestamp, timestamp, raw, int(alert_id)),
)
self._conn.commit()
def recent(self, limit: int = 100) -> list[dict[str, Any]]:
limit = min(max(int(limit), 1), 500)
with self._lock:
rows = self._conn.execute(
"""
SELECT id, timestamp, src_ip, src_port, dest_ip, dest_port, proto,
SELECT id, timestamp, first_seen, last_seen, hit_count,
src_ip, src_port, dest_ip, dest_port, proto,
signature_id, signature, category, severity, action,
blocked, block_target, block_reason
FROM alerts ORDER BY id DESC LIMIT ?
FROM alerts ORDER BY COALESCE(last_seen,timestamp) DESC, id DESC LIMIT ?
""",
(limit,),
).fetchall()
@@ -110,15 +312,105 @@ class AlertStore:
def summary(self) -> dict[str, Any]:
with self._lock:
total = self._conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0]
blocked = self._conn.execute("SELECT COUNT(*) FROM alerts WHERE blocked=1").fetchone()[0]
row = self._conn.execute(
"""
SELECT COUNT(*) AS incidents,
COALESCE(SUM(hit_count),0) AS total_alerts,
COALESCE(SUM(CASE WHEN blocked=1 THEN 1 ELSE 0 END),0) AS blocked_alerts,
COUNT(DISTINCT signature_id) AS unique_signatures
FROM alerts
"""
).fetchone()
sev = self._conn.execute(
"SELECT severity, COUNT(*) AS count FROM alerts GROUP BY severity ORDER BY severity"
"""
SELECT severity, COALESCE(SUM(hit_count),0) AS count
FROM alerts GROUP BY severity ORDER BY severity
"""
).fetchall()
return {
"total_alerts": int(total),
"blocked_alerts": int(blocked),
"by_severity": {str(row["severity"]): int(row["count"]) for row in sev},
"total_alerts": int(row["total_alerts"]),
"incidents": int(row["incidents"]),
"blocked_alerts": int(row["blocked_alerts"]),
"unique_signatures": int(row["unique_signatures"]),
"by_severity": {str(item["severity"]): int(item["count"]) for item in sev},
}
def analytics(self, top_limit: int = 8) -> dict[str, Any]:
top_limit = min(max(int(top_limit), 1), 25)
now = datetime.now(timezone.utc)
cutoff_1h = (now - timedelta(hours=1)).isoformat()
cutoff_24h = (now - timedelta(hours=24)).isoformat()
with self._lock:
windows = self._conn.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(last_seen,timestamp)>=? THEN hit_count ELSE 0 END),0) AS alerts_1h,
COALESCE(SUM(CASE WHEN COALESCE(last_seen,timestamp)>=? THEN hit_count ELSE 0 END),0) AS alerts_24h,
COUNT(DISTINCT CASE WHEN COALESCE(last_seen,timestamp)>=? THEN src_ip END) AS sources_24h,
COUNT(DISTINCT CASE WHEN COALESCE(last_seen,timestamp)>=? THEN signature_id END) AS signatures_24h
FROM alerts
""",
(cutoff_1h, cutoff_24h, cutoff_24h, cutoff_24h),
).fetchone()
top_signatures = self._conn.execute(
"""
SELECT signature_id, signature, severity,
COALESCE(SUM(hit_count),0) AS count,
MAX(COALESCE(last_seen,timestamp)) AS last_seen
FROM alerts
WHERE COALESCE(last_seen,timestamp)>=?
GROUP BY signature_id, signature, severity
ORDER BY count DESC, last_seen DESC LIMIT ?
""",
(cutoff_24h, top_limit),
).fetchall()
top_sources = self._conn.execute(
"""
SELECT src_ip, COALESCE(SUM(hit_count),0) AS count,
MAX(COALESCE(last_seen,timestamp)) AS last_seen
FROM alerts
WHERE COALESCE(last_seen,timestamp)>=? AND src_ip IS NOT NULL
GROUP BY src_ip ORDER BY count DESC, last_seen DESC LIMIT ?
""",
(cutoff_24h, top_limit),
).fetchall()
top_destinations = self._conn.execute(
"""
SELECT dest_ip, COALESCE(SUM(hit_count),0) AS count,
MAX(COALESCE(last_seen,timestamp)) AS last_seen
FROM alerts
WHERE COALESCE(last_seen,timestamp)>=? AND dest_ip IS NOT NULL
GROUP BY dest_ip ORDER BY count DESC, last_seen DESC LIMIT ?
""",
(cutoff_24h, top_limit),
).fetchall()
return {
"alerts_1h": int(windows["alerts_1h"]),
"alerts_24h": int(windows["alerts_24h"]),
"sources_24h": int(windows["sources_24h"]),
"signatures_24h": int(windows["signatures_24h"]),
"top_signatures": [dict(row) for row in top_signatures],
"top_sources": [dict(row) for row in top_sources],
"top_destinations": [dict(row) for row in top_destinations],
}
def database_info(self) -> dict[str, Any]:
with self._lock:
self._conn.execute("SELECT 1").fetchone()
journal_mode = str(self._conn.execute("PRAGMA journal_mode").fetchone()[0])
user_version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
row_count = int(self._conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0])
size = _file_size(self.path)
wal_size = _file_size(self.path + "-wal")
return {
"ok": True,
"path": self.path,
"exists": os.path.exists(self.path),
"size_bytes": size,
"wal_size_bytes": wal_size,
"rows": row_count,
"journal_mode": journal_mode,
"schema_version": user_version,
}
def purge_older_than(self, days: int) -> int:
@@ -126,15 +418,53 @@ class AlertStore:
return 0
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
with self._lock:
cursor = self._conn.execute("DELETE FROM alerts WHERE timestamp < ?", (cutoff,))
cursor = self._conn.execute(
"DELETE FROM alerts WHERE COALESCE(last_seen,timestamp) < ?", (cutoff,)
)
self._conn.commit()
return int(cursor.rowcount)
def clear_alerts(self) -> int:
with self._lock:
count = int(self._conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0])
self._conn.execute("DELETE FROM alerts")
self._conn.commit()
return count
def vacuum(self) -> None:
with self._lock:
self._conn.execute("VACUUM")
def close(self) -> None:
with self._lock:
self._conn.close()
def _parse_timestamp(value: Any) -> datetime:
text = _normalise_timestamp(value)
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def _normalise_timestamp(value: Any) -> str:
if value not in (None, ""):
text = str(value).strip()
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat()
except ValueError:
pass
return datetime.now(timezone.utc).isoformat()
def _file_size(path: str) -> int:
try:
return int(os.path.getsize(path))
except OSError:
return 0
def _as_int(value: Any) -> int | None:
if value is None or value == "":
return None
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class TuningDecision:
keep: bool
reason: str
class AlertTuner:
"""Small second-stage noise filter for the dashboard/incident database.
It intentionally does not replace Suricata threshold.config. The latter is
the right place for sensor-level suppressions and thresholds. This filter
is a final guardrail so low-priority or explicitly ignored alerts do not
flood SQLite and the UI.
"""
def __init__(
self,
max_severity: int,
ignore_sids: str = "",
ignore_categories: str = "",
) -> None:
self.max_severity = max(0, int(max_severity))
self.ignore_sids = _parse_int_set(ignore_sids)
self.ignore_categories = {
value.strip().casefold()
for value in (ignore_categories or "").split(",")
if value.strip()
}
def evaluate(self, event: dict[str, Any]) -> TuningDecision:
alert = event.get("alert") or {}
sid = _as_int(alert.get("signature_id"))
severity = _as_int(alert.get("severity"))
category = str(alert.get("category") or "").strip()
if sid is not None and sid in self.ignore_sids:
return TuningDecision(False, "ignored_sid")
if category and category.casefold() in self.ignore_categories:
return TuningDecision(False, "ignored_category")
if self.max_severity > 0:
if severity is None:
return TuningDecision(False, "invalid_severity")
if severity > self.max_severity:
return TuningDecision(False, "low_priority")
return TuningDecision(True, "accepted")
def _parse_int_set(value: str) -> set[int]:
result: set[int] = set()
for item in (value or "").split(","):
item = item.strip()
if not item:
continue
try:
result.add(int(item))
except ValueError:
continue
return result
def _as_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
+266 -36
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hmac
import json
import threading
import urllib.parse
@@ -7,6 +8,9 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable
from .config import Config
from .maintenance import clear_suricata_logs
from .rules import RuleManager
from .state import RuntimeStats
from .store import AlertStore
DASHBOARD = r'''<!doctype html>
@@ -16,59 +20,154 @@ DASHBOARD = r'''<!doctype html>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RouterOS Suricata TZSP</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#111827;color:#e5e7eb}
main{max-width:1200px;margin:auto;padding:24px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px}
.card{background:#1f2937;border:1px solid #374151;border-radius:10px;padding:14px}.value{font-size:28px;font-weight:700}.muted{color:#9ca3af;font-size:13px}
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:10px;overflow:hidden;margin-bottom:22px}th,td{padding:10px;border-bottom:1px solid #374151;text-align:left;font-size:13px}th{color:#9ca3af}.ok{color:#34d399}.bad{color:#f87171}.warn{color:#fbbf24}.off{color:#9ca3af}
code{background:#111827;padding:2px 5px;border-radius:4px}.top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.section-title{margin-top:24px}
.badge{display:inline-block;border:1px solid #4b5563;border-radius:999px;padding:3px 8px;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
:root{color-scheme:dark}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#111827;color:#e5e7eb}main{max-width:1320px;margin:auto;padding:20px 24px 34px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}h1{margin:0 0 6px}h2{margin:4px 0 14px}h3{margin:0 0 10px;font-size:15px}.muted{color:#9ca3af;font-size:13px}.ok{color:#34d399}.bad{color:#f87171}.warn{color:#fbbf24}.off{color:#9ca3af}.menu{position:sticky;top:0;z-index:20;display:flex;gap:7px;flex-wrap:wrap;margin:18px -6px;padding:10px 6px;background:rgba(17,24,39,.96);backdrop-filter:blur(8px);border-bottom:1px solid #273449}.menu button{background:transparent}.menu button.active{background:#273449;border-color:#6b7280}.view{display:none}.view.active{display:block}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:0 0 16px}.card,.panel,.tablebox{background:#1f2937;border:1px solid #374151;border-radius:10px}.card,.panel{padding:14px}.tablebox{overflow:hidden}.value{font-size:26px;font-weight:700}.badge{display:inline-block;border:1px solid #4b5563;border-radius:999px;padding:3px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.04em}table{width:100%;border-collapse:collapse;background:#1f2937}th,td{padding:9px;border-bottom:1px solid #374151;text-align:left;font-size:13px;vertical-align:top}th{color:#9ca3af}tr:last-child td{border-bottom:0}.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(390px,1fr));gap:12px}.stack{display:grid;gap:12px}.section-title{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 0 10px}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}button,input,textarea{font:inherit}button{background:#273449;color:#e5e7eb;border:1px solid #4b5563;border-radius:7px;padding:7px 10px;cursor:pointer}button:hover{border-color:#6b7280}button.danger{border-color:#7f1d1d;color:#fecaca}button.small{padding:3px 7px;font-size:11px}input,textarea{background:#111827;color:#e5e7eb;border:1px solid #4b5563;border-radius:7px;padding:8px}input{min-width:260px}textarea{width:100%;min-height:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;resize:vertical}details{background:#1f2937;border:1px solid #374151;border-radius:10px;padding:12px}summary{cursor:pointer;font-weight:650}.notice{padding:9px 11px;border:1px solid #374151;border-radius:8px;margin:10px 0;font-size:13px;display:none}.nowrap{white-space:nowrap}.count{font-weight:700}.right{text-align:right}.spacer{height:12px}.hint{padding:10px 12px;border-left:3px solid #4b5563;background:#172033;border-radius:6px;font-size:13px;color:#cbd5e1}@media(max-width:760px){main{padding:14px}.grid2{grid-template-columns:1fr}.tablebox{overflow-x:auto}.menu{top:0;margin-left:-2px;margin-right:-2px}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}}
</style>
</head>
<body><main>
<div class="top"><div><h1>RouterOS Suricata TZSP</h1><div class="muted">TZSP → TAP → Suricata → EVE JSON → SQLite</div></div><div id="status">Loading…</div></div>
<nav class="menu" aria-label="Dashboard sections">
<button data-view="overview" onclick="showSection('overview')">Overview</button>
<button data-view="incidents" onclick="showSection('incidents')">Incidents</button>
<button data-view="statistics" onclick="showSection('statistics')">Statistics</button>
<button data-view="system" onclick="showSection('system')">System</button>
<button data-view="rules" onclick="showSection('rules')">Rules</button>
<button data-view="maintenance" onclick="showSection('maintenance')">Maintenance</button>
</nav>
<div id="notice" class="notice"></div>
<section id="view-overview" class="view">
<div class="cards">
<div class="card"><div class="muted">TZSP datagrams</div><div id="tzsp" class="value">0</div></div>
<div class="card"><div class="muted">Frames injected into TAP</div><div id="frames" class="value">0</div></div>
<div class="card"><div class="muted">Suricata alerts</div><div id="alerts" class="value">0</div></div>
<div class="card"><div class="muted">Frames to TAP</div><div id="frames" class="value">0</div></div>
<div class="card"><div class="muted">Alert hits</div><div id="alerts" class="value">0</div></div>
<div class="card"><div class="muted">Incidents</div><div id="incidents" class="value">0</div></div>
<div class="card"><div class="muted">Alerts / 24h</div><div id="alerts24h" class="value">0</div></div>
<div class="card"><div class="muted">RouterOS blocks</div><div id="blocked" class="value">0</div></div>
<div class="card"><div class="muted">Filtered noise</div><div id="filtered" class="value">0</div></div>
<div class="card"><div class="muted">Deduplicated</div><div id="dedup" class="value">0</div></div>
</div>
<h2 class="section-title">System status</h2>
<table><thead><tr><th>Component</th><th>Status</th><th>Details</th></tr></thead><tbody id="serviceRows"></tbody></table>
<h2 class="section-title">Ports</h2>
<table><thead><tr><th>Service</th><th>Direction</th><th>Protocol</th><th>Address</th><th>Port</th><th>Status</th></tr></thead><tbody id="portRows"></tbody></table>
<h2 class="section-title">Recent alerts</h2>
<table><thead><tr><th>Time</th><th>Severity</th><th>Signature</th><th>Source</th><th>Destination</th><th>Action</th></tr></thead><tbody id="rows"></tbody></table>
<div class="grid2">
<div class="panel"><h3>Detection profile</h3><div id="tuningText" class="muted">Loading tuning configuration…</div></div>
<div class="panel"><h3>Rules in the image</h3><div id="rulesSummary" class="muted">Loading rule status…</div></div>
</div>
<div class="spacer"></div>
<div class="hint">The reserved self-test SID 1000001 only matches the explicit TZSP test payload and is filtered from the incident database. Production detections use separate SIDs.</div>
</section>
<section id="view-incidents" class="view">
<div class="section-title"><h2>Recent incidents</h2><span class="muted">Repeated matches are aggregated into one incident window.</span></div>
<div class="tablebox"><table><thead><tr><th>Last seen</th><th>Hits</th><th>Severity</th><th>Signature</th><th>Source</th><th>Destination</th><th>Action</th><th></th></tr></thead><tbody id="rows"></tbody></table></div>
</section>
<section id="view-statistics" class="view">
<h2>Extended statistics</h2>
<div class="grid2">
<div class="tablebox"><table><thead><tr><th colspan="5">Top signatures / 24h</th></tr><tr><th>SID</th><th>Signature</th><th>Severity</th><th>Hits</th><th></th></tr></thead><tbody id="signatureRows"></tbody></table></div>
<div class="tablebox"><table><thead><tr><th colspan="3">Top sources / 24h</th></tr><tr><th>Source</th><th>Hits</th><th>Last seen</th></tr></thead><tbody id="sourceRows"></tbody></table></div>
</div>
<div class="spacer"></div>
<div class="grid2">
<div class="tablebox"><table><thead><tr><th colspan="3">Top destinations / 24h</th></tr><tr><th>Destination</th><th>Hits</th><th>Last seen</th></tr></thead><tbody id="destinationRows"></tbody></table></div>
<div class="tablebox"><table><thead><tr><th colspan="2">Severity distribution</th></tr><tr><th>Severity</th><th class="right">Hits</th></tr></thead><tbody id="severityRows"></tbody></table></div>
</div>
<div class="spacer"></div>
<div class="grid2">
<div class="tablebox"><table><thead><tr><th>Sensor counter</th><th class="right">Value</th></tr></thead><tbody id="runtimeRows"></tbody></table></div>
<div class="tablebox"><table><thead><tr><th>Suricata counter</th><th class="right">Value</th></tr></thead><tbody id="suricataRows"></tbody></table></div>
</div>
</section>
<section id="view-system" class="view">
<h2>System status</h2>
<div class="stack">
<div class="tablebox"><table><thead><tr><th>Component</th><th>Status</th><th>Details</th></tr></thead><tbody id="serviceRows"></tbody></table></div>
<div><h2>Ports</h2><div class="tablebox"><table><thead><tr><th>Service</th><th>Direction</th><th>Protocol</th><th>Address</th><th>Port</th><th>Status</th></tr></thead><tbody id="portRows"></tbody></table></div></div>
<div class="panel"><h3>Database & storage</h3><div id="storageText" class="muted">Loading database/storage state…</div></div>
</div>
</section>
<section id="view-rules" class="view">
<h2>Rules & signature feeds</h2>
<div class="panel"><div class="muted">The image contains an ET/Open snapshot plus conservative local production rules. Downloaded feeds and their enabled-source configuration are persisted in <code>/var/lib/suricata</code>. Every downloaded ruleset is validated with <code>suricata -T</code> before it replaces the last known-good rules.</div><div class="toolbar"><input id="adminTokenRules" type="password" placeholder="Admin token"><button onclick="saveTokenFrom('adminTokenRules')">Use token</button><button onclick="loadRules()">Load rule editors</button><button onclick="reloadRules()">Reload rules</button></div></div>
<div class="spacer"></div>
<div class="panel">
<div class="section-title"><h3>Signature sources</h3><span id="sourceMeta" class="muted">Load the OISF source catalog to manage feeds.</span></div>
<div class="muted">The table is populated by <code>suricata-update list-sources --free</code> from the official OISF source index. ET/Open is the default feed. Other free feeds can be enabled individually; sources requiring parameters are shown but are not enabled blindly from the UI.</div>
<div class="toolbar"><button onclick="loadRuleSources()">Load sources</button><button onclick="refreshRuleSources()">Refresh OISF catalog</button><button onclick="updateRules()">Download / update active signatures</button><input id="sourceFilter" type="search" placeholder="Filter sources" oninput="renderRuleSources()"></div>
<div class="tablebox"><table><thead><tr><th>Source</th><th>Vendor</th><th>License</th><th>Tags</th><th>Status</th><th>Action</th></tr></thead><tbody id="ruleSourceRows"><tr><td colspan="6" class="muted">Source catalog not loaded yet.</td></tr></tbody></table></div>
</div>
<div class="spacer"></div>
<div class="stack">
<details open><summary>Custom Suricata signatures</summary><p class="muted">Use SIDs 1001000+ for site-specific detections. Built-in production rules are maintained by the image.</p><textarea id="customRules" spellcheck="false" placeholder='alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LOCAL SITE example"; ...; sid:1001000; rev:1;)'></textarea><div class="toolbar"><button onclick="saveCustomRules()">Validate, save & reload</button></div></details>
<details><summary>threshold.config / suppressions</summary><p class="muted">Global suppress removes alerts for a SID. Prefer source/destination-scoped suppression or rate limits when only one host is noisy.</p><textarea id="thresholdConfig" spellcheck="false"></textarea><div class="toolbar"><button onclick="saveThresholds()">Validate, save & reload</button></div></details>
</div>
</section>
<section id="view-maintenance" class="view">
<h2>Maintenance</h2>
<div class="panel">
<div class="muted">Destructive actions require <code>ADMIN_TOKEN</code>. The token is kept only in this browser session.</div>
<div class="toolbar"><input id="adminToken" type="password" placeholder="Admin token"><button onclick="saveTokenFrom('adminToken')">Use token</button></div>
<div class="toolbar"><button class="danger" onclick="clearAlerts()">Clear alerts</button><button class="danger" onclick="clearLogs()">Clear Suricata logs</button><button onclick="vacuumDb()">Compact SQLite</button><button onclick="resetCounters()">Reset runtime counters</button></div>
</div>
</section>
<script>
function valueOrDash(v){return (v===null||v===undefined||v==='')?'-':String(v)}
function esc(v){return valueOrDash(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function stateClass(v){v=String(v||'').toLowerCase();if(v==='up'||v==='ok'||v==='running'||v==='configured')return'ok';if(v==='down'||v==='error'||v==='degraded')return'bad';if(v==='disabled'||v==='not configured'||v==='development')return'off';return'warn'}
function stateBadge(v){return `<span class="badge ${stateClass(v)}">${esc(v)}</span>`}
function bytes(v){v=Number(v||0);const u=['B','KiB','MiB','GiB'];let i=0;while(v>=1024&&i<u.length-1){v/=1024;i++}return `${v.toFixed(i?1:0)} ${u[i]}`}
function fmtTime(v){if(!v)return'-';const d=new Date(v);if(Number.isNaN(d.getTime()))return valueOrDash(v);return d.toLocaleString(undefined,{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
function endpoint(ip,port){const base=valueOrDash(ip);return (port===null||port===undefined||port==='')?base:`${base}:${port}`}
function incidentActions(x){const sid=Number(x.signature_id)||0;let source=String(x.src_ip||'');if(!/^[0-9A-Fa-f:.]+$/.test(source))source='';const scoped=source?`<button class="small" onclick="suppressSid(${sid},'by_src','${source}')">Mute source</button>`:'';return `<div class="toolbar">${scoped}<button class="small" onclick="suppressSid(${sid})">Suppress SID</button></div>`}
function token(){return sessionStorage.getItem('adminToken')||''}
function syncTokens(){for(const id of ['adminToken','adminTokenRules']){const e=document.getElementById(id);if(e)e.value=token()}}
function saveTokenFrom(id){sessionStorage.setItem('adminToken',document.getElementById(id).value);syncTokens();notice('Admin token stored for this browser session.','ok');if(id==='adminTokenRules')loadRuleSources()}
function notice(text,kind='warn'){const n=document.getElementById('notice');if(!n)return;n.style.display='block';n.className='notice '+kind;n.textContent=text}
function showSection(name,updateHash=true){const valid=['overview','incidents','statistics','system','rules','maintenance'];if(!valid.includes(name))name='overview';document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.menu button').forEach(x=>x.classList.toggle('active',x.dataset.view===name));document.getElementById('view-'+name).classList.add('active');if(updateHash)history.replaceState(null,'','#'+name);if(name==='rules'&&token()&&!ruleSourcesLoaded)setTimeout(loadRuleSources,0)}
async function api(url,opts={}){const headers=Object.assign({'Accept':'application/json'},opts.headers||{});if(opts.admin)headers['X-Admin-Token']=token();if(opts.body)headers['Content-Type']='application/json';const r=await fetch(url,Object.assign({},opts,{headers}));let data={};try{data=await r.json()}catch(_){}if(!r.ok)throw new Error(data.error||`HTTP ${r.status}`);return data}
async function adminPost(url,body={}){return api(url,{method:'POST',body:JSON.stringify(body),admin:true})}
function severityClass(v){v=Number(v);return v===1?'bad':v===2?'warn':'off'}
let ruleSources=[];let ruleSourcesLoaded=false;
function renderRuleSources(){const q=String(document.getElementById('sourceFilter')?.value||'').trim().toLowerCase();const rows=ruleSources.filter(s=>!q||[s.name,s.vendor,s.summary,(s.tags||[]).join(' ')].join(' ').toLowerCase().includes(q)).map(s=>{const status=s.default?'<span class="badge ok">default active</span>':s.enabled?'<span class="badge ok">enabled</span>':'<span class="badge off">disabled</span>';let action='';if(s.default){action='<button class="small" onclick="updateRules()">Update now</button>'}else if(!s.can_toggle){action='<span class="muted">Needs parameters: '+esc((s.parameters||[]).join(', '))+'</span>'}else{action=`<button class="small" onclick="toggleRuleSource('${s.name}',${!s.enabled})">${s.enabled?'Disable + rebuild':'Enable + download'}</button>`}return `<tr><td><b>${esc(s.name)}</b><br><span class="muted">${esc(s.summary)}</span></td><td>${esc(s.vendor)}</td><td>${esc(s.license)}</td><td>${esc((s.tags||[]).join(', '))}</td><td>${status}</td><td>${action}</td></tr>`}).join('');document.getElementById('ruleSourceRows').innerHTML=rows||'<tr><td colspan="6" class="muted">No matching free sources.</td></tr>'}
function runtimeTable(runtime){const keys=[['tzsp_to_tap_loss','TZSP → TAP loss'],['tzsp_decode_errors','TZSP decode errors'],['tzsp_unsupported','TZSP unsupported'],['inject_errors','TAP inject errors'],['eve_events','EVE events'],['eve_parse_errors','EVE parse errors'],['alerts_filtered','Filtered alerts'],['alerts_filtered_ignored_sid','Filtered reserved/test SID'],['alerts_deduplicated','Deduplicated alerts'],['block_attempts','Block attempts'],['block_success','Block successes'],['block_errors','Block errors'],['block_success_rate','Block success %']];return keys.map(([k,n])=>`<tr><td>${esc(n)}</td><td class="right">${esc(runtime[k]??0)}</td></tr>`).join('')}
function suricataTable(runtime){const s=runtime.suricata||{};const preferred=['decoder.pkts','decoder.bytes','capture.kernel_packets','capture.kernel_drops','detect.alert','flow.memuse','tcp.sessions','tcp.reassembly_gap'];let rows=[];for(const k of preferred){if(k in s)rows.push([k,s[k]])}if(!rows.length)rows=Object.entries(s).slice(0,8);return rows.map(([k,v])=>`<tr><td>${esc(k)}</td><td class="right">${esc(v)}</td></tr>`).join('')||'<tr><td colspan="2" class="muted">No EVE stats event received yet.</td></tr>'}
async function refresh(){
try{
const [statusData,summary,alertsData]=await Promise.all([fetch('/api/status').then(r=>r.json()),fetch('/api/summary').then(r=>r.json()),fetch('/api/alerts?limit=50').then(r=>r.json())]);
if(statusData.dev_mode){
document.getElementById('status').innerHTML='<span class="warn">Development mode: Web UI only</span>';
}else if(statusData.status==='ok'){
document.getElementById('status').innerHTML='<span class="ok">System operational</span>';
}else{
document.getElementById('status').innerHTML='<span class="bad">System degraded</span>';
}
document.getElementById('tzsp').textContent=valueOrDash(statusData.runtime?.tzsp_datagrams);
document.getElementById('frames').textContent=valueOrDash(statusData.runtime?.frames_injected);
document.getElementById('alerts').textContent=valueOrDash(summary.total_alerts);
document.getElementById('blocked').textContent=valueOrDash(summary.blocked_alerts);
const serviceRows=Object.entries(statusData.services||{}).map(([name,item])=>`<tr><td>${esc(item.name||name)}</td><td>${stateBadge(item.status)}</td><td>${esc(item.details)}</td></tr>`).join('');
document.getElementById('serviceRows').innerHTML=serviceRows||'<tr><td colspan="3" class="muted">No service status data available.</td></tr>';
const portRows=(statusData.ports||[]).map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.direction)}</td><td>${esc(item.protocol)}</td><td>${esc(item.address)}</td><td>${esc(item.port)}</td><td>${stateBadge(item.status)}</td></tr>`).join('');
document.getElementById('portRows').innerHTML=portRows||'<tr><td colspan="6" class="muted">No port status data available.</td></tr>';
const rows=(alertsData.alerts||[]).map(x=>`<tr><td>${esc(x.timestamp)}</td><td>${esc(x.severity)}</td><td>${esc(x.signature)}<br><span class="muted">SID ${esc(x.signature_id)}</span></td><td>${esc(x.src_ip)}:${esc(x.src_port)}</td><td>${esc(x.dest_ip)}:${esc(x.dest_port)}</td><td>${x.blocked?'<span class="bad">BLOCK '+esc(x.block_target)+'</span>':'<span class="muted">'+esc(x.block_reason)+'</span>'}</td></tr>`).join('');
document.getElementById('rows').innerHTML=rows||'<tr><td colspan="6" class="muted">No alerts yet. Run scripts/selftest.sh for a full-stack test or start dev mode with DEV_SEED_DATA=true.</td></tr>';
const [statusData,summary,analyticsData,alertsData,config]=await Promise.all([api('/api/status'),api('/api/summary'),api('/api/stats'),api('/api/alerts?limit=100'),api('/api/config')]);
const analytics=analyticsData.analytics||{};
if(statusData.dev_mode)document.getElementById('status').innerHTML='<span class="warn">Development mode: Web UI only</span>';else if(statusData.status==='ok')document.getElementById('status').innerHTML='<span class="ok">System operational</span>';else document.getElementById('status').innerHTML='<span class="bad">System degraded</span>';
const rt=statusData.runtime||{};document.getElementById('tzsp').textContent=valueOrDash(rt.tzsp_datagrams);document.getElementById('frames').textContent=valueOrDash(rt.frames_injected);document.getElementById('alerts').textContent=valueOrDash(summary.total_alerts);document.getElementById('incidents').textContent=valueOrDash(summary.incidents);document.getElementById('alerts24h').textContent=valueOrDash(analytics.alerts_24h);document.getElementById('blocked').textContent=valueOrDash(summary.blocked_alerts);document.getElementById('filtered').textContent=valueOrDash(rt.alerts_filtered);document.getElementById('dedup').textContent=valueOrDash(rt.alerts_deduplicated);
const db=statusData.database||{},st=statusData.storage||{},rules=statusData.rules||{};
document.getElementById('tuningText').innerHTML=`Store severities <b>1-${esc(config.alert_max_severity)}</b>; aggregate identical SID/source/destination/protocol/destination-port for <b>${esc(config.alert_dedup_window_seconds)}s</b>; retention <b>${esc(config.alert_retention_days)} days</b>. Auto-block: <b>${config.auto_block?'enabled':'disabled'}</b>.`;
document.getElementById('rulesSummary').innerHTML=`Built-in local detections: <b>${esc(rules.builtin_rule_count||0)}</b>; custom detections: <b>${esc(rules.custom_rule_count||0)}</b>; threshold/suppress entries: <b>${esc(rules.threshold_entry_count||0)}</b>; vendor rules: <b>${esc(bytes(rules.vendor_rules_size_bytes||0))}</b>, last installed <b>${esc(fmtTime(rules.vendor_rules_updated_at))}</b>. Source catalog: <b>${esc(fmtTime(rules.source_index_updated_at))}</b>.`;
document.getElementById('storageText').innerHTML=`SQLite <b>${esc(db.path)}</b>: ${esc(bytes(db.size_bytes))} + WAL ${esc(bytes(db.wal_size_bytes))}; schema v${esc(db.schema_version)}; ${esc(db.rows)} incident rows. Persistent filesystem <b>${esc(st.path)}</b>: ${esc(st.used_percent)}% used; Suricata logs ${esc(bytes(st.suricata_log_bytes))}; containerized: <b>${st.containerized?'yes':'no'}</b>; host <b>${esc(st.hostname)}</b>.`;
document.getElementById('serviceRows').innerHTML=Object.entries(statusData.services||{}).map(([name,item])=>`<tr><td>${esc(item.name||name)}</td><td>${stateBadge(item.status)}</td><td>${esc(item.details)}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No service status data.</td></tr>';
document.getElementById('portRows').innerHTML=(statusData.ports||[]).map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.direction)}</td><td>${esc(item.protocol)}</td><td>${esc(item.address)}</td><td>${esc(item.port)}</td><td>${stateBadge(item.status)}</td></tr>`).join('')||'<tr><td colspan="6" class="muted">No port data.</td></tr>';
document.getElementById('runtimeRows').innerHTML=runtimeTable(rt);document.getElementById('suricataRows').innerHTML=suricataTable(rt);
document.getElementById('signatureRows').innerHTML=(analytics.top_signatures||[]).map(x=>`<tr><td>${esc(x.signature_id)}</td><td>${esc(x.signature)}</td><td><span class="${severityClass(x.severity)}">${esc(x.severity)}</span></td><td class="count">${esc(x.count)}</td><td><button class="small" onclick="suppressSid(${Number(x.signature_id)||0})">Suppress</button></td></tr>`).join('')||'<tr><td colspan="5" class="muted">No alerts in the last 24 hours.</td></tr>';
document.getElementById('sourceRows').innerHTML=(analytics.top_sources||[]).map(x=>`<tr><td>${esc(x.src_ip)}</td><td class="count">${esc(x.count)}</td><td>${esc(fmtTime(x.last_seen))}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No source statistics yet.</td></tr>';
document.getElementById('destinationRows').innerHTML=(analytics.top_destinations||[]).map(x=>`<tr><td>${esc(x.dest_ip)}</td><td class="count">${esc(x.count)}</td><td>${esc(fmtTime(x.last_seen))}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No destination statistics yet.</td></tr>';
document.getElementById('severityRows').innerHTML=Object.entries(summary.by_severity||{}).map(([severity,count])=>`<tr><td><span class="${severityClass(severity)}">Severity ${esc(severity)}</span></td><td class="right count">${esc(count)}</td></tr>`).join('')||'<tr><td colspan="2" class="muted">No severity statistics yet.</td></tr>';
document.getElementById('rows').innerHTML=(alertsData.alerts||[]).map(x=>`<tr><td class="nowrap">${esc(fmtTime(x.last_seen||x.timestamp))}<br><span class="muted">first ${esc(fmtTime(x.first_seen||x.timestamp))}</span></td><td class="count">${esc(x.hit_count||1)}</td><td><span class="${severityClass(x.severity)}">${esc(x.severity)}</span></td><td>${esc(x.signature)}<br><span class="muted">SID ${esc(x.signature_id)} · ${esc(x.category)}</span></td><td>${esc(endpoint(x.src_ip,x.src_port))}</td><td>${esc(endpoint(x.dest_ip,x.dest_port))}</td><td>${x.blocked?'<span class="bad">BLOCK '+esc(x.block_target)+'</span>':'<span class="muted">'+esc(x.block_reason)+'</span>'}</td><td>${incidentActions(x)}</td></tr>`).join('')||'<tr><td colspan="8" class="muted">No stored incidents. Low-priority/noisy events and the reserved self-test SID may be filtered before SQLite.</td></tr>';
}catch(err){document.getElementById('status').innerHTML='<span class="bad">Application unavailable</span>'}
}
refresh();setInterval(refresh,2500);
async function action(url,body,success){try{const r=await adminPost(url,body);notice(r.message||success,'ok');await refresh()}catch(e){notice(e.message,'bad')}}
async function clearAlerts(){if(confirm('Delete all alert incidents from SQLite?'))await action('/api/admin/alerts/clear',{},'Alerts cleared.')}
async function clearLogs(){if(confirm('Truncate active Suricata log files?'))await action('/api/admin/logs/clear',{},'Logs cleared.')}
async function vacuumDb(){await action('/api/admin/database/vacuum',{},'Database compacted.')}
async function resetCounters(){await action('/api/admin/runtime/reset',{},'Runtime counters reset.')}
async function reloadRules(){await action('/api/admin/rules/reload',{},'Rule reload requested.')}
async function loadRuleSources(){try{const r=await api('/api/admin/rules/sources',{admin:true});ruleSources=r.sources||[];ruleSourcesLoaded=true;const st=r.status||{};document.getElementById('sourceMeta').textContent=`${ruleSources.length} free sources · ${r.enabled_sources?.length||0} active · vendor rules ${bytes(st.vendor_rules_size_bytes||0)} · installed ${fmtTime(st.vendor_rules_updated_at)}`;renderRuleSources()}catch(e){notice(e.message,'bad')}}
async function refreshRuleSources(){if(!confirm('Refresh the rule-source index from OISF now?'))return;await action('/api/admin/rules/sources/refresh',{},'OISF source catalog refreshed.');await loadRuleSources()}
async function updateRules(){if(!confirm('Download all active signature feeds, validate them and reload Suricata?'))return;await action('/api/admin/rules/update',{},'Active signature feeds updated.');await loadRuleSources()}
async function toggleRuleSource(name,enable){const verb=enable?'Enable and download':'Disable and rebuild without';if(!confirm(`${verb} ${name}?`))return;await action(`/api/admin/rules/sources/${enable?'enable':'disable'}`,{source:name},`${name} ${enable?'enabled':'disabled'}.`);await loadRuleSources()}
async function suppressSid(sid,track='',ip=''){if(!sid)return notice('Invalid SID','bad');const scoped=track&&ip;const prompt=scoped?`Suppress SID ${sid} only for source ${ip}?`:`Globally suppress Suricata SID ${sid}? This stops alerts for that SID.`;if(confirm(prompt))await action('/api/admin/rules/suppress',{sid,track,ip},scoped?`SID ${sid} muted for ${ip}.`:`SID ${sid} suppressed.`)}
async function loadRules(){try{const r=await api('/api/admin/rules',{admin:true});document.getElementById('customRules').value=r.custom_rules||'';document.getElementById('thresholdConfig').value=r.threshold_config||'';notice('Rule editors loaded.','ok')}catch(e){notice(e.message,'bad')}}
async function saveCustomRules(){await action('/api/admin/rules/custom',{content:document.getElementById('customRules').value},'Custom rules saved and reloaded.')}
async function saveThresholds(){await action('/api/admin/rules/thresholds',{content:document.getElementById('thresholdConfig').value},'Threshold configuration saved and reloaded.')}
syncTokens();showSection((location.hash||'#overview').slice(1),false);refresh();setInterval(refresh,4000);window.addEventListener('hashchange',()=>showSection((location.hash||'#overview').slice(1),false));
</script>
</main></body></html>'''
@@ -79,10 +178,14 @@ class WebServer:
config: Config,
store: AlertStore,
health_provider: Callable[[], dict],
stats: RuntimeStats | None = None,
rule_manager: RuleManager | None = None,
) -> None:
self.config = config
self.store = store
self.health_provider = health_provider
self.stats = stats
self.rule_manager = rule_manager
self.server = ThreadingHTTPServer((config.web_bind, config.web_port), self._handler())
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
@@ -90,8 +193,12 @@ class WebServer:
store = self.store
config = self.config
health_provider = self.health_provider
stats = self.stats
rule_manager = self.rule_manager
class Handler(BaseHTTPRequestHandler):
MAX_BODY = 1024 * 1024
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/":
@@ -103,6 +210,9 @@ class WebServer:
if parsed.path == "/api/summary":
self._json(store.summary())
return
if parsed.path == "/api/stats":
self._json({"summary": store.summary(), "analytics": store.analytics()})
return
if parsed.path == "/api/config":
self._json(config.public_dict())
return
@@ -114,8 +224,124 @@ class WebServer:
limit = 100
self._json({"alerts": store.recent(limit)})
return
if parsed.path == "/api/admin/rules":
if not self._require_admin():
return
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503)
else:
self._json(rule_manager.content())
return
if parsed.path == "/api/admin/rules/sources":
if not self._require_admin():
return
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503)
else:
payload = rule_manager.source_catalog()
self._json(payload, status=200 if payload.get("ok") else 503)
return
self._json({"error": "not found"}, status=404)
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if not parsed.path.startswith("/api/admin/"):
self._json({"error": "not found"}, status=404)
return
if not self._require_admin():
return
body = self._read_json()
if body is None:
return
if parsed.path == "/api/admin/alerts/clear":
count = store.clear_alerts()
self._json({"ok": True, "message": f"Deleted {count} incident rows"})
return
if parsed.path == "/api/admin/logs/clear":
result = clear_suricata_logs(config.eve_path)
self._json({"ok": True, "message": f"Cleared {len(result['files'])} log files; freed {result['bytes_freed']} bytes", **result})
return
if parsed.path == "/api/admin/database/vacuum":
store.vacuum()
self._json({"ok": True, "message": "SQLite VACUUM completed"})
return
if parsed.path == "/api/admin/runtime/reset":
if stats is None:
self._json({"error": "runtime stats unavailable"}, status=503)
else:
stats.reset()
self._json({"ok": True, "message": "Runtime counters reset"})
return
if parsed.path.startswith("/api/admin/rules/"):
if rule_manager is None:
self._json({"error": "rule manager unavailable"}, status=503)
return
if parsed.path == "/api/admin/rules/custom":
result = rule_manager.replace_custom_rules(str(body.get("content", "")))
elif parsed.path == "/api/admin/rules/thresholds":
result = rule_manager.replace_threshold_config(str(body.get("content", "")))
elif parsed.path == "/api/admin/rules/suppress":
try:
sid = int(body.get("sid"))
except (TypeError, ValueError):
self._json({"error": "valid SID is required"}, status=400)
return
result = rule_manager.suppress_sid(sid, str(body.get("track") or ""), body.get("ip"))
elif parsed.path == "/api/admin/rules/reload":
result = rule_manager.reload()
elif parsed.path == "/api/admin/rules/update":
result = rule_manager.update_vendor_rules()
elif parsed.path == "/api/admin/rules/sources/refresh":
result = rule_manager.refresh_source_catalog()
elif parsed.path in {"/api/admin/rules/sources/enable", "/api/admin/rules/sources/disable"}:
source_name = str(body.get("source") or "")
result = rule_manager.set_source_enabled(
source_name,
parsed.path.endswith("/enable"),
)
else:
self._json({"error": "not found"}, status=404)
return
self._json(
{"ok": result.ok, "message": result.message},
status=200 if result.ok else 400,
)
return
self._json({"error": "not found"}, status=404)
def _require_admin(self) -> bool:
if not config.admin_token:
self._json(
{"error": "admin actions are disabled; set ADMIN_TOKEN in the container environment"},
status=403,
)
return False
supplied = self.headers.get("X-Admin-Token", "")
if not hmac.compare_digest(supplied, config.admin_token):
self._json({"error": "invalid admin token"}, status=403)
return False
return True
def _read_json(self):
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length < 0 or length > self.MAX_BODY:
self._json({"error": "request body too large"}, status=413)
return None
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
self._json({"error": "invalid JSON body"}, status=400)
return None
if not isinstance(data, dict):
self._json({"error": "JSON body must be an object"}, status=400)
return None
return data
def _json(self, obj, status: int = 200):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self._send(status, data, "application/json; charset=utf-8")
@@ -125,6 +351,10 @@ class WebServer:
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)