Expand TUI guidance and host detection coverage
This commit is contained in:
parent
3b037646d2
commit
8194d13734
20 changed files with 414 additions and 42 deletions
12
README.md
12
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 |
|
| `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 |
|
| `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 |
|
| `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_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 |
|
| `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 |
|
| `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:
|
The TUI mirrors the read-only management console for terminal operators:
|
||||||
status, recent alerts, incidents, posture findings, integrity/watchdog state,
|
status, recent alerts, incidents, posture findings, integrity/watchdog state,
|
||||||
incident timelines, event-rule metadata, event tail, and dry-run response-plan
|
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,
|
Keys: `1` status, `2` alerts, `3` incidents, `4` timelines, `5` posture,
|
||||||
`6` integrity, `7` rules, `8` events, `9` response plans, `r` refresh, `/` filter, `:`
|
`6` integrity, `7` rules, `8` events, `9` response plans, `n`/`b` next/back in
|
||||||
command mode, `Tab` complete command names, `q` quit.
|
the beginner workflow, `r` refresh, `/` filter, `:` command mode, `Tab` complete
|
||||||
|
command names, `q` quit.
|
||||||
|
|
||||||
Shell completion is generated without extra dependencies:
|
Shell completion is generated without extra dependencies:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -403,10 +403,16 @@ daemon status, recent alert snapshots, incident index and timelines, posture
|
||||||
findings, integrity/watchdog state, event-rule metadata, event tail, and
|
findings, integrity/watchdog state, event-rule metadata, event tail, and
|
||||||
read-only response-plan previews.
|
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:
|
Keys:
|
||||||
|
|
||||||
- `1` / `2` / `3` / `4`: status, alerts, incidents, timelines.
|
- `1` / `2` / `3` / `4`: status, alerts, incidents, timelines.
|
||||||
- `5` / `6` / `7` / `8` / `9`: posture, integrity, rules, events, response plans.
|
- `5` / `6` / `7` / `8` / `9`: posture, integrity, rules, events, response plans.
|
||||||
|
- `n` / `b`: next/back through the beginner workflow.
|
||||||
- `j` / `k` or arrows: scroll.
|
- `j` / `k` or arrows: scroll.
|
||||||
- `r`: refresh.
|
- `r`: refresh.
|
||||||
- `/`: filter the current view.
|
- `/`: filter the current view.
|
||||||
|
|
@ -416,7 +422,8 @@ Keys:
|
||||||
- `q`: quit.
|
- `q`: quit.
|
||||||
|
|
||||||
Command mode supports `status`, `alerts`, `incidents`, `timeline`, `posture`,
|
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 <text>`, `clear`, and `quit`.
|
`filter <text>`, `clear`, and `quit`.
|
||||||
|
|
||||||
### `web`
|
### `web`
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,10 @@ for quick remote review.
|
||||||
For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over
|
For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over
|
||||||
the same local read-only management state: status, recent alerts, incidents,
|
the same local read-only management state: status, recent alerts, incidents,
|
||||||
incident timelines, posture findings, integrity/watchdog state, event-rule
|
incident timelines, posture findings, integrity/watchdog state, event-rule
|
||||||
metadata, event tail, and dry-run response-plan previews. Use `:` for command
|
metadata, event tail, and dry-run response-plan previews. Newer operators can
|
||||||
mode and `Tab` to complete TUI commands.
|
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]`
|
On desktop hosts the optional tray applet (`enodia-sentinel-tray`, `[tray]`
|
||||||
extra) wraps the same workflow: it drives the same `systemctl` units operators
|
extra) wraps the same workflow: it drives the same `systemctl` units operators
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ work that is bigger than single alerts.
|
||||||
raw ICMP/special-protocol rootcheck detection.
|
raw ICMP/special-protocol rootcheck detection.
|
||||||
- ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps`
|
- ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps`
|
||||||
cross-view checks plus memory-map scans for hide libraries, RWX memory, and
|
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,
|
- ✅ Add event-driven memory telemetry for short-lived `mprotect`/`mmap` RWX,
|
||||||
`memfd_create`, `ptrace`, seccomp, cross-process memory access, and memory
|
`memfd_create`, `ptrace`, seccomp, cross-process memory access, and memory
|
||||||
locking syscalls.
|
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:
|
- ✅ Add the first typed host-event rule family beside exec/syscall rules:
|
||||||
`rules test` accepts `tcp_connect` event JSON and ships SID `100067` for
|
`rules test` accepts `tcp_connect` event JSON and ships SID `100067` for
|
||||||
interpreter egress to unusual public ports, with a replayable fixture.
|
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:
|
- 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.
|
`capset`, and module-load events.
|
||||||
- Keep rules declarative and Snort-like:
|
- Keep rules declarative and Snort-like:
|
||||||
`sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as
|
`sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as
|
||||||
|
|
|
||||||
|
|
@ -223,3 +223,22 @@ Expected false positives:
|
||||||
- Developer tooling using interpreters for custom APIs on high ports.
|
- 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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/<pid>/maps`. They do not read process memory; they
|
These alerts come from `/proc/<pid>/maps`. They do not read process memory; they
|
||||||
flag map shapes associated with encrypted/packed payloads or userland hiding:
|
flag map shapes associated with encrypted/packed payloads or userland hiding:
|
||||||
RWX pages, executable anonymous memory, executable `memfd`/deleted mappings, or
|
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**
|
**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
|
- 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
|
and whether it is loaded into tools like `ps`, `pgrep`, `ss`, shells, or admin
|
||||||
utilities.
|
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
|
- For `memory_obfuscation`, distinguish expected JIT runtimes from unknown
|
||||||
implants. Tune known runtimes with `memory_obfuscation_allow_comms`; use
|
implants. Tune known runtimes with `memory_obfuscation_allow_comms`; use
|
||||||
`memory_obfuscation_allow_paths` only for well-understood local runtimes or
|
`memory_obfuscation_allow_paths` only for well-understood local runtimes or
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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.
|
Encrypted/packed implants still need executable memory after decrypting code.
|
||||||
This detector does not read process memory; it only inspects ``/proc/<pid>/maps``
|
This detector does not read process memory; it only inspects ``/proc/<pid>/maps``
|
||||||
for high-signal shapes: executable anonymous mappings, executable memfd/deleted
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -16,10 +17,13 @@ from ..system import MemoryMap, SystemState
|
||||||
|
|
||||||
SID_MEMORY_OBFUSCATION = 100039
|
SID_MEMORY_OBFUSCATION = 100039
|
||||||
SID_PROCESS_HIDING_LIBRARY = 100048
|
SID_PROCESS_HIDING_LIBRARY = 100048
|
||||||
|
SID_PROCESS_INJECTION_LIBRARY = 100049
|
||||||
|
|
||||||
_HIDE_LIBRARY_HINTS = frozenset({
|
_HIDE_LIBRARY_HINTS = frozenset({
|
||||||
"libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide",
|
"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]:
|
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)
|
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:
|
def _allowed_path(path: str, cfg: Config) -> bool:
|
||||||
for prefix in cfg.memory_obfuscation_allow_paths:
|
for prefix in cfg.memory_obfuscation_allow_paths:
|
||||||
if prefix and path.startswith(prefix):
|
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
|
executable = "x" in mm.perms
|
||||||
writable = "w" 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):
|
if executable and ("memfd:" in path or "(deleted)" in path):
|
||||||
return "memory_obfuscation", Severity.CRITICAL, (
|
return "memory_obfuscation", Severity.CRITICAL, (
|
||||||
f"executable transient mapping perms={mm.perms} path={path or '<anonymous>'}"
|
f"executable transient mapping perms={mm.perms} path={path or '<anonymous>'}"
|
||||||
|
|
@ -83,8 +99,10 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||||
if signature == "memory_obfuscation" and comm in cfg.memory_obfuscation_allow_comms:
|
if signature == "memory_obfuscation" and comm in cfg.memory_obfuscation_allow_comms:
|
||||||
continue
|
continue
|
||||||
seen.add(signature)
|
seen.add(signature)
|
||||||
sid = (SID_PROCESS_HIDING_LIBRARY if signature == "process_hiding_library"
|
sid = {
|
||||||
else SID_MEMORY_OBFUSCATION)
|
"process_hiding_library": SID_PROCESS_HIDING_LIBRARY,
|
||||||
|
"process_injection_library": SID_PROCESS_INJECTION_LIBRARY,
|
||||||
|
}.get(signature, SID_MEMORY_OBFUSCATION)
|
||||||
yield Alert(
|
yield Alert(
|
||||||
severity=severity,
|
severity=severity,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
|
|
@ -95,6 +113,8 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||||
),
|
),
|
||||||
pids=(proc.pid,),
|
pids=(proc.pid,),
|
||||||
sid=sid,
|
sid=sid,
|
||||||
classtype=("process-hiding" if signature == "process_hiding_library"
|
classtype={
|
||||||
else "memory-obfuscation"),
|
"process_hiding_library": "process-hiding",
|
||||||
|
"process_injection_library": "process-injection",
|
||||||
|
}.get(signature, "memory-obfuscation"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class HostRule:
|
||||||
peer_public: bool | None = None
|
peer_public: bool | None = None
|
||||||
peer_ports: frozenset[int] = frozenset()
|
peer_ports: frozenset[int] = frozenset()
|
||||||
peer_port_exclude: 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
|
argv_regex: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -37,7 +39,8 @@ class HostRule:
|
||||||
raise ValueError(f"rule sid={self.sid} has no event types")
|
raise ValueError(f"rule sid={self.sid} has no event types")
|
||||||
if not any((
|
if not any((
|
||||||
self.comm, self.parent_comm, self.peer_public is not None,
|
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")
|
raise ValueError(f"rule sid={self.sid} has no match conditions")
|
||||||
if self.argv_regex is not None:
|
if self.argv_regex is not None:
|
||||||
|
|
@ -59,6 +62,10 @@ class HostRule:
|
||||||
return False
|
return False
|
||||||
if self.peer_port_exclude and ev.peer_port in self.peer_port_exclude:
|
if self.peer_port_exclude and ev.peer_port in self.peer_port_exclude:
|
||||||
return False
|
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):
|
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
@ -70,7 +77,8 @@ class HostRule:
|
||||||
key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}",
|
key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}",
|
||||||
detail=(
|
detail=(
|
||||||
f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} "
|
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,),
|
pids=(ev.pid,),
|
||||||
sid=self.sid,
|
sid=self.sid,
|
||||||
|
|
@ -89,6 +97,15 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
|
||||||
peer_public=True,
|
peer_public=True,
|
||||||
peer_port_exclude=_COMMON_PUBLIC_PORTS,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,10 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]:
|
||||||
conditions["peer_ports"] = sorted(rule.peer_ports)
|
conditions["peer_ports"] = sorted(rule.peer_ports)
|
||||||
if rule.peer_port_exclude:
|
if rule.peer_port_exclude:
|
||||||
conditions["peer_port_exclude"] = sorted(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:
|
if rule.argv_regex:
|
||||||
conditions["argv_regex"] = rule.argv_regex
|
conditions["argv_regex"] = rule.argv_regex
|
||||||
return {
|
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.",
|
"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", ())),
|
argv=tuple(str(a) for a in event.get("argv", ())),
|
||||||
peer_ip=str(event.get("peer_ip", event.get("remote_ip", ""))),
|
peer_ip=str(event.get("peer_ip", event.get("remote_ip", ""))),
|
||||||
peer_port=_to_int(event.get("peer_port", event.get("remote_port", 0))),
|
peer_port=_to_int(event.get("peer_port", event.get("remote_port", 0))),
|
||||||
local_ip=str(event.get("local_ip", "")),
|
local_ip=str(event.get("local_ip", event.get("bind_ip",
|
||||||
local_port=_to_int(event.get("local_port", 0)),
|
event.get("listen_ip", "")))),
|
||||||
|
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
||||||
|
event.get("listen_port", 0)))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
|
||||||
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
|
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
|
||||||
"process_hiding_library", "detector",
|
"process_hiding_library", "detector",
|
||||||
f"{_DETECTORS}.memory_obfuscation"),
|
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_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"),
|
||||||
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
|
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
|
||||||
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
|
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
|
||||||
|
|
|
||||||
|
|
@ -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(LIKELY_FP, f"preload library is package-owned: {lib}")
|
||||||
return Verdict(REVIEW, "library injection — verify the .so by hand")
|
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 sig == "deleted_exe":
|
||||||
if "memfd:" in detail:
|
if "memfd:" in detail:
|
||||||
return Verdict(REVIEW, "memfd-backed (fileless) execution")
|
return Verdict(REVIEW, "memfd-backed (fileless) execution")
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ VIEWS = (
|
||||||
"response",
|
"response",
|
||||||
"help",
|
"help",
|
||||||
)
|
)
|
||||||
|
WORKFLOW_VIEWS = ("status", "incidents", "timeline", "response", "integrity")
|
||||||
TUI_COMMANDS = {
|
TUI_COMMANDS = {
|
||||||
"status": "show daemon health and heartbeat",
|
"status": "show daemon health and heartbeat",
|
||||||
"alerts": "show recent captured alert snapshots",
|
"alerts": "show recent captured alert snapshots",
|
||||||
|
|
@ -35,11 +36,57 @@ TUI_COMMANDS = {
|
||||||
"events": "tail local events.log",
|
"events": "tail local events.log",
|
||||||
"response": "preview read-only response plans for incidents",
|
"response": "preview read-only response plans for incidents",
|
||||||
"help": "show keys and commands",
|
"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",
|
"refresh": "reload data from disk",
|
||||||
"filter": "filter current view: filter <text>",
|
"filter": "filter current view: filter <text>",
|
||||||
"clear": "clear filter and message",
|
"clear": "clear filter and message",
|
||||||
"quit": "exit the TUI",
|
"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
|
@dataclass
|
||||||
|
|
@ -126,7 +173,23 @@ def run_tui_command(state: TuiState, command: str) -> TuiState:
|
||||||
if name in VIEWS:
|
if name in VIEWS:
|
||||||
state.view = name
|
state.view = name
|
||||||
state.scroll = 0
|
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":
|
elif name == "refresh":
|
||||||
state.message = "refreshed"
|
state.message = "refreshed"
|
||||||
elif name == "filter":
|
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)
|
lines = _render_response(model.get("response") or [], width)
|
||||||
else:
|
else:
|
||||||
lines = _render_help(width)
|
lines = _render_help(width)
|
||||||
|
if view != "help":
|
||||||
|
lines = _with_intro(view, lines, width)
|
||||||
if state.filter_text:
|
if state.filter_text:
|
||||||
needle = state.filter_text.lower()
|
needle = state.filter_text.lower()
|
||||||
lines = [line for line in lines if needle in line.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
|
state.view, state.scroll = "response", 0
|
||||||
elif key in (ord("h"), ord("?")):
|
elif key in (ord("h"), ord("?")):
|
||||||
state.view, state.scroll = "help", 0
|
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(":"):
|
elif key == ord(":"):
|
||||||
state.command_mode = True
|
state.command_mode = True
|
||||||
state.command = ""
|
state.command = ""
|
||||||
|
|
@ -277,7 +348,10 @@ def _draw(stdscr, state: TuiState) -> None:
|
||||||
width = max(width, 20)
|
width = max(width, 20)
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
stdscr.addnstr(0, 0, _header_text(state, width), width - 1, curses.A_REVERSE)
|
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)
|
stdscr.addnstr(1, 0, meta[:width - 1], width - 1)
|
||||||
lines = render_lines(state, width)
|
lines = render_lines(state, width)
|
||||||
body_h = max(0, height - 4)
|
body_h = max(0, height - 4)
|
||||||
|
|
@ -285,7 +359,11 @@ def _draw(stdscr, state: TuiState) -> None:
|
||||||
state.scroll = min(state.scroll, max_scroll)
|
state.scroll = min(state.scroll, max_scroll)
|
||||||
for row, line in enumerate(lines[state.scroll:state.scroll + body_h], start=2):
|
for row, line in enumerate(lines[state.scroll:state.scroll + body_h], start=2):
|
||||||
stdscr.addnstr(row, 0, line[:width - 1], width - 1)
|
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)
|
stdscr.addnstr(height - 1, 0, prompt[:width - 1], width - 1, curses.A_REVERSE)
|
||||||
if state.command_mode:
|
if state.command_mode:
|
||||||
stdscr.move(height - 1, min(width - 1, len(prompt)))
|
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:
|
elif width >= len(title) + len(compact_tabs) + 2:
|
||||||
text = title + compact_tabs
|
text = title + compact_tabs
|
||||||
else:
|
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)]
|
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]:
|
def _render_status(status: dict, width: int) -> list[str]:
|
||||||
if not status:
|
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")
|
age = status.get("heartbeat_age")
|
||||||
heartbeat = "none" if age is None else f"{int(age)}s ago"
|
heartbeat = "none" if age is None else f"{int(age)}s ago"
|
||||||
if status.get("heartbeat_stale"):
|
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"Exec probe: {status.get('ebpf_exec', 'unknown')}",
|
||||||
f"Syscall: {status.get('ebpf_syscall', 'unknown')}",
|
f"Syscall: {status.get('ebpf_syscall', 'unknown')}",
|
||||||
"",
|
"",
|
||||||
"Use 2-9 to inspect alerts, incidents, timelines, posture, integrity, rules, events, and response plans.",
|
"Plain language:",
|
||||||
"Use ':' for commands.",
|
" 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]:
|
def _render_alerts(alerts: list[dict], width: int) -> list[str]:
|
||||||
if not alerts:
|
if not alerts:
|
||||||
return ["No alert snapshots found."]
|
return [
|
||||||
lines = [f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL"]
|
"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 snap in alerts:
|
||||||
for alert in snap.get("alerts", []):
|
for alert in snap.get("alerts", []):
|
||||||
detail = alert.get("detail", "")
|
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]:
|
def _render_incidents(incidents: list[dict], width: int) -> list[str]:
|
||||||
if not incidents:
|
if not incidents:
|
||||||
return ["No incidents recorded."]
|
return [
|
||||||
lines = [f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES"]
|
"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:
|
for inc in incidents:
|
||||||
sigs = ", ".join(inc.get("signatures", []))
|
sigs = ", ".join(inc.get("signatures", []))
|
||||||
lines.append(
|
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]:
|
def _render_timelines(views: list[dict], width: int) -> list[str]:
|
||||||
if not views:
|
if not views:
|
||||||
return ["No incident timelines."]
|
return [
|
||||||
|
"No incident timelines.",
|
||||||
|
"Timelines need at least one recorded incident snapshot.",
|
||||||
|
]
|
||||||
lines = []
|
lines = []
|
||||||
for view in views:
|
for view in views:
|
||||||
if view.get("error"):
|
if view.get("error"):
|
||||||
|
|
@ -411,8 +540,9 @@ def _render_posture(report: dict, width: int) -> list[str]:
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
if not findings:
|
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
|
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")
|
lines.append(f"{'SEV':<9} {'SID':<7} {'SIGNATURE':<22} DETAIL")
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
detail = finding.get("detail", "")
|
detail = finding.get("detail", "")
|
||||||
|
|
@ -480,7 +610,7 @@ def _render_rules(catalog, width: int) -> list[str]:
|
||||||
return [f"Rules unavailable: {catalog['error']}"]
|
return [f"Rules unavailable: {catalog['error']}"]
|
||||||
rules = catalog.get("rules", []) if isinstance(catalog, dict) else catalog
|
rules = catalog.get("rules", []) if isinstance(catalog, dict) else catalog
|
||||||
if not rules:
|
if not rules:
|
||||||
return ["No rules loaded."]
|
return ["No rules loaded.", "This usually means no built-in rule metadata was available."]
|
||||||
lines = []
|
lines = []
|
||||||
if isinstance(catalog, dict):
|
if isinstance(catalog, dict):
|
||||||
lines.append(
|
lines.append(
|
||||||
|
|
@ -502,15 +632,26 @@ def _render_rules(catalog, width: int) -> list[str]:
|
||||||
|
|
||||||
def _render_events(events: list[str], width: int) -> list[str]:
|
def _render_events(events: list[str], width: int) -> list[str]:
|
||||||
if not events:
|
if not events:
|
||||||
return ["No events.log entries found."]
|
return [
|
||||||
return [_clip(line, width) for line in events]
|
"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]:
|
def _render_response(plans: list[dict], width: int) -> list[str]:
|
||||||
if not plans:
|
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 = [
|
lines = [
|
||||||
"Read-only response previews. Commands are not executed from the TUI.",
|
"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:
|
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]:
|
def _render_help(width: int) -> list[str]:
|
||||||
lines = [
|
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:",
|
"Keys:",
|
||||||
" 1-9 switch views: status, alerts, incidents, timeline, posture, integrity, rules, events, response",
|
" 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",
|
" j/k or arrows scroll",
|
||||||
" PgUp/PgDn page scroll",
|
" PgUp/PgDn page scroll",
|
||||||
" r refresh",
|
" r refresh",
|
||||||
|
|
|
||||||
12
tests/fixtures/sids/100068-interpreter-unusual-listener.json
vendored
Normal file
12
tests/fixtures/sids/100068-interpreter-unusual-listener.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -303,6 +303,16 @@ DRILLS: dict[int, Callable[[], list[Alert]]] = {
|
||||||
]),
|
]),
|
||||||
_cfg(),
|
_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([
|
100050: lambda: list(posture.systemd_findings([
|
||||||
posture.UnitFact("foo.service", "/etc/systemd/system/foo.service",
|
posture.UnitFact("foo.service", "/etc/systemd/system/foo.service",
|
||||||
0o100664, True, ("/usr/bin/foo",)),
|
0o100664, True, ("/usr/bin/foo",)),
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,30 @@ class TestMemoryObfuscation(unittest.TestCase):
|
||||||
self.assertEqual(alerts[0].signature, "process_hiding_library")
|
self.assertEqual(alerts[0].signature, "process_hiding_library")
|
||||||
self.assertEqual(alerts[0].severity, Severity.CRITICAL)
|
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):
|
def test_jit_allowlisted_comm_ignored(self):
|
||||||
proc = FakeProc(
|
proc = FakeProc(
|
||||||
pid=353, comm="node",
|
pid=353, comm="node",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,18 @@ class TestRuleEventCompatibility(unittest.TestCase):
|
||||||
}
|
}
|
||||||
self.assertIn(100067, self._sids(event))
|
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):
|
def test_unknown_event_shape_still_errors(self):
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
ValueError, "event JSON must be an exec, syscall, or host event"
|
ValueError, "event JSON must be an exec, syscall, or host event"
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,16 @@ class TestRuleOps(unittest.TestCase):
|
||||||
self.assertIn(100001, sids)
|
self.assertIn(100001, sids)
|
||||||
self.assertIn(100060, sids)
|
self.assertIn(100060, sids)
|
||||||
self.assertIn(100067, sids)
|
self.assertIn(100067, sids)
|
||||||
|
self.assertIn(100068, sids)
|
||||||
exec_rule = next(r for r in rules if r["sid"] == 100002)
|
exec_rule = next(r for r in rules if r["sid"] == 100002)
|
||||||
self.assertEqual(exec_rule["event"], "exec")
|
self.assertEqual(exec_rule["event"], "exec")
|
||||||
self.assertIn("argv_regex", exec_rule["conditions"])
|
self.assertIn("argv_regex", exec_rule["conditions"])
|
||||||
host_rule = next(r for r in rules if r["sid"] == 100067)
|
host_rule = next(r for r in rules if r["sid"] == 100067)
|
||||||
self.assertEqual(host_rule["event"], "tcp_connect")
|
self.assertEqual(host_rule["event"], "tcp_connect")
|
||||||
self.assertIn("peer_port_exclude", host_rule["conditions"])
|
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):
|
def test_configured_exec_rule_is_listed(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|
@ -97,6 +101,33 @@ exec_comm = ["id"]
|
||||||
}
|
}
|
||||||
self.assertEqual(ruleops.test_event(cfg, event), [])
|
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):
|
def test_render_markdown_documents_rules(self):
|
||||||
text = ruleops.render_markdown(Config())
|
text = ruleops.render_markdown(Config())
|
||||||
self.assertIn("# Enodia Sentinel Event Rule Reference", text)
|
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("Enodia Sentinel Event Rule Reference", out)
|
||||||
self.assertIn("SID 100060", out)
|
self.assertIn("SID 100060", out)
|
||||||
self.assertIn("SID 100067", out)
|
self.assertIn("SID 100067", out)
|
||||||
|
self.assertIn("SID 100068", out)
|
||||||
self.assertIn("Expected false positives", out)
|
self.assertIn("Expected false positives", out)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class TestRegistry(unittest.TestCase):
|
||||||
self.assertEqual(len(nums), len(set(nums)))
|
self.assertEqual(len(nums), len(set(nums)))
|
||||||
|
|
||||||
def test_count_and_helpers(self):
|
def test_count_and_helpers(self):
|
||||||
self.assertEqual(len(sids.BUILTIN_SIDS), 53)
|
self.assertEqual(len(sids.BUILTIN_SIDS), 55)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,14 @@ class TestOtherSignatures(unittest.TestCase):
|
||||||
self.assertIn("memory_obfuscation_allow_comms", v.suggest)
|
self.assertIn("memory_obfuscation_allow_comms", v.suggest)
|
||||||
self.assertIn('"jit-runtime"', 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ class TestTuiCore(unittest.TestCase):
|
||||||
run_tui_command(state, "quit")
|
run_tui_command(state, "quit")
|
||||||
self.assertTrue(state.should_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):
|
def test_collect_model_contains_dashboard_parity_sections(self):
|
||||||
model = collect_model(self.cfg)
|
model = collect_model(self.cfg)
|
||||||
for key in (
|
for key in (
|
||||||
|
|
@ -64,6 +77,8 @@ class TestTuiCore(unittest.TestCase):
|
||||||
lines = render_lines(state, width=80)
|
lines = render_lines(state, width=80)
|
||||||
self.assertTrue(any("Daemon:" in line for line in lines))
|
self.assertTrue(any("Daemon:" in line for line in lines))
|
||||||
self.assertTrue(any("Heartbeat:" 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):
|
def test_render_alerts_and_filter(self):
|
||||||
(self.tmp / "alert-1.json").write_text(json.dumps({
|
(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("Overall:" in line for line in integrity))
|
||||||
self.assertTrue(any("ttl expired" 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)
|
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)
|
response = render_lines(TuiState(view="response", model=model), width=100)
|
||||||
self.assertTrue(any("inc-1" in line for line in response))
|
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("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):
|
def test_cli_completion_outputs_scripts(self):
|
||||||
for shell in ("bash", "zsh"):
|
for shell in ("bash", "zsh"):
|
||||||
|
|
@ -182,6 +200,9 @@ class TestTuiCore(unittest.TestCase):
|
||||||
help_lines = render_lines(TuiState(view="help"), width=100)
|
help_lines = render_lines(TuiState(view="help"), width=100)
|
||||||
for command in TUI_COMMANDS:
|
for command in TUI_COMMANDS:
|
||||||
self.assertTrue(any(command in line for line in help_lines), command)
|
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):
|
def test_header_scales_to_terminal_width(self):
|
||||||
state = TuiState(view="integrity")
|
state = TuiState(view="integrity")
|
||||||
|
|
@ -189,7 +210,7 @@ class TestTuiCore(unittest.TestCase):
|
||||||
with self.subTest(width=width):
|
with self.subTest(width=width):
|
||||||
header = _header_text(state, width)
|
header = _header_text(state, width)
|
||||||
self.assertLessEqual(len(header), width - 1)
|
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 Int", _header_text(state, 100))
|
||||||
self.assertIn("6 Integrity", _header_text(state, 180))
|
self.assertIn("6 Integrity", _header_text(state, 180))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue