From 9784033f86a9b8b1f3cf52e6ec104e664f6d7bea Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 04:11:33 -0700 Subject: [PATCH] Add host event rules for bind and capability activity --- docs/ROADMAP.md | 7 +- docs/RULES.md | 76 +++++++++++++++++++ enodia_sentinel/events/host_event.py | 4 + enodia_sentinel/events/host_rules.py | 63 ++++++++++++++- enodia_sentinel/ruleops.py | 45 +++++++++++ .../sids/100073-interpreter-unusual-bind.json | 9 +++ .../100076-interpreter-unusual-accept.json | 11 +++ .../100077-interpreter-capset-sensitive.json | 8 ++ .../100078-module-load-writable-path.json | 9 +++ tests/test_rule_compat.py | 38 ++++++++++ tests/test_ruleops.py | 52 +++++++++++++ tests/test_sids.py | 2 +- 12 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/sids/100073-interpreter-unusual-bind.json create mode 100644 tests/fixtures/sids/100076-interpreter-unusual-accept.json create mode 100644 tests/fixtures/sids/100077-interpreter-capset-sensitive.json create mode 100644 tests/fixtures/sids/100078-module-load-writable-path.json diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 28d65c0..f2d7922 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -136,10 +136,9 @@ Purpose: reduce blind spots and connect related signals. flags interpreters opening listeners on unusual local ports, with local-port match conditions and a replayable fixture. - ✅ Continue generalizing the rule engine across more typed host events: - `file_write`, `chmod/chown`, and `setuid/setgid` now have built-in rules, - typed event parsing, fixtures, and rule-operation tests. -- Continue extending typed host-event coverage to: - `bind`, `accept`, `capset`, and module-load events. + `file_write`, `chmod/chown`, `setuid/setgid`, `bind`, `accept`, `capset`, + and module-load now have built-in rules, typed event parsing, fixtures, and + rule-operation tests. - Keep rules declarative and Snort-like: `sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as process name, parent process, argv regex, path prefix, peer IP/port, UID/GID, diff --git a/docs/RULES.md b/docs/RULES.md index 1806f01..6b54c21 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -317,3 +317,79 @@ Expected false positives: - Privileged installer or service-management scripts during maintenance windows. Drill or fixture: Use `rules test` with `tests/fixtures/sids/100072-interpreter-setgid-root.json`. + +## SID 100073: Interpreter bound an unusual local port + +- Event: `bind` +- Signature: `host_rule.suspicious-bind` +- Classtype: `suspicious-bind` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `bind` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `local_port_exclude`: `22`, `53`, `80`, `123`, `443`, `853` + +Expected false positives: +- Developer or diagnostic scripts binding high local ports intentionally. +- Short-lived local service wrappers that bind before handing sockets to a supervised process. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100073-interpreter-unusual-bind.json`. + +## SID 100076: Interpreter accepted inbound traffic on an unusual local port + +- Event: `accept` +- Signature: `host_rule.suspicious-accept` +- Classtype: `suspicious-accept` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `accept` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `peer_public`: `True` +- `local_port_exclude`: `22`, `53`, `80`, `123`, `443`, `853` + +Expected false positives: +- Developer servers or test harnesses accepting inbound public traffic from an interpreter. +- Administrative troubleshooting with temporary netcat/socat listeners. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100076-interpreter-unusual-accept.json`. + +## SID 100077: Interpreter requested sensitive Linux capabilities + +- Event: `capset` +- Signature: `host_rule.capability-escalation` +- Classtype: `capability-escalation` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `capset` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `capabilities_any`: `CAP_DAC_READ_SEARCH`, `CAP_NET_ADMIN`, `CAP_NET_RAW`, `CAP_SYS_ADMIN`, `CAP_SYS_MODULE`, `CAP_SYS_PTRACE` + +Expected false positives: +- Privileged maintenance scripts that intentionally adjust capabilities during controlled administration. +- Container or network lab setup scripts using interpreters to configure namespaces or packet capture. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100077-interpreter-capset-sensitive.json`. + +## SID 100078: Kernel module load requested from a writable runtime path + +- Event: `module_load` +- Signature: `host_rule.suspicious-module-load` +- Classtype: `suspicious-module-load` +- Severity: `CRITICAL` +- Origin: `builtin` + +Match fields: +- `events`: `module_load` +- `path_prefixes`: `/tmp/`, `/var/tmp/`, `/dev/shm/`, `/run/user/` + +Expected false positives: +- Kernel development labs loading locally built modules from temporary build directories. +- Driver troubleshooting sessions that intentionally test an unsigned module from a writable path. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100078-module-load-writable-path.json`. diff --git a/enodia_sentinel/events/host_event.py b/enodia_sentinel/events/host_event.py index 2796fc4..10c4c26 100644 --- a/enodia_sentinel/events/host_event.py +++ b/enodia_sentinel/events/host_event.py @@ -22,6 +22,8 @@ class HostEvent: target_uid: int = -1 target_gid: int = -1 mode: int = 0 + capabilities: tuple[str, ...] = field(default_factory=tuple) + module_name: str = "" @property def argv_str(self) -> str: @@ -44,4 +46,6 @@ class HostEvent: "target_uid": self.target_uid, "target_gid": self.target_gid, "mode": self.mode, + "capabilities": list(self.capabilities), + "module_name": self.module_name, } diff --git a/enodia_sentinel/events/host_rules.py b/enodia_sentinel/events/host_rules.py index 5f08437..fc75e76 100644 --- a/enodia_sentinel/events/host_rules.py +++ b/enodia_sentinel/events/host_rules.py @@ -21,6 +21,15 @@ _PERSISTENCE_PREFIXES = ( "/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/etc/sudoers.d/", ) +_WRITABLE_RUNTIME_PREFIXES = ("/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/") +_SENSITIVE_CAPABILITIES = frozenset({ + "CAP_SYS_ADMIN", + "CAP_SYS_MODULE", + "CAP_SYS_PTRACE", + "CAP_DAC_READ_SEARCH", + "CAP_NET_ADMIN", + "CAP_NET_RAW", +}) @dataclass(frozen=True) @@ -40,6 +49,8 @@ class HostRule: path_prefixes: tuple[str, ...] = () target_uids: frozenset[int] = frozenset() target_gids: frozenset[int] = frozenset() + capabilities_any: frozenset[str] = frozenset() + module_names: frozenset[str] = frozenset() argv_regex: str | None = None def __post_init__(self) -> None: @@ -49,7 +60,8 @@ class HostRule: self.comm, self.parent_comm, self.peer_public is not None, self.peer_ports, self.peer_port_exclude, self.local_ports, self.local_port_exclude, self.path_prefixes, self.target_uids, - self.target_gids, self.argv_regex, + self.target_gids, self.capabilities_any, self.module_names, + self.argv_regex, )): raise ValueError(f"rule sid={self.sid} has no match conditions") if self.argv_regex is not None: @@ -84,6 +96,12 @@ class HostRule: return False if self.target_gids and ev.target_gid not in self.target_gids: return False + if self.capabilities_any and not ( + {cap.upper() for cap in ev.capabilities} & self.capabilities_any + ): + return False + if self.module_names and ev.module_name not in self.module_names: + return False if self._argv_re is not None and not self._argv_re.search(ev.argv_str): return False return True @@ -95,14 +113,17 @@ class HostRule: key=( f"host:{self.sid}:{ev.event}:{ev.pid}:" f"{ev.peer_ip}:{ev.peer_port}:{ev.local_ip}:{ev.local_port}:" - f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}" + f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}:" + f"{','.join(ev.capabilities)}:{ev.module_name}" ), detail=( f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} " f"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port} " f"local={ev.local_ip}:{ev.local_port} path={ev.path} " f"target_uid={ev.target_uid} target_gid={ev.target_gid} " - f"mode={oct(ev.mode) if ev.mode else '-'}" + f"mode={oct(ev.mode) if ev.mode else '-'} " + f"capabilities={','.join(ev.capabilities) or '-'} " + f"module={ev.module_name or '-'}" ), pids=(ev.pid,), sid=self.sid, @@ -130,6 +151,15 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = ( comm=_INTERPRETERS, local_port_exclude=_COMMON_PUBLIC_PORTS, ), + HostRule( + sid=100073, + msg="Interpreter bound an unusual local port", + severity=Severity.HIGH, + classtype="suspicious-bind", + events=frozenset({"bind"}), + comm=_INTERPRETERS, + local_port_exclude=_COMMON_PUBLIC_PORTS, + ), HostRule( sid=100069, msg="Interpreter wrote to a persistence path", @@ -165,6 +195,33 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = ( comm=_INTERPRETERS, target_gids=frozenset({0}), ), + HostRule( + sid=100076, + msg="Interpreter accepted inbound traffic on an unusual local port", + severity=Severity.HIGH, + classtype="suspicious-accept", + events=frozenset({"accept"}), + comm=_INTERPRETERS, + peer_public=True, + local_port_exclude=_COMMON_PUBLIC_PORTS, + ), + HostRule( + sid=100077, + msg="Interpreter requested sensitive Linux capabilities", + severity=Severity.HIGH, + classtype="capability-escalation", + events=frozenset({"capset"}), + comm=_INTERPRETERS, + capabilities_any=_SENSITIVE_CAPABILITIES, + ), + HostRule( + sid=100078, + msg="Kernel module load requested from a writable runtime path", + severity=Severity.CRITICAL, + classtype="suspicious-module-load", + events=frozenset({"module_load"}), + path_prefixes=_WRITABLE_RUNTIME_PREFIXES, + ), ) diff --git a/enodia_sentinel/ruleops.py b/enodia_sentinel/ruleops.py index 5b825a9..c64696f 100644 --- a/enodia_sentinel/ruleops.py +++ b/enodia_sentinel/ruleops.py @@ -174,6 +174,10 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]: conditions["target_uids"] = sorted(rule.target_uids) if rule.target_gids: conditions["target_gids"] = sorted(rule.target_gids) + if rule.capabilities_any: + conditions["capabilities_any"] = sorted(rule.capabilities_any) + if rule.module_names: + conditions["module_names"] = sorted(rule.module_names) if rule.argv_regex: conditions["argv_regex"] = rule.argv_regex return { @@ -280,6 +284,34 @@ _RULE_DOCS: dict[int, dict[str, Any]] = { ], "drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.", }, + 100073: { + "false_positives": [ + "Developer or diagnostic scripts binding high local ports intentionally.", + "Short-lived local service wrappers that bind before handing sockets to a supervised process.", + ], + "drill": "Use `rules test` with a `bind` event whose `comm` is an interpreter and whose `local_port` is not a common service port.", + }, + 100076: { + "false_positives": [ + "Developer servers or test harnesses accepting inbound public traffic from an interpreter.", + "Administrative troubleshooting with temporary netcat/socat listeners.", + ], + "drill": "Use `rules test` with an `accept` event whose `comm` is an interpreter, public `peer_ip`, and unusual `local_port`.", + }, + 100077: { + "false_positives": [ + "Privileged maintenance scripts that intentionally adjust capabilities during controlled administration.", + "Container or network lab setup scripts using interpreters to configure namespaces or packet capture.", + ], + "drill": "Use `rules test` with a `capset` event containing a sensitive capability such as `CAP_SYS_ADMIN`.", + }, + 100078: { + "false_positives": [ + "Kernel development labs loading locally built modules from temporary build directories.", + "Driver troubleshooting sessions that intentionally test an unsigned module from a writable path.", + ], + "drill": "Use `rules test` with a `module_load` event whose `path` is under `/tmp`, `/var/tmp`, `/dev/shm`, or `/run/user`.", + }, } @@ -368,6 +400,11 @@ def _host_event(event: dict[str, Any]) -> HostEvent: target_uid=_to_int(event.get("target_uid", event.get("uid_target", -1))), target_gid=_to_int(event.get("target_gid", event.get("gid_target", -1))), mode=_to_int(event.get("mode", 0)), + capabilities=_to_str_tuple(event.get( + "capabilities", + event.get("caps", event.get("cap_effective", event.get("capability", ()))), + )), + module_name=str(event.get("module_name", event.get("name", ""))), ) @@ -375,3 +412,11 @@ def _to_int(value: Any) -> int: if isinstance(value, str): return int(value, 0) return int(value) + + +def _to_str_tuple(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return (value.upper(),) if value else () + return tuple(str(item).upper() for item in value) diff --git a/tests/fixtures/sids/100073-interpreter-unusual-bind.json b/tests/fixtures/sids/100073-interpreter-unusual-bind.json new file mode 100644 index 0000000..d71f0e5 --- /dev/null +++ b/tests/fixtures/sids/100073-interpreter-unusual-bind.json @@ -0,0 +1,9 @@ +{ + "event": "bind", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "local_ip": "0.0.0.0", + "local_port": 4444 +} diff --git a/tests/fixtures/sids/100076-interpreter-unusual-accept.json b/tests/fixtures/sids/100076-interpreter-unusual-accept.json new file mode 100644 index 0000000..7d9a457 --- /dev/null +++ b/tests/fixtures/sids/100076-interpreter-unusual-accept.json @@ -0,0 +1,11 @@ +{ + "event": "accept", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "peer_ip": "8.8.8.8", + "peer_port": 51515, + "local_ip": "0.0.0.0", + "local_port": 4444 +} diff --git a/tests/fixtures/sids/100077-interpreter-capset-sensitive.json b/tests/fixtures/sids/100077-interpreter-capset-sensitive.json new file mode 100644 index 0000000..825b7d4 --- /dev/null +++ b/tests/fixtures/sids/100077-interpreter-capset-sensitive.json @@ -0,0 +1,8 @@ +{ + "event": "capset", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "capabilities": ["CAP_SYS_ADMIN"] +} diff --git a/tests/fixtures/sids/100078-module-load-writable-path.json b/tests/fixtures/sids/100078-module-load-writable-path.json new file mode 100644 index 0000000..61a7847 --- /dev/null +++ b/tests/fixtures/sids/100078-module-load-writable-path.json @@ -0,0 +1,9 @@ +{ + "event": "module_load", + "pid": 4242, + "ppid": 1, + "uid": 0, + "comm": "insmod", + "path": "/tmp/evil.ko", + "module_name": "evil" +} diff --git a/tests/test_rule_compat.py b/tests/test_rule_compat.py index 0a40d31..93f2f91 100644 --- a/tests/test_rule_compat.py +++ b/tests/test_rule_compat.py @@ -124,6 +124,44 @@ class TestRuleEventCompatibility(unittest.TestCase): "target_uid": "0", })) + def test_host_event_accepts_bind_accept_capset_and_module_shapes(self): + self.assertIn(100073, self._sids({ + "type": "bind", + "pid": "4242", + "ppid": "1", + "uid": "1000", + "comm": "python3", + "bind_ip": "0.0.0.0", + "bind_port": "4444", + })) + self.assertIn(100076, self._sids({ + "event": "accept", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "remote_ip": "8.8.8.8", + "remote_port": "51515", + "local_port": "4444", + })) + self.assertIn(100077, self._sids({ + "event": "capset", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "capability": "CAP_SYS_ADMIN", + })) + self.assertIn(100078, self._sids({ + "event": "module_load", + "pid": 4242, + "ppid": 1, + "uid": 0, + "comm": "insmod", + "path": "/tmp/evil.ko", + "name": "evil", + })) + def test_unknown_event_shape_still_errors(self): with self.assertRaisesRegex( ValueError, "event JSON must be an exec, syscall, or host event" diff --git a/tests/test_ruleops.py b/tests/test_ruleops.py index f52912f..0302bf4 100644 --- a/tests/test_ruleops.py +++ b/tests/test_ruleops.py @@ -24,6 +24,10 @@ class TestRuleOps(unittest.TestCase): self.assertIn(100070, sids) self.assertIn(100071, sids) self.assertIn(100072, sids) + self.assertIn(100073, sids) + self.assertIn(100076, sids) + self.assertIn(100077, sids) + self.assertIn(100078, 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"]) @@ -36,6 +40,9 @@ class TestRuleOps(unittest.TestCase): 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"]) + cap_rule = next(r for r in rules if r["sid"] == 100077) + self.assertEqual(cap_rule["event"], "capset") + self.assertIn("capabilities_any", cap_rule["conditions"]) def test_configured_exec_rule_is_listed(self): with tempfile.TemporaryDirectory() as d: @@ -135,6 +142,51 @@ exec_comm = ["id"] } self.assertEqual(ruleops.test_event(cfg, event), []) + def test_matches_bind_accept_capset_and_module_load_events(self): + cfg = Config() + cases = ( + (100073, { + "event": "bind", + "pid": 42, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "local_ip": "0.0.0.0", + "local_port": 4444, + }), + (100076, { + "event": "accept", + "pid": 42, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "peer_ip": "8.8.8.8", + "peer_port": 51515, + "local_ip": "0.0.0.0", + "local_port": 4444, + }), + (100077, { + "event": "capset", + "pid": 42, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "capabilities": ["CAP_SYS_ADMIN"], + }), + (100078, { + "event": "module_load", + "pid": 42, + "ppid": 1, + "uid": 0, + "comm": "insmod", + "path": "/tmp/evil.ko", + "module_name": "evil", + }), + ) + for sid, event in cases: + with self.subTest(sid=sid): + self.assertIn(sid, {a.sid for a in 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) diff --git a/tests/test_sids.py b/tests/test_sids.py index c1ea729..22430c2 100644 --- a/tests/test_sids.py +++ b/tests/test_sids.py @@ -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), 62) + self.assertEqual(len(sids.BUILTIN_SIDS), 66) self.assertEqual( sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS) )