Add baseline reconciliation: accept audited drift with a reason
Implements the v0.9 roadmap item from the approved design spec. Operators accept a specific FIM/package/listener/SUID drift item with a mandatory reason; the ack suppresses that one alert only while the live state still matches the recorded fingerprint. Content kinds (fim/pkgfile) re-alert on further change; identity kinds (listener/suid) retire on TTL or revoke. - reconcile.py: ReconcileStore (mtime-cached, fails closed on missing/corrupt store), fingerprint builders, and the filter_alerts chokepoint. - CLI: baseline accept/revoke/list with --reason/--expires/--force/--stale/--json. - Wired at the daemon sweep + eBPF chokepoint, fim-check, and /api/integrity. - RECONCILE_V1 schema, config knob, COMMAND_REFERENCE/SCHEMAS/OPERATIONS/ROADMAP docs, and reconcile unit/CLI/integration tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3d2047fde2
commit
dbbe35b3fc
18 changed files with 1155 additions and 28 deletions
218
tests/test_reconcile.py
Normal file
218
tests/test_reconcile.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for baseline reconciliation: fingerprints, store I/O, alert filtering."""
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import reconcile
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.config import Config
|
||||
|
||||
|
||||
def _cfg(tmp: Path) -> Config:
|
||||
cfg = Config()
|
||||
cfg.log_dir = tmp
|
||||
return cfg
|
||||
|
||||
|
||||
def _alert(key: str, sig: str = "x") -> Alert:
|
||||
return Alert(Severity.HIGH, sig, key, "detail")
|
||||
|
||||
|
||||
class TestAlertIdentity(unittest.TestCase):
|
||||
def test_fim_modified_added_removed_map_to_fim(self):
|
||||
for prefix in ("fim:mod:", "fim:add:", "fim:del:"):
|
||||
kind, target = reconcile.alert_identity(prefix + "/etc/hosts")
|
||||
self.assertEqual((kind, target), ("fim", "/etc/hosts"))
|
||||
|
||||
def test_pkgfile_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("fim:pkg:/usr/bin/ssh"),
|
||||
("pkgfile", "/usr/bin/ssh"))
|
||||
|
||||
def test_listener_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("lis:8080/nginx"),
|
||||
("listener", "8080/nginx"))
|
||||
|
||||
def test_suid_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("suid:/usr/bin/sudo"),
|
||||
("suid", "/usr/bin/sudo"))
|
||||
|
||||
def test_unknown_key_is_passthrough(self):
|
||||
self.assertEqual(reconcile.alert_identity("rsh:1234"), (None, None))
|
||||
|
||||
|
||||
class TestParseDuration(unittest.TestCase):
|
||||
def test_days_hours_minutes(self):
|
||||
self.assertEqual(reconcile.parse_duration("7d"), timedelta(days=7))
|
||||
self.assertEqual(reconcile.parse_duration("12h"), timedelta(hours=12))
|
||||
self.assertEqual(reconcile.parse_duration("30m"), timedelta(minutes=30))
|
||||
|
||||
def test_invalid_raises(self):
|
||||
for bad in ("", "7", "d", "7x", "-1d", "1.5h"):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.parse_duration(bad)
|
||||
|
||||
|
||||
class TestBuildFingerprint(unittest.TestCase):
|
||||
def test_fim_extracts_content_fields_only(self):
|
||||
live = {"mode": 33188, "uid": 0, "gid": 0, "size": 12,
|
||||
"sha256": "abc123"}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self.assertEqual(fp, {"sha256": "abc123", "mode": 33188,
|
||||
"uid": 0, "gid": 0})
|
||||
|
||||
def test_fim_absent_target_is_all_none(self):
|
||||
fp = reconcile.build_fingerprint("fim", "/gone", None)
|
||||
self.assertEqual(fp, {"sha256": None, "mode": None,
|
||||
"uid": None, "gid": None})
|
||||
|
||||
def test_pkgfile_sorts_reasons(self):
|
||||
fp = reconcile.build_fingerprint(
|
||||
"pkgfile", "/usr/bin/ssh", ["size mismatch", "checksum mismatch"])
|
||||
self.assertEqual(fp, {"reasons": ["checksum mismatch", "size mismatch"]})
|
||||
|
||||
def test_listener_and_suid_are_identity(self):
|
||||
self.assertEqual(
|
||||
reconcile.build_fingerprint("listener", "8080/nginx", None),
|
||||
{"key": "8080/nginx"})
|
||||
self.assertEqual(
|
||||
reconcile.build_fingerprint("suid", "/usr/bin/sudo", None),
|
||||
{"path": "/usr/bin/sudo"})
|
||||
|
||||
|
||||
class TestStoreRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_missing_file_is_empty_store(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
def test_corrupt_json_fails_closed_to_empty(self):
|
||||
reconcile.ReconcileStore.store_path(self.cfg).write_text("{not json")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
def test_accept_persists_and_reloads(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts",
|
||||
{"sha256": "a", "mode": 1, "uid": 0, "gid": 0})
|
||||
store.accept("fim", "/etc/hosts", fp, "added host entry", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
again = reconcile.ReconcileStore.load(self.cfg)
|
||||
recs = again.list()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0].target, "/etc/hosts")
|
||||
self.assertEqual(recs[0].reason, "added host entry")
|
||||
self.assertEqual(recs[0].actor, "luna")
|
||||
self.assertEqual(recs[0].status, "ok")
|
||||
|
||||
def test_accept_existing_updates_returns_true(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
fp = {"key": "8080/nginx"}
|
||||
self.assertFalse(store.accept("listener", "8080/nginx", fp, "r1", "luna"))
|
||||
self.assertTrue(store.accept("listener", "8080/nginx", fp, "r2", "luna"))
|
||||
self.assertEqual(len(store.list()), 1)
|
||||
|
||||
def test_revoke_removes_and_reports_missing(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("suid", "/usr/bin/sudo", {"path": "/usr/bin/sudo"}, "r", "luna")
|
||||
self.assertTrue(store.revoke("suid", "/usr/bin/sudo"))
|
||||
self.assertFalse(store.revoke("suid", "/usr/bin/sudo"))
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
|
||||
class TestFilterAlerts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
self.store = reconcile.ReconcileStore.load(self.cfg)
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_unacked_alert_passes_through(self):
|
||||
out = self.store.filter_alerts([_alert("fim:mod:/etc/hosts")], {})
|
||||
self.assertEqual(len(out), 1)
|
||||
|
||||
def test_matching_fim_ack_suppresses(self):
|
||||
live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 9}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self.store.accept("fim", "/etc/hosts", fp, "edit", "luna")
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": live}})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
def test_changed_fim_passes_and_marks_stale(self):
|
||||
accepted = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0}
|
||||
self.store.accept("fim", "/etc/hosts", accepted, "edit", "luna")
|
||||
changed = {"sha256": "DIFFERENT", "mode": 33188, "uid": 0, "gid": 0}
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": changed}})
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(self.store.list()[0].status, "stale")
|
||||
|
||||
def test_accepted_removal_stays_suppressed_when_absent(self):
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/gone", None)
|
||||
self.store.accept("fim", "/etc/gone", fp, "decommissioned", "luna")
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:del:/etc/gone")], {"fim": {}})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
def test_expired_ttl_passes_and_marks_stale(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"},
|
||||
"temp", "luna", expires_at=past)
|
||||
out = self.store.filter_alerts([_alert("lis:8080/nginx")], {})
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(self.store.list()[0].status, "stale")
|
||||
|
||||
def test_listener_ack_suppresses_within_ttl(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"},
|
||||
"temp", "luna", expires_at=future)
|
||||
out = self.store.filter_alerts([_alert("lis:8080/nginx")], {})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
|
||||
class TestListAndStale(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_list_stale_only_filters(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
store.accept("listener", "1/a", {"key": "1/a"}, "ok", "luna",
|
||||
expires_at=future)
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna",
|
||||
expires_at=past)
|
||||
stale = store.list(stale_only=True)
|
||||
self.assertEqual([r.target for r in stale], ["2/b"])
|
||||
|
||||
def test_list_persists_stale_transition(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna",
|
||||
expires_at=past)
|
||||
store.list() # triggers TTL re-evaluation + lazy write-back
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
reloaded = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(reloaded.list()[0].status, "stale")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue