worked poc
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user