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