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
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue