poc3
This commit is contained in:
@@ -18,6 +18,25 @@ class ConfigTests(unittest.TestCase):
|
||||
with patch.dict(os.environ, {"RULE_UPDATE_INTERVAL_HOURS": "-5"}, clear=True):
|
||||
self.assertEqual(Config.from_env().rule_update_interval_hours, 0)
|
||||
|
||||
def test_metrics_acl_defaults_to_loopback_ip_only(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
cfg = Config.from_env()
|
||||
self.assertEqual(cfg.metrics_allowed_ips, "127.0.0.1/32,::1/128")
|
||||
self.assertEqual(cfg.metrics_basic_auth_username, "")
|
||||
self.assertEqual(cfg.metrics_basic_auth_password, "")
|
||||
|
||||
def test_metrics_acl_reads_ip_and_basic_auth_from_env(self):
|
||||
env = {
|
||||
"METRICS_ALLOWED_IPS": "10.0.0.5/32,10.0.1.0/24",
|
||||
"METRICS_BASIC_AUTH_USERNAME": "prometheus",
|
||||
"METRICS_BASIC_AUTH_PASSWORD": "secret",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
cfg = Config.from_env()
|
||||
self.assertEqual(cfg.metrics_allowed_ips, env["METRICS_ALLOWED_IPS"])
|
||||
self.assertEqual(cfg.metrics_basic_auth_username, "prometheus")
|
||||
self.assertEqual(cfg.metrics_basic_auth_password, "secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -64,5 +64,7 @@ def test_routeros_deploy_forwards_ndr_and_persistence_controls():
|
||||
"BEHAVIOR_MIN_OBSERVATIONS", "NDR_AUTO_BLOCK", "NDR_AUTO_BLOCK_RISK",
|
||||
"ROUTEROS_INVENTORY_INTERVAL_SECONDS", "NOTIFY_WEBHOOK_URL",
|
||||
"NOTIFY_MIN_RISK", "NOTIFY_TIMEOUT_SECONDS",
|
||||
"METRICS_ALLOWED_IPS", "METRICS_BASIC_AUTH_USERNAME",
|
||||
"METRICS_BASIC_AUTH_PASSWORD",
|
||||
):
|
||||
assert f"key={key}" in script
|
||||
|
||||
@@ -66,6 +66,32 @@ class FlowTrackerTests(unittest.TestCase):
|
||||
self.assertFalse(first_persist)
|
||||
self.assertFalse(second_persist)
|
||||
|
||||
def test_status_exposes_monotonic_directional_traffic_counters(self):
|
||||
pipeline = _Pipeline()
|
||||
tracker = FlowTracker(
|
||||
TrafficNormalizer("192.168.100.0/24"),
|
||||
pipeline, # type: ignore[arg-type]
|
||||
max_flows=1000,
|
||||
)
|
||||
outbound = _ipv4_tcp_frame("192.168.100.10", 51000, "1.1.1.1", 443, b"out")
|
||||
inbound = _ipv4_tcp_frame("1.1.1.1", 443, "192.168.100.10", 51000, b"in")
|
||||
internal = _ipv4_tcp_frame("192.168.100.10", 51000, "192.168.100.20", 443, b"lan")
|
||||
external = _ipv4_tcp_frame("1.1.1.1", 51000, "8.8.8.8", 443, b"wan")
|
||||
|
||||
for frame in (outbound, inbound, internal, external):
|
||||
tracker.observe(frame)
|
||||
|
||||
traffic = tracker.status()["traffic_counters"]
|
||||
self.assertEqual(traffic["packets_total"], 4)
|
||||
self.assertEqual(traffic["packets_out"], 1)
|
||||
self.assertEqual(traffic["packets_in"], 1)
|
||||
self.assertEqual(traffic["packets_internal"], 1)
|
||||
self.assertEqual(traffic["packets_external"], 1)
|
||||
self.assertEqual(
|
||||
traffic["bytes_total"],
|
||||
traffic["bytes_out"] + traffic["bytes_in"] + traffic["bytes_internal"] + traffic["bytes_external"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.metrics import PrometheusMetrics
|
||||
from app.state import RuntimeStats
|
||||
from app.store import AlertStore
|
||||
from app.webui import WebServer
|
||||
|
||||
|
||||
class _Status:
|
||||
def __init__(self, **values):
|
||||
self.values = values
|
||||
|
||||
def status(self):
|
||||
return dict(self.values)
|
||||
|
||||
|
||||
class PrometheusMetricsTests(unittest.TestCase):
|
||||
def test_render_exports_raw_runtime_suricata_and_in_memory_component_state(self):
|
||||
stats = RuntimeStats()
|
||||
stats.inc("tzsp_datagrams", 5)
|
||||
stats.inc("frames_injected", 4)
|
||||
stats.stamp("last_packet_at")
|
||||
stats.update_suricata(
|
||||
{
|
||||
"capture": {"kernel_packets": 123, "kernel_drops": 2},
|
||||
"detect": {"alert_queue_overflow": 1},
|
||||
},
|
||||
"2026-08-16T07:00:00+00:00",
|
||||
)
|
||||
metrics = PrometheusMetrics(
|
||||
stats,
|
||||
version="1.2.3",
|
||||
mode="full",
|
||||
started_at=datetime(2026, 8, 16, 6, 0, tzinfo=timezone.utc),
|
||||
state_provider=lambda: {
|
||||
"components": {"suricata": True, "tzsp": True},
|
||||
"features": {"auto_block": False},
|
||||
},
|
||||
flow_tracker=_Status(
|
||||
active_flows=3,
|
||||
max_flows=20000,
|
||||
published_updates=7,
|
||||
evicted_flows=1,
|
||||
parse_errors=2,
|
||||
throughput_samples=9,
|
||||
update_interval_seconds=2.0,
|
||||
traffic_counters={
|
||||
"bytes_total": 10000,
|
||||
"bytes_in": 6000,
|
||||
"bytes_out": 3000,
|
||||
"bytes_internal": 750,
|
||||
"bytes_external": 250,
|
||||
"packets_total": 100,
|
||||
"packets_in": 60,
|
||||
"packets_out": 30,
|
||||
"packets_internal": 7,
|
||||
"packets_external": 3,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
rendered = metrics.render()
|
||||
self.assertIn('mikrosuricata_build_info{mode="full",version="1.2.3"} 1', rendered)
|
||||
self.assertIn("mikrosuricata_tzsp_datagrams_total 5", rendered)
|
||||
self.assertIn("mikrosuricata_frames_injected_total 4", rendered)
|
||||
self.assertIn("mikrosuricata_suricata_capture_kernel_packets 123", rendered)
|
||||
self.assertIn("mikrosuricata_suricata_capture_kernel_drops 2", rendered)
|
||||
self.assertIn('mikrosuricata_component_up{component="suricata"} 1', rendered)
|
||||
self.assertIn("mikrosuricata_flow_tracker_active_flows 3", rendered)
|
||||
self.assertIn('mikrosuricata_traffic_bytes_total{direction="total"} 10000', rendered)
|
||||
self.assertIn('mikrosuricata_traffic_bytes_total{direction="inbound"} 6000', rendered)
|
||||
self.assertIn('mikrosuricata_traffic_packets_total{direction="external"} 3', rendered)
|
||||
self.assertEqual(rendered.count("# TYPE mikrosuricata_traffic_bytes_total counter"), 1)
|
||||
self.assertNotIn("block_success_rate", rendered)
|
||||
self.assertNotIn("tzsp_to_tap_loss", rendered)
|
||||
self.assertEqual(rendered.count("# TYPE mikrosuricata_component_up gauge"), 1)
|
||||
|
||||
def test_runtime_metrics_snapshot_has_no_derived_values(self):
|
||||
stats = RuntimeStats()
|
||||
stats.inc("tzsp_datagrams", 10)
|
||||
stats.inc("frames_injected", 9)
|
||||
raw = stats.metrics_snapshot()
|
||||
self.assertNotIn("tzsp_to_tap_loss", raw)
|
||||
self.assertNotIn("block_success_rate", raw)
|
||||
|
||||
def test_metrics_endpoint_allows_default_loopback_acl_and_does_not_call_health_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,
|
||||
admin_password="test-password",
|
||||
)
|
||||
store = AlertStore(db_path)
|
||||
|
||||
def forbidden_health():
|
||||
raise AssertionError("/metrics must not call the health provider")
|
||||
|
||||
web = WebServer(
|
||||
cfg,
|
||||
store,
|
||||
forbidden_health,
|
||||
metrics_provider=lambda: "# TYPE mikrosuricata_test gauge\nmikrosuricata_test 1\n",
|
||||
)
|
||||
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}/metrics", timeout=2) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
self.assertEqual(200, response.status)
|
||||
self.assertIn("version=0.0.4", content_type)
|
||||
self.assertIn("mikrosuricata_test 1", body)
|
||||
finally:
|
||||
web.stop()
|
||||
store.close()
|
||||
|
||||
|
||||
def test_metrics_ip_acl_denies_before_rendering_metrics(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,
|
||||
metrics_allowed_ips="192.0.2.10/32",
|
||||
)
|
||||
store = AlertStore(db_path)
|
||||
calls = []
|
||||
web = WebServer(
|
||||
cfg,
|
||||
store,
|
||||
lambda: {},
|
||||
metrics_provider=lambda: calls.append(True) or "mikrosuricata_test 1\n",
|
||||
)
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
web.start()
|
||||
port = web.server.server_address[1]
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=2)
|
||||
self.assertEqual(403, caught.exception.code)
|
||||
self.assertEqual([], calls)
|
||||
finally:
|
||||
web.stop()
|
||||
store.close()
|
||||
|
||||
def test_metrics_basic_auth_requires_valid_credentials_after_ip_acl(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,
|
||||
metrics_allowed_ips="127.0.0.1",
|
||||
metrics_basic_auth_username="prometheus",
|
||||
metrics_basic_auth_password="strong-secret",
|
||||
)
|
||||
store = AlertStore(db_path)
|
||||
calls = []
|
||||
web = WebServer(
|
||||
cfg,
|
||||
store,
|
||||
lambda: {},
|
||||
metrics_provider=lambda: calls.append(True) or "mikrosuricata_test 1\n",
|
||||
)
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
web.start()
|
||||
port = web.server.server_address[1]
|
||||
url = f"http://127.0.0.1:{port}/metrics"
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
urllib.request.urlopen(url, timeout=2)
|
||||
self.assertEqual(401, caught.exception.code)
|
||||
self.assertIn("Basic", caught.exception.headers.get("WWW-Authenticate", ""))
|
||||
self.assertEqual([], calls)
|
||||
|
||||
bad = urllib.request.Request(url, headers={"Authorization": "Basic !!!"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
urllib.request.urlopen(bad, timeout=2)
|
||||
self.assertEqual(401, caught.exception.code)
|
||||
self.assertEqual([], calls)
|
||||
|
||||
token = base64.b64encode(b"prometheus:strong-secret").decode("ascii")
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"Basic {token}"})
|
||||
with urllib.request.urlopen(request, timeout=2) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertEqual(200, response.status)
|
||||
self.assertIn("mikrosuricata_test 1", body)
|
||||
self.assertEqual([True], calls)
|
||||
finally:
|
||||
web.stop()
|
||||
store.close()
|
||||
|
||||
def test_metrics_basic_auth_configuration_must_be_complete(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,
|
||||
metrics_basic_auth_username="prometheus",
|
||||
metrics_basic_auth_password="",
|
||||
)
|
||||
store = AlertStore(db_path)
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError, "must either both be set"):
|
||||
WebServer(cfg, store, lambda: {}, metrics_provider=lambda: "")
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_metrics_acl_rejects_invalid_network_configuration(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,
|
||||
metrics_allowed_ips="not-an-ip",
|
||||
)
|
||||
store = AlertStore(db_path)
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError, "invalid METRICS_ALLOWED_IPS"):
|
||||
WebServer(cfg, store, lambda: {}, metrics_provider=lambda: "")
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user