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()
|
||||
125
tests/test_reconcile_cli.py
Normal file
125
tests/test_reconcile_cli.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for the `baseline accept/revoke/list` CLI surface."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import reconcile
|
||||
from enodia_sentinel.cli import main
|
||||
|
||||
|
||||
def _run(*argv) -> tuple[int, str, str]:
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = main(list(argv))
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
|
||||
class BaselineCLITest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self._old = os.environ.get("ENODIA_LOG_DIR")
|
||||
os.environ["ENODIA_LOG_DIR"] = self.d.name
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
if self._old is None:
|
||||
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||
else:
|
||||
os.environ["ENODIA_LOG_DIR"] = self._old
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _store(self):
|
||||
from enodia_sentinel.config import Config
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
return reconcile.ReconcileStore.load(Config.load())
|
||||
|
||||
def test_accept_fim_path_round_trips(self):
|
||||
f = Path(self.d.name) / "watched.conf"
|
||||
f.write_text("hello")
|
||||
code, _out, _err = _run("baseline", "accept", "fim", str(f),
|
||||
"--reason", "operator edit")
|
||||
self.assertEqual(code, 0)
|
||||
recs = self._store().list()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0].kind, "fim")
|
||||
self.assertEqual(recs[0].reason, "operator edit")
|
||||
self.assertIn("sha256", recs[0].fingerprint)
|
||||
|
||||
def test_accept_requires_reason(self):
|
||||
f = Path(self.d.name) / "x"
|
||||
f.write_text("y")
|
||||
code, _out, err = _run("baseline", "accept", "fim", str(f))
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("reason", err.lower())
|
||||
|
||||
def test_accept_missing_live_data_requires_force(self):
|
||||
code, _out, err = _run("baseline", "accept", "listener", "65000/ghost",
|
||||
"--reason", "temp")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("force", err.lower())
|
||||
self.assertEqual(self._store().list(), [])
|
||||
|
||||
def test_accept_force_overrides_missing_live_data(self):
|
||||
code, _out, _err = _run("baseline", "accept", "listener",
|
||||
"65000/ghost", "--reason", "temp", "--force")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(self._store().list()), 1)
|
||||
|
||||
def test_accept_invalid_expires_errors(self):
|
||||
code, _out, err = _run("baseline", "accept", "listener", "1/a",
|
||||
"--reason", "r", "--force", "--expires", "soon")
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("duration", err.lower())
|
||||
|
||||
def test_revoke_removes_then_reports_missing(self):
|
||||
_run("baseline", "accept", "listener", "1/a", "--reason", "r", "--force")
|
||||
code, _o, _e = _run("baseline", "revoke", "listener", "1/a")
|
||||
self.assertEqual(code, 0)
|
||||
code2, _o2, err2 = _run("baseline", "revoke", "listener", "1/a")
|
||||
self.assertEqual(code2, 1)
|
||||
self.assertIn("not found", err2.lower())
|
||||
|
||||
def test_list_json_emits_records(self):
|
||||
f = Path(self.d.name) / "c.conf"
|
||||
f.write_text("z")
|
||||
_run("baseline", "accept", "fim", str(f), "--reason", "edit")
|
||||
code, out, _err = _run("baseline", "list", "--json")
|
||||
self.assertEqual(code, 0)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(len(data["records"]), 1)
|
||||
self.assertEqual(data["records"][0]["kind"], "fim")
|
||||
|
||||
def test_list_stale_exit_code(self):
|
||||
# no acks -> exit 0
|
||||
code, _o, _e = _run("baseline", "list", "--stale")
|
||||
self.assertEqual(code, 0)
|
||||
# an expired ack -> exit 1
|
||||
_run("baseline", "accept", "listener", "1/a", "--reason", "r",
|
||||
"--force", "--expires", "0m")
|
||||
code2, _o2, _e2 = _run("baseline", "list", "--stale")
|
||||
self.assertEqual(code2, 1)
|
||||
|
||||
def test_bare_baseline_still_builds(self):
|
||||
# The legacy `baseline` (no action) must keep routing to the rebuild
|
||||
# path. Stub the expensive filesystem scan; we only assert the dispatch.
|
||||
from enodia_sentinel import cli
|
||||
called = []
|
||||
orig = cli.Sentinel.build_baselines
|
||||
cli.Sentinel.build_baselines = lambda self: called.append(True)
|
||||
try:
|
||||
code, out, _err = _run("baseline")
|
||||
finally:
|
||||
cli.Sentinel.build_baselines = orig
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(called)
|
||||
self.assertIn("Baselines written", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
137
tests/test_reconcile_integration.py
Normal file
137
tests/test_reconcile_integration.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Reconciliation wired into the daemon chokepoint, fim-check, and the web API."""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import fim, reconcile, web
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.cli import _cmd_fim_check
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.daemon import Sentinel
|
||||
|
||||
|
||||
def _cfg(tmp: Path) -> Config:
|
||||
cfg = Config()
|
||||
cfg.log_dir = tmp
|
||||
return cfg
|
||||
|
||||
|
||||
class DaemonChokepointTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _accept(self, kind, target, fp, **kw):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept(kind, target, fp, "reason", "luna", **kw)
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_unacked_alert_passes_through(self):
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d")
|
||||
self.assertEqual(len(s.fresh_alerts([a], time.time())), 1)
|
||||
|
||||
def test_acked_listener_is_dropped(self):
|
||||
self._accept("listener", "8080/nginx", {"key": "8080/nginx"})
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d")
|
||||
self.assertEqual(s.fresh_alerts([a], time.time()), [])
|
||||
|
||||
def test_acked_fim_dropped_only_while_fingerprint_matches(self):
|
||||
live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 4}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self._accept("fim", "/etc/hosts", fp)
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "fim_modified", "fim:mod:/etc/hosts", "d")
|
||||
# matching live state -> suppressed
|
||||
self.assertEqual(
|
||||
s.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": live}}), [])
|
||||
# changed again -> re-alerts
|
||||
s2 = Sentinel(self.cfg)
|
||||
changed = dict(live, sha256="b")
|
||||
self.assertEqual(
|
||||
len(s2.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": changed}})), 1)
|
||||
|
||||
|
||||
class FimCheckSuppressionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self.d.name)
|
||||
self.watch = self.tmp / "watched"
|
||||
self.watch.mkdir()
|
||||
self.cfg = _cfg(self.tmp)
|
||||
self.cfg.fim_paths = (str(self.watch),)
|
||||
self.cfg.fim_pkg_verify = False
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _baseline(self):
|
||||
# Seed a baseline directly so the command doesn't shell out to pacman.
|
||||
base = fim.scan_paths(self.cfg.fim_path_list())
|
||||
self.cfg.fim_baseline.write_text(__import__("json").dumps(base))
|
||||
|
||||
def _fim_check(self):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = _cmd_fim_check(self.cfg, packages=False)
|
||||
return code, out.getvalue()
|
||||
|
||||
def test_modified_path_reported_then_suppressed_after_accept(self):
|
||||
f = self.watch / "app.conf"
|
||||
f.write_text("v1")
|
||||
self._baseline()
|
||||
f.write_text("v2-tampered")
|
||||
code, out = self._fim_check()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(str(f), out)
|
||||
# accept the drift -> the path drops out of fim-check
|
||||
live = fim.scan_paths([str(f)]).get(str(f))
|
||||
fp = reconcile.build_fingerprint("fim", str(f), live)
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("fim", str(f), fp, "rolled out v2", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
code2, out2 = self._fim_check()
|
||||
self.assertEqual(code2, 0)
|
||||
self.assertNotIn(str(f), out2)
|
||||
|
||||
|
||||
class WebReconciliationBlockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_integrity_report_has_reconciliation_summary(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
past = reconcile._utcnow() - __import__("datetime").timedelta(days=1)
|
||||
store.accept("listener", "1/a", {"key": "1/a"}, "ok now", "luna",
|
||||
expires_at=reconcile._utcnow() + __import__("datetime").timedelta(days=1))
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "expired", "luna",
|
||||
expires_at=past)
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
report = web.integrity_report(self.cfg)
|
||||
recon = report["reconciliation"]
|
||||
self.assertEqual(recon["total"], 2)
|
||||
self.assertEqual(recon["stale"], 1)
|
||||
self.assertEqual(report["checks"]["reconciliation"], "review")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -9,7 +9,7 @@ from io import StringIO
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from enodia_sentinel import incident, respond, schemas, snapshot, web
|
||||
from enodia_sentinel import 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
|
||||
|
|
@ -77,6 +77,27 @@ class TestSchemaContracts(unittest.TestCase):
|
|||
"pids": list,
|
||||
})
|
||||
|
||||
def test_reconcile_v1_contract(self):
|
||||
self.assertEqual(schemas.RECONCILE_V1, "enodia.reconcile.v1")
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("fim", "/etc/hosts",
|
||||
{"sha256": "a", "mode": 1, "uid": 0, "gid": 0},
|
||||
"operator edit", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
data = json.loads(
|
||||
reconcile.ReconcileStore.store_path(self.cfg).read_text())
|
||||
self.assertEqual(data["schema"], schemas.RECONCILE_V1)
|
||||
_assert_types(self, data, {"schema": str, "records": list})
|
||||
_assert_types(self, data["records"][0], {
|
||||
"kind": str,
|
||||
"target": str,
|
||||
"fingerprint": dict,
|
||||
"reason": str,
|
||||
"actor": str,
|
||||
"accepted_at": str,
|
||||
"status": str,
|
||||
})
|
||||
|
||||
def test_alert_snapshot_v1_contract(self):
|
||||
with patch("enodia_sentinel.snapshot._run", return_value=""):
|
||||
path = snapshot.capture([_alert()], self._State(), self.cfg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue