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

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