Adds asyncio-native background scheduler (TURNSTONE_INGEST_INTERVAL, default 900s) that runs batch ingest then pushes pattern-matched entries to a remote CF harvest endpoint (TURNSTONE_SUBMIT_ENDPOINT). - app/tasks/ingest_scheduler.py: IngestState, scheduler_loop, run_once, submit_matched, _query_matched_since — asyncio.Lock prevents concurrent runs - app/rest.py: POST /api/ingest/batch (pre-parsed entry receiver), GET /api/tasks/ingest/status, POST /api/tasks/ingest (manual trigger), TURNSTONE_INGEST_INTERVAL + TURNSTONE_SUBMIT_ENDPOINT env wiring in lifespan - docker-compose.submissions.yml: segregated daniel (8536) + xander (8537) receiving instances on Heimdall, isolated DBs under /devl/docker/turnstone-submissions/<node>/ - podman-standalone.sh: pass-through for TURNSTONE_SUBMIT_ENDPOINT + TURNSTONE_SOURCE_HOST - app/ingest/mqtt_subscriber.py: MQTT log source adapter - app/ingest/wazuh.py: Wazuh alert JSON adapter - tests/test_ingest_wazuh.py: Wazuh adapter test suite
118 lines
4 KiB
Python
118 lines
4 KiB
Python
"""Tests for the Wazuh alert ingestor."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from app.ingest.wazuh import is_wazuh_alert, parse
|
|
from app.ingest.pipeline import _detect_format
|
|
|
|
_ALERT = {
|
|
"timestamp": "2024-01-15T10:23:45.123+0000",
|
|
"rule": {
|
|
"level": 7,
|
|
"description": "SSH authentication failure.",
|
|
"id": "5710",
|
|
"firedtimes": 1,
|
|
"groups": ["syslog", "sshd", "authentication_failed"],
|
|
},
|
|
"agent": {"id": "001", "name": "web-server-01", "ip": "192.168.1.100"},
|
|
"manager": {"name": "wazuh-mgr"},
|
|
"id": "1705312125.123456",
|
|
"full_log": "Jan 15 10:23:45 web-server-01 sshd[1234]: Failed password for admin from 10.0.0.5",
|
|
"location": "/var/log/auth.log",
|
|
"data": {"srcip": "10.0.0.5", "srcuser": "admin"},
|
|
}
|
|
|
|
_CRITICAL_ALERT = {
|
|
"timestamp": "2024-01-15T10:30:00.000+0000",
|
|
"rule": {"level": 13, "description": "Rootkit detected.", "id": "510", "groups": ["rootcheck"]},
|
|
"agent": {"id": "002", "name": "db-host", "ip": "192.168.1.200"},
|
|
"manager": {"name": "wazuh-mgr"},
|
|
"full_log": "rootkit patterns found",
|
|
"location": "/var/ossec/logs/active-responses.log",
|
|
}
|
|
|
|
|
|
class TestDetector:
|
|
def test_detects_valid_alert(self):
|
|
assert is_wazuh_alert(_ALERT)
|
|
|
|
def test_detects_minimal_alert(self):
|
|
assert is_wazuh_alert({
|
|
"timestamp": "2024-01-15T10:23:45+0000",
|
|
"rule": {"level": 5, "description": "test"},
|
|
"agent": {"name": "host"},
|
|
})
|
|
|
|
def test_rejects_journald(self):
|
|
assert not is_wazuh_alert({"__REALTIME_TIMESTAMP": "123", "MESSAGE": "hi"})
|
|
|
|
def test_rejects_caddy(self):
|
|
assert not is_wazuh_alert({"ts": 1234, "msg": "served", "request": {}})
|
|
|
|
def test_rejects_no_agent(self):
|
|
assert not is_wazuh_alert({"rule": {"level": 5}, "timestamp": "2024-01-01T00:00:00Z"})
|
|
|
|
def test_pipeline_routes_to_wazuh(self):
|
|
assert _detect_format(json.dumps(_ALERT)) == "wazuh"
|
|
|
|
|
|
class TestParser:
|
|
def _parse(self, *alerts) -> list:
|
|
lines = [json.dumps(a) for a in alerts]
|
|
return list(parse(iter(lines), "wazuh", []))
|
|
|
|
def test_single_entry_parsed(self):
|
|
entries = self._parse(_ALERT)
|
|
assert len(entries) == 1
|
|
|
|
def test_severity_from_level(self):
|
|
entries = self._parse(_ALERT)
|
|
assert entries[0].severity == "WARN" # level 7
|
|
|
|
def test_critical_severity(self):
|
|
entries = self._parse(_CRITICAL_ALERT)
|
|
assert entries[0].severity == "CRITICAL" # level 13
|
|
|
|
def test_source_id_includes_agent(self):
|
|
entries = self._parse(_ALERT)
|
|
assert entries[0].source_id == "wazuh:web-server-01"
|
|
|
|
def test_text_contains_rule_description(self):
|
|
entries = self._parse(_ALERT)
|
|
assert "SSH authentication failure" in entries[0].text
|
|
|
|
def test_text_contains_agent_name(self):
|
|
entries = self._parse(_ALERT)
|
|
assert "web-server-01" in entries[0].text
|
|
|
|
def test_text_contains_decoded_data(self):
|
|
entries = self._parse(_ALERT)
|
|
assert "10.0.0.5" in entries[0].text
|
|
|
|
def test_text_contains_full_log(self):
|
|
entries = self._parse(_ALERT)
|
|
assert "Failed password" in entries[0].text
|
|
|
|
def test_timestamp_parsed_to_utc(self):
|
|
entries = self._parse(_ALERT)
|
|
dt = datetime.fromisoformat(entries[0].timestamp_iso)
|
|
assert dt.utcoffset() is not None
|
|
assert dt.hour == 10 and dt.minute == 23 and dt.second == 45
|
|
|
|
def test_skips_malformed_json(self):
|
|
lines = iter(["not json\n", json.dumps(_ALERT)])
|
|
entries = list(parse(lines, "wazuh", []))
|
|
assert len(entries) == 1
|
|
|
|
def test_skips_empty_lines(self):
|
|
lines = iter(["\n", " \n", json.dumps(_ALERT)])
|
|
entries = list(parse(lines, "wazuh", []))
|
|
assert len(entries) == 1
|
|
|
|
def test_multi_alert_sequence(self):
|
|
entries = self._parse(_ALERT, _CRITICAL_ALERT)
|
|
assert len(entries) == 2
|
|
seqs = [e.sequence for e in entries]
|
|
assert seqs == sorted(seqs)
|