v.0.11.4
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user