diff --git a/README.md b/README.md index 2505185..96f2479 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ EDRs are built on: | `input_snooper` | A non-allowlisted process holding `/dev/input`, `/dev/uinput`, or HID raw devices open | Keyloggers have to read keystroke/event devices somewhere; expected compositors/remappers are tunable | | `credential_access` | A non-allowlisted process with shadow files, private SSH keys, browser stores, or secret profiles open | Credential harvesters have to open the material they steal; legitimate auth/keyring/browser readers are tunable | | `stealth_network` | Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable | -| `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, or mapped process-hiding libraries | Encrypted/packed payloads still need executable memory after decrypting; hide libraries must be mapped to hook tools | +| `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, mapped process-hiding libraries, or executable `.so` mappings from writable runtime paths | Encrypted/packed payloads still need executable memory after decrypting; hide/injection libraries must be mapped to hook tools | | `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere | | `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | @@ -229,11 +229,15 @@ enodia-sentinel-tui The TUI mirrors the read-only management console for terminal operators: status, recent alerts, incidents, posture findings, integrity/watchdog state, incident timelines, event-rule metadata, event tail, and dry-run response-plan -previews. +previews. It includes plain-language view descriptions and a beginner workflow: +start at `1` Status, then use `3` Incidents, `4` Timeline, `9` Response Plans, +and `6` Integrity. The TUI displays evidence and suggested commands but does not +change the host. Keys: `1` status, `2` alerts, `3` incidents, `4` timelines, `5` posture, -`6` integrity, `7` rules, `8` events, `9` response plans, `r` refresh, `/` filter, `:` -command mode, `Tab` complete command names, `q` quit. +`6` integrity, `7` rules, `8` events, `9` response plans, `n`/`b` next/back in +the beginner workflow, `r` refresh, `/` filter, `:` command mode, `Tab` complete +command names, `q` quit. Shell completion is generated without extra dependencies: diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 832f528..1ce995d 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -403,10 +403,16 @@ daemon status, recent alert snapshots, incident index and timelines, posture findings, integrity/watchdog state, event-rule metadata, event tail, and read-only response-plan previews. +Beginner-oriented prompts in each view explain what the data means and suggest a +safe flow: check Status first, review grouped Incidents, read the Timeline, +inspect Response Plans, then confirm Integrity. The TUI is read-only; it never +executes response commands. + Keys: - `1` / `2` / `3` / `4`: status, alerts, incidents, timelines. - `5` / `6` / `7` / `8` / `9`: posture, integrity, rules, events, response plans. +- `n` / `b`: next/back through the beginner workflow. - `j` / `k` or arrows: scroll. - `r`: refresh. - `/`: filter the current view. @@ -416,7 +422,8 @@ Keys: - `q`: quit. Command mode supports `status`, `alerts`, `incidents`, `timeline`, `posture`, -`integrity`, `rules`, `events`, `response`, `help`, `refresh`, +`integrity`, `rules`, `events`, `response`, `help`, `home`, `guide`, `next`, +`back`, `refresh`, `filter `, `clear`, and `quit`. ### `web` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0a504e9..59eb8b8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -80,8 +80,10 @@ for quick remote review. For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over the same local read-only management state: status, recent alerts, incidents, incident timelines, posture findings, integrity/watchdog state, event-rule -metadata, event tail, and dry-run response-plan previews. Use `:` for command -mode and `Tab` to complete TUI commands. +metadata, event tail, and dry-run response-plan previews. Newer operators can +follow the built-in workflow hints: Status -> Incidents -> Timeline -> Response +Plans -> Integrity. Press `n`/`b` to step through that workflow, or use `:` for +command mode and `Tab` to complete TUI commands. On desktop hosts the optional tray applet (`enodia-sentinel-tray`, `[tray]` extra) wraps the same workflow: it drives the same `systemctl` units operators diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5961bd6..c2b8acb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,7 +44,8 @@ work that is bigger than single alerts. raw ICMP/special-protocol rootcheck detection. - ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps` cross-view checks plus memory-map scans for hide libraries, RWX memory, and - executable anonymous/memfd/deleted mappings. + executable anonymous/memfd/deleted mappings, plus writable-path executable + library mappings that suggest userland process injection. - ✅ Add event-driven memory telemetry for short-lived `mprotect`/`mmap` RWX, `memfd_create`, `ptrace`, seccomp, cross-process memory access, and memory locking syscalls. @@ -131,8 +132,11 @@ Purpose: reduce blind spots and connect related signals. - ✅ Add the first typed host-event rule family beside exec/syscall rules: `rules test` accepts `tcp_connect` event JSON and ships SID `100067` for interpreter egress to unusual public ports, with a replayable fixture. +- ✅ Add a second typed host-event rule family for `listen` events: SID `100068` + 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: - `bind`, `listen`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`, + `bind`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`, `capset`, and module-load events. - Keep rules declarative and Snort-like: `sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as diff --git a/docs/RULES.md b/docs/RULES.md index 6961442..0513eab 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -223,3 +223,22 @@ Expected false positives: - Developer tooling using interpreters for custom APIs on high ports. Drill or fixture: Use `rules test` with a `tcp_connect` event whose `comm` is an interpreter and whose public `peer_port` is not a common service port. + +## SID 100068: Interpreter opened a listener on an unusual local port + +- Event: `listen` +- Signature: `host_rule.suspicious-listener` +- Classtype: `suspicious-listener` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `listen` +- `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 HTTP servers, netcat listeners, or local test harnesses intentionally opened from an interpreter. +- Administrative troubleshooting that temporarily listens on a high port. + +Drill or fixture: Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port. diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index f4b90d5..186a0b1 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -243,14 +243,18 @@ modules you intentionally trust. --- -## Runbook 5 — Memory obfuscation or process-hiding library +## Runbook 5 — Memory obfuscation, process hiding, or injection library -**Triggers:** `memory_obfuscation` (100039), `process_hiding_library` (100048) +**Triggers:** `memory_obfuscation` (100039), `process_hiding_library` (100048), +`process_injection_library` (100049) These alerts come from `/proc//maps`. They do not read process memory; they flag map shapes associated with encrypted/packed payloads or userland hiding: RWX pages, executable anonymous memory, executable `memfd`/deleted mappings, or -libraries with process-hiding names such as `libhide`. +libraries with process-hiding names such as `libhide`. They also flag executable +shared-library mappings from writable/runtime paths such as `/tmp`, `/dev/shm`, +`/var/tmp`, and `/run/user`, which are common places for injected userland hooks +or staged `.so` payloads. **Confirm** @@ -259,6 +263,9 @@ libraries with process-hiding names such as `libhide`. - For `process_hiding_library`, check whether the mapped library is package-owned and whether it is loaded into tools like `ps`, `pgrep`, `ss`, shells, or admin utilities. +- For `process_injection_library`, preserve the mapped `.so`, check its owner, + hash, parent process, and whether it was deliberately loaded by a known + profiler, debugger, or local instrumentation tool. - For `memory_obfuscation`, distinguish expected JIT runtimes from unknown implants. Tune known runtimes with `memory_obfuscation_allow_comms`; use `memory_obfuscation_allow_paths` only for well-understood local runtimes or diff --git a/enodia_sentinel/detectors/memory_obfuscation.py b/enodia_sentinel/detectors/memory_obfuscation.py index 3a43625..f2e8f5f 100644 --- a/enodia_sentinel/detectors/memory_obfuscation.py +++ b/enodia_sentinel/detectors/memory_obfuscation.py @@ -1,10 +1,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later -"""memory_obfuscation — memory-map indicators of hiding or encrypted payloads. +"""memory_obfuscation — memory-map indicators of hiding or injected payloads. Encrypted/packed implants still need executable memory after decrypting code. This detector does not read process memory; it only inspects ``/proc//maps`` for high-signal shapes: executable anonymous mappings, executable memfd/deleted -mappings, RWX pages, and mapped libraries associated with process hiding. +mappings, RWX pages, and mapped libraries associated with process hiding or +process injection. """ from __future__ import annotations @@ -16,10 +17,13 @@ from ..system import MemoryMap, SystemState SID_MEMORY_OBFUSCATION = 100039 SID_PROCESS_HIDING_LIBRARY = 100048 +SID_PROCESS_INJECTION_LIBRARY = 100049 _HIDE_LIBRARY_HINTS = frozenset({ "libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide", }) +_WRITABLE_MAP_PREFIXES = ("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/") +_LIB_EXTENSIONS = (".so", ".so.", ".dylib") def _maps(proc) -> list[MemoryMap]: @@ -40,6 +44,14 @@ def _hide_library(path: str) -> bool: return any(hint in name for hint in _HIDE_LIBRARY_HINTS) +def _writable_library(path: str) -> bool: + lower = path.lower() + if not lower.startswith(_WRITABLE_MAP_PREFIXES): + return False + name = lower.rsplit("/", 1)[-1] + return any(ext in name for ext in _LIB_EXTENSIONS) + + def _allowed_path(path: str, cfg: Config) -> bool: for prefix in cfg.memory_obfuscation_allow_paths: if prefix and path.startswith(prefix): @@ -57,6 +69,10 @@ def _indicator(mm: MemoryMap, cfg: Config) -> tuple[str, Severity, str]: ) executable = "x" in mm.perms writable = "w" in mm.perms + if executable and path and _writable_library(path): + return "process_injection_library", Severity.HIGH, ( + f"executable library mapped from writable/runtime path: {path}" + ) if executable and ("memfd:" in path or "(deleted)" in path): return "memory_obfuscation", Severity.CRITICAL, ( f"executable transient mapping perms={mm.perms} path={path or ''}" @@ -83,8 +99,10 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: if signature == "memory_obfuscation" and comm in cfg.memory_obfuscation_allow_comms: continue seen.add(signature) - sid = (SID_PROCESS_HIDING_LIBRARY if signature == "process_hiding_library" - else SID_MEMORY_OBFUSCATION) + sid = { + "process_hiding_library": SID_PROCESS_HIDING_LIBRARY, + "process_injection_library": SID_PROCESS_INJECTION_LIBRARY, + }.get(signature, SID_MEMORY_OBFUSCATION) yield Alert( severity=severity, signature=signature, @@ -95,6 +113,8 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: ), pids=(proc.pid,), sid=sid, - classtype=("process-hiding" if signature == "process_hiding_library" - else "memory-obfuscation"), + classtype={ + "process_hiding_library": "process-hiding", + "process_injection_library": "process-injection", + }.get(signature, "memory-obfuscation"), ) diff --git a/enodia_sentinel/events/host_rules.py b/enodia_sentinel/events/host_rules.py index 7129e2d..4adcbff 100644 --- a/enodia_sentinel/events/host_rules.py +++ b/enodia_sentinel/events/host_rules.py @@ -30,6 +30,8 @@ class HostRule: peer_public: bool | None = None peer_ports: frozenset[int] = frozenset() peer_port_exclude: frozenset[int] = frozenset() + local_ports: frozenset[int] = frozenset() + local_port_exclude: frozenset[int] = frozenset() argv_regex: str | None = None def __post_init__(self) -> None: @@ -37,7 +39,8 @@ class HostRule: raise ValueError(f"rule sid={self.sid} has no event types") if not any(( self.comm, self.parent_comm, self.peer_public is not None, - self.peer_ports, self.peer_port_exclude, self.argv_regex, + self.peer_ports, self.peer_port_exclude, self.local_ports, + self.local_port_exclude, self.argv_regex, )): raise ValueError(f"rule sid={self.sid} has no match conditions") if self.argv_regex is not None: @@ -59,6 +62,10 @@ class HostRule: return False if self.peer_port_exclude and ev.peer_port in self.peer_port_exclude: return False + if self.local_ports and ev.local_port not in self.local_ports: + return False + if self.local_port_exclude and ev.local_port in self.local_port_exclude: + return False if self._argv_re is not None and not self._argv_re.search(ev.argv_str): return False return True @@ -70,7 +77,8 @@ class HostRule: key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}", 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"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port} " + f"local={ev.local_ip}:{ev.local_port}" ), pids=(ev.pid,), sid=self.sid, @@ -89,6 +97,15 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = ( peer_public=True, peer_port_exclude=_COMMON_PUBLIC_PORTS, ), + HostRule( + sid=100068, + msg="Interpreter opened a listener on an unusual local port", + severity=Severity.HIGH, + classtype="suspicious-listener", + events=frozenset({"listen"}), + comm=_INTERPRETERS, + local_port_exclude=_COMMON_PUBLIC_PORTS, + ), ) diff --git a/enodia_sentinel/ruleops.py b/enodia_sentinel/ruleops.py index 98e9fd4..95afbb1 100644 --- a/enodia_sentinel/ruleops.py +++ b/enodia_sentinel/ruleops.py @@ -164,6 +164,10 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]: conditions["peer_ports"] = sorted(rule.peer_ports) if rule.peer_port_exclude: conditions["peer_port_exclude"] = sorted(rule.peer_port_exclude) + if rule.local_ports: + conditions["local_ports"] = sorted(rule.local_ports) + if rule.local_port_exclude: + conditions["local_port_exclude"] = sorted(rule.local_port_exclude) if rule.argv_regex: conditions["argv_regex"] = rule.argv_regex return { @@ -263,6 +267,13 @@ _RULE_DOCS: dict[int, dict[str, Any]] = { ], "drill": "Use `rules test` with a `tcp_connect` event whose `comm` is an interpreter and whose public `peer_port` is not a common service port.", }, + 100068: { + "false_positives": [ + "Developer HTTP servers, netcat listeners, or local test harnesses intentionally opened from an interpreter.", + "Administrative troubleshooting that temporarily listens on a high port.", + ], + "drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.", + }, } @@ -344,8 +355,10 @@ def _host_event(event: dict[str, Any]) -> HostEvent: argv=tuple(str(a) for a in event.get("argv", ())), peer_ip=str(event.get("peer_ip", event.get("remote_ip", ""))), peer_port=_to_int(event.get("peer_port", event.get("remote_port", 0))), - local_ip=str(event.get("local_ip", "")), - local_port=_to_int(event.get("local_port", 0)), + local_ip=str(event.get("local_ip", event.get("bind_ip", + event.get("listen_ip", "")))), + local_port=_to_int(event.get("local_port", event.get("bind_port", + event.get("listen_port", 0)))), ) diff --git a/enodia_sentinel/sids.py b/enodia_sentinel/sids.py index f79b228..3aec141 100644 --- a/enodia_sentinel/sids.py +++ b/enodia_sentinel/sids.py @@ -63,6 +63,9 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = ( SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY, "process_hiding_library", "detector", f"{_DETECTORS}.memory_obfuscation"), + SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY, + "process_injection_library", "detector", + f"{_DETECTORS}.memory_obfuscation"), SidInfo(fim.SID_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"), SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"), SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"), diff --git a/enodia_sentinel/triage.py b/enodia_sentinel/triage.py index fa74649..9990f38 100644 --- a/enodia_sentinel/triage.py +++ b/enodia_sentinel/triage.py @@ -84,6 +84,9 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config, return Verdict(LIKELY_FP, f"preload library is package-owned: {lib}") return Verdict(REVIEW, "library injection — verify the .so by hand") + if sig == "process_injection_library": + return Verdict(REVIEW, "executable library mapped from writable path") + if sig == "deleted_exe": if "memfd:" in detail: return Verdict(REVIEW, "memfd-backed (fileless) execution") diff --git a/enodia_sentinel/tui.py b/enodia_sentinel/tui.py index 460d724..64f71ba 100644 --- a/enodia_sentinel/tui.py +++ b/enodia_sentinel/tui.py @@ -24,6 +24,7 @@ VIEWS = ( "response", "help", ) +WORKFLOW_VIEWS = ("status", "incidents", "timeline", "response", "integrity") TUI_COMMANDS = { "status": "show daemon health and heartbeat", "alerts": "show recent captured alert snapshots", @@ -35,11 +36,57 @@ TUI_COMMANDS = { "events": "tail local events.log", "response": "preview read-only response plans for incidents", "help": "show keys and commands", + "home": "return to the Status starting view", + "guide": "show beginner workflow help", + "next": "move to the next beginner workflow view", + "back": "move to the previous beginner workflow view", "refresh": "reload data from disk", "filter": "filter current view: filter ", "clear": "clear filter and message", "quit": "exit the TUI", } +VIEW_INFO = { + "status": ( + "Status", + "Start here. Shows whether the Sentinel sensor is running and fresh.", + ), + "alerts": ( + "Alerts", + "Individual findings from detectors. Use Incidents when several alerts may be related.", + ), + "incidents": ( + "Incidents", + "Grouped alerts that tell one story. Start investigations here.", + ), + "timeline": ( + "Timeline", + "Time-ordered incident activity. Use this to understand what happened first.", + ), + "posture": ( + "Posture", + "Hardening advice. These are risky settings, not proof of compromise by themselves.", + ), + "integrity": ( + "Integrity", + "Trust checks for heartbeat, FIM, package DB, signatures, and accepted drift.", + ), + "rules": ( + "Rules", + "Detection rule catalog. Mostly useful for tuning or understanding why an alert fired.", + ), + "events": ( + "Events", + "Technical event log. Useful for troubleshooting sensor startup and probes.", + ), + "response": ( + "Response Plans", + "Read-only containment and recovery suggestions. The TUI never executes commands.", + ), + "help": ( + "Help", + "Keys, commands, and a safe first investigation workflow.", + ), +} @dataclass @@ -126,7 +173,23 @@ def run_tui_command(state: TuiState, command: str) -> TuiState: if name in VIEWS: state.view = name state.scroll = 0 - state.message = f"view: {name}" + state.message = f"view: {_view_label(name)}" + elif name == "home": + state.view = "status" + state.scroll = 0 + state.message = "view: Status" + elif name == "guide": + state.view = "help" + state.scroll = 0 + state.message = "beginner guide" + elif name == "next": + state.view = _workflow_neighbor(state.view, 1) + state.scroll = 0 + state.message = f"next: {_view_label(state.view)}" + elif name == "back": + state.view = _workflow_neighbor(state.view, -1) + state.scroll = 0 + state.message = f"back: {_view_label(state.view)}" elif name == "refresh": state.message = "refreshed" elif name == "filter": @@ -169,6 +232,8 @@ def render_lines(state: TuiState, width: int = 100) -> list[str]: lines = _render_response(model.get("response") or [], width) else: lines = _render_help(width) + if view != "help": + lines = _with_intro(view, lines, width) if state.filter_text: needle = state.filter_text.lower() lines = [line for line in lines if needle in line.lower()] @@ -233,6 +298,12 @@ def _loop(stdscr, cfg: Config) -> None: state.view, state.scroll = "response", 0 elif key in (ord("h"), ord("?")): state.view, state.scroll = "help", 0 + elif key in (ord("n"), ord("N")): + state.view, state.scroll = _workflow_neighbor(state.view, 1), 0 + state.message = f"next: {_view_label(state.view)}" + elif key in (ord("b"), ord("B")): + state.view, state.scroll = _workflow_neighbor(state.view, -1), 0 + state.message = f"back: {_view_label(state.view)}" elif key == ord(":"): state.command_mode = True state.command = "" @@ -277,7 +348,10 @@ def _draw(stdscr, state: TuiState) -> None: width = max(width, 20) stdscr.erase() stdscr.addnstr(0, 0, _header_text(state, width), width - 1, curses.A_REVERSE) - meta = f"view={state.view} filter={state.filter_text or '-'} {state.message}" + meta = ( + f"view={_view_label(state.view)} " + f"filter={state.filter_text or '-'} {state.message}" + ) stdscr.addnstr(1, 0, meta[:width - 1], width - 1) lines = render_lines(state, width) body_h = max(0, height - 4) @@ -285,7 +359,11 @@ def _draw(stdscr, state: TuiState) -> None: state.scroll = min(state.scroll, max_scroll) for row, line in enumerate(lines[state.scroll:state.scroll + body_h], start=2): stdscr.addnstr(row, 0, line[:width - 1], width - 1) - prompt = ":" + state.command if state.command_mode else "Press ':' for commands; Tab completes command names." + prompt = ( + ":" + state.command + if state.command_mode + else "Read-only. h help, n next, b back, 1-9 views, / filter, q quit." + ) stdscr.addnstr(height - 1, 0, prompt[:width - 1], width - 1, curses.A_REVERSE) if state.command_mode: stdscr.move(height - 1, min(width - 1, len(prompt))) @@ -306,13 +384,45 @@ def _header_text(state: TuiState, width: int) -> str: elif width >= len(title) + len(compact_tabs) + 2: text = title + compact_tabs else: - text = f"{title}view={state.view} 1-9 switch h help r refresh q quit" + text = ( + f"{title}{_view_label(state.view)} " + "1-9 switch h help r refresh q quit" + ) return text[:max(0, width - 1)] +def _view_label(view: str) -> str: + return VIEW_INFO.get(view, (view.title(), ""))[0] + + +def _workflow_neighbor(view: str, direction: int) -> str: + try: + index = WORKFLOW_VIEWS.index(view) + except ValueError: + index = 0 + index = max(0, min(len(WORKFLOW_VIEWS) - 1, index + direction)) + return WORKFLOW_VIEWS[index] + + +def _with_intro(view: str, lines: list[str], width: int) -> list[str]: + label, hint = VIEW_INFO.get(view, (view.title(), "")) + intro = [f"{label} - {_clip(hint, max(20, width - len(label) - 3))}"] + if view in {"alerts", "incidents", "timeline", "response"}: + intro.append("Suggested flow: 3 Incidents -> 4 Timeline -> 9 Response Plans.") + elif view == "status": + intro.append("If the daemon is stopped or heartbeat is stale, start/restart the service before trusting old data.") + elif view == "integrity": + intro.append("Overall 'review' means check details; 'critical' means the watchdog or heartbeat needs attention.") + intro.append("") + return intro + lines + + def _render_status(status: dict, width: int) -> list[str]: if not status: - return ["No status available."] + return [ + "No status available.", + "Next step: start the daemon or check that this TUI uses the same config/log_dir.", + ] age = status.get("heartbeat_age") heartbeat = "none" if age is None else f"{int(age)}s ago" if status.get("heartbeat_stale"): @@ -330,15 +440,25 @@ def _render_status(status: dict, width: int) -> list[str]: f"Exec probe: {status.get('ebpf_exec', 'unknown')}", f"Syscall: {status.get('ebpf_syscall', 'unknown')}", "", - "Use 2-9 to inspect alerts, incidents, timelines, posture, integrity, rules, events, and response plans.", - "Use ':' for commands.", + "Plain language:", + " Daemon running + fresh heartbeat = the local sensor is alive.", + " Alerts are raw findings; incidents group related findings together.", + " eBPF unknown/disabled means Sentinel falls back to polling detectors.", + "", + "Use 3 for incidents, 4 for timelines, 9 for response plans, or h for help.", ] def _render_alerts(alerts: list[dict], width: int) -> list[str]: if not alerts: - return ["No alert snapshots found."] - lines = [f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL"] + return [ + "No alert snapshots found.", + "This is good if the daemon is running and the heartbeat is fresh.", + ] + lines = [ + "Severity guide: CRITICAL = urgent, HIGH = review soon, MEDIUM = investigate when practical.", + f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL", + ] for snap in alerts: for alert in snap.get("alerts", []): detail = alert.get("detail", "") @@ -353,8 +473,14 @@ def _render_alerts(alerts: list[dict], width: int) -> list[str]: def _render_incidents(incidents: list[dict], width: int) -> list[str]: if not incidents: - return ["No incidents recorded."] - lines = [f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES"] + return [ + "No incidents recorded.", + "Incidents appear after alerts are grouped by process lineage or time window.", + ] + lines = [ + "Pick an incident id, then use 4 Timeline to see the order of events.", + f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES", + ] for inc in incidents: sigs = ", ".join(inc.get("signatures", [])) lines.append( @@ -366,7 +492,10 @@ def _render_incidents(incidents: list[dict], width: int) -> list[str]: def _render_timelines(views: list[dict], width: int) -> list[str]: if not views: - return ["No incident timelines."] + return [ + "No incident timelines.", + "Timelines need at least one recorded incident snapshot.", + ] lines = [] for view in views: if view.get("error"): @@ -411,8 +540,9 @@ def _render_posture(report: dict, width: int) -> list[str]: "", ] if not findings: - lines.append("No posture findings.") + lines.append("No posture findings. SSH, sudo, PATH, file perms, and package signature policy look sound.") return lines + lines.append("These are hardening tasks. Fixing them reduces risk even if no attack is active.") lines.append(f"{'SEV':<9} {'SID':<7} {'SIGNATURE':<22} DETAIL") for finding in findings: detail = finding.get("detail", "") @@ -480,7 +610,7 @@ def _render_rules(catalog, width: int) -> list[str]: return [f"Rules unavailable: {catalog['error']}"] rules = catalog.get("rules", []) if isinstance(catalog, dict) else catalog if not rules: - return ["No rules loaded."] + return ["No rules loaded.", "This usually means no built-in rule metadata was available."] lines = [] if isinstance(catalog, dict): lines.append( @@ -502,15 +632,26 @@ def _render_rules(catalog, width: int) -> list[str]: def _render_events(events: list[str], width: int) -> list[str]: if not events: - return ["No events.log entries found."] - return [_clip(line, width) for line in events] + return [ + "No events.log entries found.", + "This log is mostly for troubleshooting daemon startup and probe status.", + ] + return [ + "Newest daemon/probe messages are shown below. These are technical logs.", + "", + *[_clip(line, width) for line in events], + ] def _render_response(plans: list[dict], width: int) -> list[str]: if not plans: - return ["No incidents with response plans."] + return [ + "No incidents with response plans.", + "Response plans appear after an incident exists. They are suggestions only.", + ] lines = [ "Read-only response previews. Commands are not executed from the TUI.", + "Review evidence before stopping processes, blocking IPs, or moving files.", "", ] for plan in plans: @@ -540,8 +681,21 @@ def _render_response(plans: list[dict], width: int) -> list[str]: def _render_help(width: int) -> list[str]: lines = [ + "New User Workflow:", + " 1 Status confirm the sensor is running and heartbeat is fresh", + " 3 Incidents find grouped activity instead of chasing raw alerts", + " 4 Timeline read what happened in time order", + " 9 Response review suggested commands; the TUI does not execute them", + " 6 Integrity check watchdog, file integrity, and package trust", + " Press n/b to walk this workflow forward/back.", + "", + "Safety:", + " This TUI is read-only. It displays commands and evidence, but does not change the host.", + " If you are unsure, preserve evidence first and avoid deleting or moving files.", + "", "Keys:", " 1-9 switch views: status, alerts, incidents, timeline, posture, integrity, rules, events, response", + " n / b next/back in the beginner workflow", " j/k or arrows scroll", " PgUp/PgDn page scroll", " r refresh", diff --git a/tests/fixtures/sids/100068-interpreter-unusual-listener.json b/tests/fixtures/sids/100068-interpreter-unusual-listener.json new file mode 100644 index 0000000..e7ec653 --- /dev/null +++ b/tests/fixtures/sids/100068-interpreter-unusual-listener.json @@ -0,0 +1,12 @@ +{ + "event": "listen", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "parent_comm": "bash", + "path": "/usr/bin/python3", + "argv": ["-m", "http.server", "4444"], + "local_ip": "0.0.0.0", + "local_port": 4444 +} diff --git a/tests/sid_drills.py b/tests/sid_drills.py index 3f9aba7..1fe2ec8 100644 --- a/tests/sid_drills.py +++ b/tests/sid_drills.py @@ -303,6 +303,16 @@ DRILLS: dict[int, Callable[[], list[Alert]]] = { ]), _cfg(), )), + 100049: lambda: list(memory_obfuscation.detect( + SystemState(processes=[ + FakeProc(pid=355, comm="sshd", + memory_maps=[ + MemoryMap(0xb000, 0xc000, "r-xp", + "/tmp/libinject.so"), + ]), + ]), + _cfg(), + )), 100050: lambda: list(posture.systemd_findings([ posture.UnitFact("foo.service", "/etc/systemd/system/foo.service", 0o100664, True, ("/usr/bin/foo",)), diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 9abce66..4bc476f 100755 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -197,6 +197,30 @@ class TestMemoryObfuscation(unittest.TestCase): self.assertEqual(alerts[0].signature, "process_hiding_library") self.assertEqual(alerts[0].severity, Severity.CRITICAL) + def test_process_injection_library_alerts(self): + proc = FakeProc( + pid=355, comm="sshd", + memory_maps=[MemoryMap(0xb000, 0xc000, "r-xp", "/tmp/libinject.so")], + ) + alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "process_injection_library") + self.assertEqual(alerts[0].sid, + memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY) + self.assertEqual(alerts[0].classtype, "process-injection") + self.assertEqual(alerts[0].severity, Severity.HIGH) + + def test_process_injection_library_allow_path(self): + c = cfg() + c.memory_obfuscation_allow_paths = ("/tmp/known-profiler/",) + proc = FakeProc( + pid=356, comm="python3", + memory_maps=[ + MemoryMap(0xd000, 0xe000, "r-xp", "/tmp/known-profiler/libhook.so"), + ], + ) + self.assertEqual(list(memory_obfuscation.detect(SystemState(processes=[proc]), c)), []) + def test_jit_allowlisted_comm_ignored(self): proc = FakeProc( pid=353, comm="node", diff --git a/tests/test_rule_compat.py b/tests/test_rule_compat.py index ad6f38a..12bfe0f 100644 --- a/tests/test_rule_compat.py +++ b/tests/test_rule_compat.py @@ -83,6 +83,18 @@ class TestRuleEventCompatibility(unittest.TestCase): } self.assertIn(100067, self._sids(event)) + def test_host_event_accepts_listen_shape(self): + event = { + "type": "listen", + "pid": "4242", + "ppid": "1", + "uid": "1000", + "comm": "python3", + "bind_ip": "0.0.0.0", + "bind_port": "4444", + } + self.assertIn(100068, self._sids(event)) + 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 b1648fe..00503c1 100644 --- a/tests/test_ruleops.py +++ b/tests/test_ruleops.py @@ -19,12 +19,16 @@ class TestRuleOps(unittest.TestCase): 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: @@ -97,6 +101,33 @@ exec_comm = ["id"] } 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) @@ -168,6 +199,7 @@ class TestRulesCli(unittest.TestCase): 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) diff --git a/tests/test_sids.py b/tests/test_sids.py index ce7ed1a..78d8f58 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), 53) + self.assertEqual(len(sids.BUILTIN_SIDS), 55) self.assertEqual( sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS) ) diff --git a/tests/test_triage.py b/tests/test_triage.py index 8c77a50..703c7af 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -112,6 +112,14 @@ class TestOtherSignatures(unittest.TestCase): self.assertIn("memory_obfuscation_allow_comms", v.suggest) self.assertIn('"jit-runtime"', v.suggest) + def test_process_injection_library_is_review(self): + a = alert("process_injection_library", + "pid=45 comm=sshd executable library mapped from writable/runtime path: /tmp/libinject.so", + sid=100049) + v = triage_alert(a, [], Config(), is_owned=NONE) + self.assertEqual(v.label, REVIEW) + self.assertIn("writable path", v.reason) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py index 512fa69..caedff7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -52,6 +52,19 @@ class TestTuiCore(unittest.TestCase): run_tui_command(state, "quit") self.assertTrue(state.should_quit) + def test_beginner_workflow_commands(self): + state = TuiState() + run_tui_command(state, "next") + self.assertEqual(state.view, "incidents") + run_tui_command(state, "next") + self.assertEqual(state.view, "timeline") + run_tui_command(state, "back") + self.assertEqual(state.view, "incidents") + run_tui_command(state, "home") + self.assertEqual(state.view, "status") + run_tui_command(state, "guide") + self.assertEqual(state.view, "help") + def test_collect_model_contains_dashboard_parity_sections(self): model = collect_model(self.cfg) for key in ( @@ -64,6 +77,8 @@ class TestTuiCore(unittest.TestCase): lines = render_lines(state, width=80) self.assertTrue(any("Daemon:" in line for line in lines)) self.assertTrue(any("Heartbeat:" in line for line in lines)) + self.assertTrue(any("Plain language:" in line for line in lines)) + self.assertTrue(any("fresh heartbeat" in line for line in lines)) def test_render_alerts_and_filter(self): (self.tmp / "alert-1.json").write_text(json.dumps({ @@ -161,10 +176,13 @@ class TestTuiCore(unittest.TestCase): self.assertTrue(any("Overall:" in line for line in integrity)) self.assertTrue(any("ttl expired" in line for line in integrity)) events = render_lines(TuiState(view="events", model=model), width=100) - self.assertEqual(events, ["2026-07-09T01:02:03 event happened"]) + self.assertTrue(any("technical logs" in line for line in events)) + self.assertTrue(any("2026-07-09T01:02:03 event happened" in line + for line in events)) response = render_lines(TuiState(view="response", model=model), width=100) self.assertTrue(any("inc-1" in line for line in response)) self.assertTrue(any("tar -czf /tmp/evidence.tgz" in line for line in response)) + self.assertTrue(any("not executed" in line for line in response)) def test_cli_completion_outputs_scripts(self): for shell in ("bash", "zsh"): @@ -182,6 +200,9 @@ class TestTuiCore(unittest.TestCase): help_lines = render_lines(TuiState(view="help"), width=100) for command in TUI_COMMANDS: self.assertTrue(any(command in line for line in help_lines), command) + self.assertTrue(any("New User Workflow" in line for line in help_lines)) + self.assertTrue(any("read-only" in line for line in help_lines)) + self.assertTrue(any("n/b" in line for line in help_lines)) def test_header_scales_to_terminal_width(self): state = TuiState(view="integrity") @@ -189,7 +210,7 @@ class TestTuiCore(unittest.TestCase): with self.subTest(width=width): header = _header_text(state, width) self.assertLessEqual(len(header), width - 1) - self.assertIn("view=integrity", _header_text(state, 60)) + self.assertIn("Integrity", _header_text(state, 60)) self.assertIn("6 Int", _header_text(state, 100)) self.assertIn("6 Integrity", _header_text(state, 180))