171 lines
7.3 KiB
Python
171 lines
7.3 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
import io
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
|
|
from enodia_sentinel import incident, respond
|
|
from enodia_sentinel.alert import Alert, Severity
|
|
from enodia_sentinel.cli import main
|
|
from enodia_sentinel.config import Config
|
|
|
|
|
|
def _alert(sig, detail, pids=(), sid=1, sev=Severity.HIGH):
|
|
return Alert(severity=sev, signature=sig, key=f"k:{sig}",
|
|
detail=detail, pids=tuple(pids), sid=sid, classtype="test")
|
|
|
|
|
|
class TestRespondPlan(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
self.tmp = Path(self.dir.name)
|
|
self.cfg = Config()
|
|
self.cfg.log_dir = self.tmp
|
|
self.iid = incident.record(self.cfg, "alert-1.log", [
|
|
_alert("reverse_shell",
|
|
"pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
|
|
pids=[4242], sid=100010, sev=Severity.CRITICAL),
|
|
_alert("persistence",
|
|
"persistence file modified: /etc/systemd/system/bad.service",
|
|
sid=100015),
|
|
_alert("new_suid",
|
|
"SUID/SGID binary in writable dir: /tmp/suidsh",
|
|
sid=100014, sev=Severity.CRITICAL),
|
|
], lineage={4242, 100}, when=1000.0, host="h")
|
|
(self.tmp / "alert-1.json").write_text(json.dumps({
|
|
"time": "2026-06-10T10:00:00-07:00",
|
|
"host": "h",
|
|
"severity": "CRITICAL",
|
|
"incident_id": self.iid,
|
|
"alerts": [
|
|
{"signature": "reverse_shell", "sid": 100010,
|
|
"detail": "pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
|
|
"pids": [4242]},
|
|
{"signature": "persistence", "sid": 100015,
|
|
"detail": "persistence file modified: /etc/systemd/system/bad.service",
|
|
"pids": []},
|
|
{"signature": "new_suid", "sid": 100014,
|
|
"detail": "SUID/SGID binary in writable dir: /tmp/suidsh",
|
|
"pids": []},
|
|
],
|
|
"processes": [{"pid": 4242, "comm": "bash"}],
|
|
}))
|
|
|
|
def tearDown(self):
|
|
self.dir.cleanup()
|
|
|
|
def test_builds_dry_run_plan_from_incident(self):
|
|
bundle = respond.load_bundle(self.cfg, self.iid)
|
|
plan = respond.build_plan(bundle, self.cfg)
|
|
self.assertEqual(plan["mode"], "dry-run")
|
|
self.assertFalse(plan["apply_supported"])
|
|
commands = [a["command"] for a in plan["actions"]]
|
|
self.assertIn(["kill", "-STOP", "4242"], commands)
|
|
self.assertIn(["kill", "-TERM", "4242"], commands)
|
|
self.assertIn(["nft", "add", "rule", "inet", "filter", "output",
|
|
"ip", "daddr", "8.8.8.8", "drop"], commands)
|
|
self.assertIn(["systemctl", "disable", "--now", "bad.service"], commands)
|
|
self.assertIn(["mv", "--", "/tmp/suidsh",
|
|
"/tmp/suidsh.enodia-quarantine"], commands)
|
|
self.assertIn(["enodia-sentinel", "check", "--json"], commands)
|
|
self.assertIn(["enodia-sentinel", "watchdog", "--url",
|
|
"https://<dashboard-host>:8787", "--token", "<token>",
|
|
"--insecure-tls"], commands)
|
|
self.assertNotIn(["mv", "--", "/etc/systemd/system/bad.service",
|
|
"/etc/systemd/system/bad.service.enodia-quarantine"],
|
|
commands)
|
|
self.assertEqual(plan["actions"][0]["category"], "evidence")
|
|
|
|
def test_package_recovery_adds_signed_package_verify(self):
|
|
iid = incident.record(self.cfg, "alert-2.log", [
|
|
_alert("pkg_signature_mismatch",
|
|
"signed package mismatch /usr/bin/ssh",
|
|
sid=100022, sev=Severity.CRITICAL),
|
|
], lineage=set(), when=1001.0, host="h")
|
|
(self.tmp / "alert-2.json").write_text(json.dumps({
|
|
"time": "2026-06-10T10:01:00-07:00",
|
|
"host": "h",
|
|
"severity": "CRITICAL",
|
|
"incident_id": iid,
|
|
"alerts": [
|
|
{"signature": "pkg_signature_mismatch", "sid": 100022,
|
|
"detail": "signed package mismatch /usr/bin/ssh",
|
|
"pids": []},
|
|
],
|
|
"processes": [],
|
|
}))
|
|
self.cfg.dashboard_url = "https://100.64.0.2:8787/"
|
|
plan = respond.build_plan(respond.load_bundle(self.cfg, iid), self.cfg)
|
|
commands = [a["command"] for a in plan["actions"]]
|
|
self.assertIn(["enodia-sentinel", "fim-check", "--packages"], commands)
|
|
self.assertIn(["enodia-sentinel", "pkgdb-verify"], commands)
|
|
self.assertIn(["pacman", "-Qo", "/usr/bin/ssh"], commands)
|
|
self.assertIn(["enodia-sentinel", "watchdog", "--url",
|
|
"https://100.64.0.2:8787", "--token", "<token>",
|
|
"--insecure-tls"], commands)
|
|
|
|
def test_cli_json(self):
|
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
|
try:
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
code = main(["respond", "plan", self.iid, "--json"])
|
|
self.assertEqual(code, 0)
|
|
plan = json.loads(buf.getvalue())
|
|
self.assertEqual(plan["incident_id"], self.iid)
|
|
self.assertGreaterEqual(plan["summary"]["action_count"], 5)
|
|
artifacts = plan["artifacts"]
|
|
saved = Path(artifacts["plan_path"])
|
|
audit = Path(artifacts["audit_log"])
|
|
self.assertTrue(saved.is_file())
|
|
self.assertTrue(audit.is_file())
|
|
stored = json.loads(saved.read_text())
|
|
self.assertEqual(stored["plan_id"], plan["plan_id"])
|
|
self.assertEqual(stored["artifacts"]["plan_path"], str(saved))
|
|
record = json.loads(audit.read_text().splitlines()[-1])
|
|
self.assertEqual(record["event"], "response_plan_generated")
|
|
self.assertEqual(record["incident_id"], self.iid)
|
|
self.assertEqual(record["plan_path"], str(saved))
|
|
finally:
|
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
|
|
|
def test_cli_text_reports_persisted_plan(self):
|
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
|
try:
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
code = main(["respond", "plan", self.iid])
|
|
self.assertEqual(code, 0)
|
|
out = buf.getvalue()
|
|
self.assertIn("saved:", out)
|
|
self.assertIn("audit:", out)
|
|
plans = list((self.tmp / "response-plans").glob("*.json"))
|
|
self.assertEqual(len(plans), 1)
|
|
self.assertTrue((self.tmp / "response-audit.log").is_file())
|
|
finally:
|
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
|
|
|
def test_cli_requires_incident_id(self):
|
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
|
try:
|
|
with redirect_stdout(io.StringIO()):
|
|
code = main(["respond", "plan"])
|
|
self.assertEqual(code, 2)
|
|
finally:
|
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
|
|
|
def test_unknown_incident(self):
|
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
|
try:
|
|
with redirect_stdout(io.StringIO()):
|
|
code = main(["respond", "plan", "inc-nope"])
|
|
self.assertEqual(code, 1)
|
|
finally:
|
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|