Add host correlation and assurance coverage
This commit is contained in:
parent
8194d13734
commit
b40ac4252c
36 changed files with 944 additions and 23 deletions
10
tests/fixtures/sids/100069-interpreter-persistence-write.json
vendored
Normal file
10
tests/fixtures/sids/100069-interpreter-persistence-write.json
vendored
Normal 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"]
|
||||
}
|
||||
10
tests/fixtures/sids/100070-persistence-permission-change.json
vendored
Normal file
10
tests/fixtures/sids/100070-persistence-permission-change.json
vendored
Normal 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"
|
||||
}
|
||||
9
tests/fixtures/sids/100071-interpreter-setuid-root.json
vendored
Normal file
9
tests/fixtures/sids/100071-interpreter-setuid-root.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "setuid",
|
||||
"pid": 4244,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"comm": "python3",
|
||||
"parent_comm": "bash",
|
||||
"target_uid": 0
|
||||
}
|
||||
9
tests/fixtures/sids/100072-interpreter-setgid-root.json
vendored
Normal file
9
tests/fixtures/sids/100072-interpreter-setgid-root.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "setgid",
|
||||
"pid": 4245,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"comm": "python3",
|
||||
"parent_comm": "bash",
|
||||
"target_gid": 0
|
||||
}
|
||||
|
|
@ -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
28
tests/test_assurance.py
Normal 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
45
tests/test_correlation.py
Normal 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
42
tests/test_first_seen.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue