Expand TUI guidance and host detection coverage

This commit is contained in:
Luna 2026-07-09 19:16:27 -07:00
parent 3b037646d2
commit 8194d13734
20 changed files with 414 additions and 42 deletions

View file

@ -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/<pid>/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 '<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:
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"),
)

View file

@ -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,
),
)

View file

@ -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)))),
)

View file

@ -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"),

View file

@ -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")

View file

@ -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 <text>",
"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",