Add host correlation and assurance coverage

This commit is contained in:
Luna 2026-07-10 03:07:00 -07:00
parent 8194d13734
commit b40ac4252c
No known key found for this signature in database
36 changed files with 944 additions and 23 deletions

View file

@ -0,0 +1,10 @@
{
"event": "file_write",
"pid": 4242,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"parent_comm": "bash",
"path": "/etc/systemd/system/evil.service",
"argv": ["-c", "write unit"]
}

View file

@ -0,0 +1,10 @@
{
"event": "chmod",
"pid": 4243,
"ppid": 1,
"uid": 0,
"comm": "chmod",
"parent_comm": "bash",
"path": "/etc/cron.d/backup",
"mode": "0o777"
}

View file

@ -0,0 +1,9 @@
{
"event": "setuid",
"pid": 4244,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"parent_comm": "bash",
"target_uid": 0
}

View file

@ -0,0 +1,9 @@
{
"event": "setgid",
"pid": 4245,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"parent_comm": "bash",
"target_gid": 0
}

View file

@ -8,13 +8,14 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from enodia_sentinel import fim, pkgdb, posture, rootcheck
from enodia_sentinel.alert import Alert
from enodia_sentinel import correlation, fim, pkgdb, posture, rootcheck
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config
from enodia_sentinel.detectors import (
credential_access,
deleted_exe,
egress,
first_seen,
input_snooper,
ld_preload,
memory_obfuscation,
@ -131,6 +132,43 @@ def _pkgverify() -> list[Alert]:
pkgdb.siglevel_alert = saved
def _first_seen_public_destination() -> list[Alert]:
with tempfile.TemporaryDirectory() as d:
cfg = _cfg()
cfg.log_dir = Path(d)
list(first_seen.detect(SystemState(sockets=[
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", 1, "curl", 400, "tcp"),
]), cfg))
return list(first_seen.detect(SystemState(sockets=[
Socket("ESTAB", "10.0.0.2:5001", "1.1.1.1:8443", 2, "curl", 400, "tcp"),
]), cfg))
def _first_seen_listener_port() -> list[Alert]:
with tempfile.TemporaryDirectory() as d:
cfg = _cfg()
cfg.log_dir = Path(d)
list(first_seen.detect(SystemState(sockets=[
Socket("LISTEN", "0.0.0.0:8000", "*:*", 1, "python3", 401, "tcp"),
]), cfg))
return list(first_seen.detect(SystemState(sockets=[
Socket("LISTEN", "0.0.0.0:9000", "*:*", 2, "python3", 401, "tcp"),
]), cfg))
def _correlation_alert() -> list[Alert]:
inc = {
"first_ts": 1000.0,
"last_ts": 1050.0,
"signatures": ["exec_rule.web-rce", "host_rule.suspicious-egress"],
}
return [
Alert(Severity.CRITICAL, c["signature"], f"corr:{c['sid']}",
c["summary"], sid=c["sid"], classtype=c["classtype"])
for c in correlation.correlate(inc)
]
def _fim_pkg() -> list[Alert]:
saved = fim.pacman_verify
fim.pacman_verify = lambda timeout=600: [
@ -321,4 +359,7 @@ DRILLS: dict[int, Callable[[], list[Alert]]] = {
posture.UnitFact("evil.service", "/etc/systemd/system/evil.service",
0o100644, True, ("/tmp/payload",)),
])),
100074: _first_seen_public_destination,
100075: _first_seen_listener_port,
100080: _correlation_alert,
}

28
tests/test_assurance.py Normal file
View file

@ -0,0 +1,28 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import tempfile
import unittest
from pathlib import Path
from enodia_sentinel import assurance
from enodia_sentinel.config import Config
class TestHashChain(unittest.TestCase):
def test_artifact_records_chain_to_previous_hash(self):
with tempfile.TemporaryDirectory() as d:
cfg = Config()
cfg.log_dir = Path(d)
one = cfg.log_dir / "one.txt"
two = cfg.log_dir / "two.txt"
one.write_text("one")
two.write_text("two")
r1 = assurance.append_artifact(cfg, "test", one)
r2 = assurance.append_artifact(cfg, "test", two)
self.assertEqual(r2["prev_hash"], r1["chain_hash"])
summary = assurance.summary(cfg)
self.assertEqual(summary["count"], 2)
self.assertEqual(summary["last_hash"], r2["chain_hash"])
if __name__ == "__main__":
unittest.main()

45
tests/test_correlation.py Normal file
View file

@ -0,0 +1,45 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import tempfile
import unittest
from pathlib import Path
from enodia_sentinel import correlation, incident
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config
def _alert(sig: str, sid: int) -> Alert:
return Alert(Severity.HIGH, sig, sig, sig, (4242,), sid=sid,
classtype=sig.rsplit(".", 1)[-1])
class TestCorrelation(unittest.TestCase):
def test_web_rce_plus_suspicious_egress_correlates(self):
inc = {
"first_ts": 1000.0,
"last_ts": 1050.0,
"signatures": ["exec_rule.web-rce", "host_rule.suspicious-egress"],
}
out = correlation.correlate(inc)
self.assertEqual(out[0]["sid"], correlation.SID_MULTI_STAGE_INTRUSION)
def test_incident_record_persists_correlation(self):
with tempfile.TemporaryDirectory() as d:
cfg = Config()
cfg.log_dir = Path(d)
iid = incident.record(
cfg, "alert-1.log", [_alert("exec_rule.web-rce", 100003)],
{4242}, 1000.0, "h")
incident.record(
cfg, "alert-2.log", [_alert("host_rule.suspicious-egress", 100067)],
{4242}, 1050.0, "h")
data = json.loads((Path(d) / "incidents.json").read_text())[iid]
self.assertEqual(data["severity"], "CRITICAL")
self.assertIn(correlation.SID_MULTI_STAGE_INTRUSION, data["sids"])
self.assertEqual(data["correlations"][0]["signature"],
"correlation.multi_stage_intrusion")
if __name__ == "__main__":
unittest.main()

42
tests/test_first_seen.py Normal file
View file

@ -0,0 +1,42 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import tempfile
import unittest
from pathlib import Path
from enodia_sentinel.config import Config
from enodia_sentinel.detectors import first_seen
from enodia_sentinel.system import Socket, SystemState
class TestFirstSeen(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.cfg = Config()
self.cfg.log_dir = Path(self.dir.name)
def tearDown(self):
self.dir.cleanup()
def test_first_pass_baselines_without_alerting_then_new_dest_alerts(self):
first = SystemState(sockets=[
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", 1, "curl", 400, "tcp"),
])
self.assertEqual(list(first_seen.detect(first, self.cfg)), [])
second = SystemState(sockets=[
Socket("ESTAB", "10.0.0.2:5001", "1.1.1.1:8443", 2, "curl", 400, "tcp"),
])
alerts = list(first_seen.detect(second, self.cfg))
self.assertEqual(alerts[0].sid, first_seen.SID_FIRST_PUBLIC_DESTINATION)
def test_new_listener_port_alerts_after_baseline(self):
list(first_seen.detect(SystemState(sockets=[
Socket("LISTEN", "0.0.0.0:8000", "*:*", 1, "python3", 401, "tcp"),
]), self.cfg))
alerts = list(first_seen.detect(SystemState(sockets=[
Socket("LISTEN", "0.0.0.0:9000", "*:*", 2, "python3", 401, "tcp"),
]), self.cfg))
self.assertEqual(alerts[0].sid, first_seen.SID_FIRST_LISTENER_PORT)
if __name__ == "__main__":
unittest.main()

View file

@ -95,6 +95,35 @@ class TestRuleEventCompatibility(unittest.TestCase):
}
self.assertIn(100068, self._sids(event))
def test_host_event_accepts_file_write_shape(self):
self.assertIn(100069, self._sids({
"event": "file_write",
"pid": 4242,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"path": "/etc/systemd/system/evil.service",
}))
def test_host_event_accepts_chmod_and_setuid_shapes(self):
self.assertIn(100070, self._sids({
"event": "chmod",
"pid": 4242,
"ppid": 1,
"uid": 0,
"comm": "chmod",
"path": "/etc/cron.d/evil",
"mode": "0o777",
}))
self.assertIn(100071, self._sids({
"event": "setuid",
"pid": 4242,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"target_uid": "0",
}))
def test_unknown_event_shape_still_errors(self):
with self.assertRaisesRegex(
ValueError, "event JSON must be an exec, syscall, or host event"

View file

@ -20,6 +20,10 @@ class TestRuleOps(unittest.TestCase):
self.assertIn(100060, sids)
self.assertIn(100067, sids)
self.assertIn(100068, sids)
self.assertIn(100069, sids)
self.assertIn(100070, sids)
self.assertIn(100071, sids)
self.assertIn(100072, sids)
exec_rule = next(r for r in rules if r["sid"] == 100002)
self.assertEqual(exec_rule["event"], "exec")
self.assertIn("argv_regex", exec_rule["conditions"])
@ -29,6 +33,9 @@ class TestRuleOps(unittest.TestCase):
listener_rule = next(r for r in rules if r["sid"] == 100068)
self.assertEqual(listener_rule["event"], "listen")
self.assertIn("local_port_exclude", listener_rule["conditions"])
write_rule = next(r for r in rules if r["sid"] == 100069)
self.assertEqual(write_rule["event"], "file_write")
self.assertIn("path_prefixes", write_rule["conditions"])
def test_configured_exec_rule_is_listed(self):
with tempfile.TemporaryDirectory() as d:

View file

@ -9,7 +9,7 @@ from io import StringIO
from pathlib import Path
from unittest.mock import patch
from enodia_sentinel import incident, reconcile, respond, schemas, snapshot, web
from enodia_sentinel import assurance, incident, reconcile, respond, schemas, snapshot, web
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.cli import main
from enodia_sentinel.config import Config
@ -134,6 +134,7 @@ class TestSchemaContracts(unittest.TestCase):
"lineage": list,
"snapshots": list,
"alert_count": int,
"correlations": list,
})
view = web.get_incident(self.cfg, iid)
@ -215,9 +216,26 @@ class TestSchemaContracts(unittest.TestCase):
"watchdog": dict,
"anchors": dict,
"sentinel_footprint": dict,
"hash_chain": dict,
"read_only": bool,
})
def test_hash_chain_v1_contract(self):
artifact = self.tmp / "artifact.json"
artifact.write_text("{}")
record = assurance.append_artifact(self.cfg, "test-artifact", artifact)
self.assertEqual(record["schema"], schemas.HASH_CHAIN_V1)
_assert_types(self, record, {
"schema": str,
"time": float,
"kind": str,
"path": str,
"size": int,
"sha256": str,
"prev_hash": str,
"chain_hash": str,
})
def test_response_plan_and_audit_contracts(self):
iid = self._record_incident_with_snapshot()
bundle = respond.load_bundle(self.cfg, iid)

View file

@ -20,7 +20,7 @@ class TestRegistry(unittest.TestCase):
self.assertEqual(len(nums), len(set(nums)))
def test_count_and_helpers(self):
self.assertEqual(len(sids.BUILTIN_SIDS), 55)
self.assertEqual(len(sids.BUILTIN_SIDS), 62)
self.assertEqual(
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
)

View file

@ -113,6 +113,12 @@ class TestTuiCore(unittest.TestCase):
"severity": "CRITICAL",
"alert_count": 1,
"signatures": ["reverse_shell"],
"correlations": [{
"sid": 100080,
"severity": "CRITICAL",
"signature": "correlation.multi_stage_intrusion",
"summary": "web RCE plus suspicious egress",
}],
},
"timeline": [{
"time": "2026-07-09T01:02:03-07:00",
@ -138,6 +144,12 @@ class TestTuiCore(unittest.TestCase):
"pacman": {"keyring_present": True, "siglevel_status": "ok"},
},
"sentinel_footprint": {"present": 2, "configured": 3, "missing": 1},
"hash_chain": {
"path": "/tmp/hash-chain.jsonl",
"exists": True,
"count": 2,
"last_hash": "abc123",
},
"reconciliation": {
"total": 1,
"stale": 1,
@ -172,8 +184,10 @@ class TestTuiCore(unittest.TestCase):
timeline = render_lines(TuiState(view="timeline", model=model), width=100)
self.assertTrue(any("inc-1" in line for line in timeline))
self.assertTrue(any("pids=4242" in line for line in timeline))
self.assertTrue(any("correlation sid=100080" in line for line in timeline))
integrity = render_lines(TuiState(view="integrity", model=model), width=100)
self.assertTrue(any("Overall:" in line for line in integrity))
self.assertTrue(any("Hash chain:" in line for line in integrity))
self.assertTrue(any("ttl expired" in line for line in integrity))
events = render_lines(TuiState(view="events", model=model), width=100)
self.assertTrue(any("technical logs" in line for line in events))

View file

@ -193,6 +193,9 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(report["anchors"]["pkgdb"]["fingerprint_prefix"], "0123456789abcdef")
self.assertTrue(report["read_only"])
self.assertIn("sentinel_footprint", report)
self.assertIn("hash_chain", report)
self.assertEqual(report["checks"]["hash_chain"], "missing")
self.assertFalse(report["hash_chain"]["exists"])
def test_dashboard_has_persistent_theme_controls(self):
html = (web._STATIC / "dashboard.html").read_text()