This commit is contained in:
Mateusz Gruszczyński
2026-08-17 10:07:19 +02:00
parent 074d17be89
commit cc3c446c8e
29 changed files with 627 additions and 105 deletions
+10
View File
@@ -30,6 +30,16 @@ class AnalyticsCacheTests(unittest.TestCase):
self.assertFalse(cache._snapshot_thread.is_alive())
store.close()
def test_background_worker_has_no_dashboard_windows_until_requested(self):
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
history = TrafficHistory("", retention_hours=24, max_events=1000, memory_events=1000)
cache = AnalyticsSnapshotCache(store, history, threading.Event(), interval_seconds=60)
self.assertEqual(cache._due_windows(), [])
cache.get(900)
self.assertEqual(cache._due_windows(), [900])
store.close()
def test_refresh_persists_all_dashboard_windows_in_sqlite_cache(self):
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
+9 -1
View File
@@ -43,9 +43,17 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(cfg.redis_maxmemory_mb, 128)
self.assertEqual(cfg.redis_snapshot_seconds, 0)
self.assertFalse(cfg.redis_aof)
self.assertEqual(cfg.traffic_archive_interval_seconds, 5)
self.assertEqual(cfg.traffic_archive_interval_seconds, 10)
self.assertEqual(cfg.analytics_snapshot_interval_seconds, 120)
self.assertEqual(cfg.traffic_archive_lag_seconds, 10)
self.assertEqual(cfg.traffic_archive_batch_size, 1000)
self.assertEqual(cfg.traffic_max_events, 50000)
def test_traffic_max_events_can_be_overridden_or_disabled(self):
with patch.dict(os.environ, {"TRAFFIC_MAX_EVENTS": "12345"}, clear=True):
self.assertEqual(Config.from_env().traffic_max_events, 12345)
with patch.dict(os.environ, {"TRAFFIC_MAX_EVENTS": "0"}, clear=True):
self.assertEqual(Config.from_env().traffic_max_events, 0)
if __name__ == "__main__":
+15
View File
@@ -8,11 +8,13 @@ from datetime import datetime, timezone
from app.store import AlertStore
from app.live import (
MAX_ANALYTICS_DIMENSION_KEYS,
EventBus,
LiveEventPipeline,
RedisUnavailableError,
TrafficHistory,
TrafficNormalizer,
_AnalyticsAccumulator,
event_matches,
)
@@ -410,6 +412,19 @@ class LiveTests(unittest.TestCase):
self.assertEqual(store.traffic_archive_status()["events"], 0)
store.close()
def test_sqlite_analytics_high_cardinality_counters_are_bounded(self):
now = int(time.time() * 1000)
acc = _AnalyticsAccumulator(now - 60_000, now, 60, track_high_cardinality=False)
for idx in range(MAX_ANALYTICS_DIMENSION_KEYS + 100):
acc.add_event({
"ts_ms": now,
"type": "fileinfo",
"filename": f"unique-{idx}.bin",
"file_sha256": f"{idx:064x}"[-64:],
"direction": "outbound",
})
self.assertEqual(len(acc.file_activity), MAX_ANALYTICS_DIMENSION_KEYS)
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -175,6 +175,74 @@ class PrometheusMetricsTests(unittest.TestCase):
store.close()
def test_health_endpoint_uses_lightweight_provider(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
full_calls = []
health_calls = []
def full_status():
full_calls.append(True)
raise AssertionError("lightweight health endpoint must not build full status")
web = WebServer(
cfg,
store,
full_status,
healthcheck_provider=lambda: health_calls.append(True) or {"status": "ok", "operational": True},
)
try:
with contextlib.redirect_stdout(io.StringIO()):
web.start()
port = web.server.server_address[1]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=2) as response:
body = response.read().decode("utf-8")
self.assertEqual(200, response.status)
self.assertIn('"operational":true', body.replace(" ", ""))
self.assertEqual([], full_calls)
self.assertEqual([True], health_calls)
finally:
web.stop()
store.close()
def test_status_payload_is_cached_for_short_poll_bursts(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
calls = []
web = WebServer(cfg, store, lambda: calls.append(True) or {"operational": True})
try:
first = web._status_payload()
second = web._status_payload()
self.assertTrue(first["operational"])
self.assertTrue(second["operational"])
self.assertEqual([True], calls)
finally:
web.server.server_close()
store.close()
def test_stats_payload_caches_expensive_sqlite_aggregates(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
cfg = replace(Config.from_env(), web_bind="127.0.0.1", web_port=0, db_path=db_path)
store = AlertStore(db_path)
calls = {"summary": 0, "analytics": 0, "ndr": 0}
store.summary = lambda: calls.__setitem__("summary", calls["summary"] + 1) or {"alerts": 0}
store.analytics = lambda: calls.__setitem__("analytics", calls["analytics"] + 1) or {"alerts_24h": 0}
store.ndr_summary = lambda: calls.__setitem__("ndr", calls["ndr"] + 1) or {"open_incidents": 0}
web = WebServer(cfg, store, lambda: {"operational": True})
try:
first = web._stats_payload()
second = web._stats_payload()
self.assertEqual(first, second)
self.assertEqual({"summary": 1, "analytics": 1, "ndr": 1}, calls)
finally:
web.server.server_close()
store.close()
def test_metrics_ip_acl_denies_before_rendering_metrics(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = str(Path(tmp) / "ids.db")
+50
View File
@@ -1,6 +1,7 @@
import base64
import os
import tempfile
from unittest.mock import patch
from app.ndr import NDRAnalyzer, ThreatIntelManager
from app.store import AlertStore
@@ -142,3 +143,52 @@ def test_repeated_ip_mac_changes_escalate_to_network_spoofing_and_anomalies_are_
events = store.ndr_incident_events(int(anomaly_incident["id"]), 20)
assert sum(1 for event in events if event["stage"] == "protocol-anomaly") == 1
store.close()
def test_baseline_touch_is_cached_for_repeated_values():
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
ti = ThreatIntelManager(store, os.path.join(td, "suricata"))
analyzer = NDRAnalyzer(
store, ti, DummyRouterOS(), "192.168.88.0/24", "", "1h",
enabled=True, auto_block=False,
)
calls = 0
original = store.baseline_touch
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
store.baseline_touch = counted
record = {
"timestamp": "2026-08-15T08:20:00+00:00",
"type": "flow", "direction": "outbound", "src_ip": "192.168.88.50",
"dest_ip": "203.0.113.20", "dest_port": 443, "app_proto": "tls",
}
for _ in range(10):
analyzer._process(dict(record), None)
assert calls == 2 # app + outbound port, only on first sight in this process
assert analyzer.status()["state_entries"]["baseline_lru"] == 2
store.close()
def test_beacon_state_is_bounded():
with tempfile.TemporaryDirectory() as td:
store = AlertStore(os.path.join(td, "ids.db"))
ti = ThreatIntelManager(store, os.path.join(td, "suricata"))
analyzer = NDRAnalyzer(
store, ti, DummyRouterOS(), "192.168.88.0/24", "", "1h",
enabled=True, auto_block=False,
)
with patch("app.ndr.NDR_BEACON_MAX_KEYS", 32):
for idx in range(64):
analyzer._behavior({
"timestamp": "2026-08-15T08:30:00+00:00",
"type": "flow", "direction": "outbound",
"src_ip": "192.168.88.60", "dest_ip": f"203.0.113.{idx}",
"dest_port": 443,
}, None, "192.168.88.60")
assert len(analyzer._beacon) == 32
store.close()