# SPDX-License-Identifier: GPL-3.0-or-later """Stdlib curses terminal UI for SSH/tmux operators.""" from __future__ import annotations import curses import json import textwrap from dataclasses import dataclass, field from typing import Iterable from . import __version__, incident from .config import Config VIEWS = ( "status", "alerts", "incidents", "timeline", "posture", "integrity", "rules", "events", "response", "help", ) WORKFLOW_VIEWS = ("status", "incidents", "timeline", "response", "integrity") TUI_COMMANDS = { "status": "show daemon health and heartbeat", "alerts": "show recent captured alert snapshots", "incidents": "show grouped incidents", "timeline": "show incident timelines", "posture": "show advisory posture findings", "integrity": "show watchdog, FIM, package, and reconciliation state", "rules": "show event-rule catalog", "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 ", "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 class TuiState: view: str = "status" filter_text: str = "" message: str = "" scroll: int = 0 command: str = "" command_mode: bool = False should_quit: bool = False model: dict = field(default_factory=dict) def complete_command(prefix: str, commands: Iterable[str] = TUI_COMMANDS) -> str: """Return the unique command completion or the common prefix of matches.""" if not prefix: return prefix matches = sorted(c for c in commands if c.startswith(prefix)) if not matches: return prefix if len(matches) == 1: return matches[0] common = matches[0] for match in matches[1:]: i = 0 while i < min(len(common), len(match)) and common[i] == match[i]: i += 1 common = common[:i] return common or prefix def collect_model(cfg: Config) -> dict: from .web import ( daemon_status, integrity_report, posture_report, rules_catalog, tail_events, ) status = _safe_section("status", lambda: daemon_status(cfg), {}) incidents = _safe_section( "incidents", lambda: sorted( incident.load_index(cfg).values(), key=lambda i: i.get("last_ts", 0.0), reverse=True, ), [], ) return { "status": status, "alerts": _recent_alerts(cfg), "incidents": incidents, "timeline": _incident_views(cfg, incidents), "posture": _safe_section("posture", lambda: posture_report(cfg), {}), "integrity": _safe_section( "integrity", lambda: integrity_report(cfg, status), {} ), "rules": _safe_section("rules", lambda: rules_catalog(cfg), {}), "events": _safe_section("events", lambda: tail_events(cfg), []), "response": _response_plans(cfg, incidents), } def _safe_section(name: str, fn, fallback): try: return fn() except Exception as exc: # pragma: no cover - defensive TUI degradation if isinstance(fallback, dict): data = dict(fallback) data["error"] = f"{name}: {exc}" return data return [f"{name}: {exc}"] def run_tui_command(state: TuiState, command: str) -> TuiState: raw = command.strip() if not raw: state.command_mode = False return state name, _, rest = raw.partition(" ") if name in VIEWS: state.view = name state.scroll = 0 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": state.filter_text = rest.strip() state.scroll = 0 state.message = f"filter: {state.filter_text or 'none'}" elif name == "clear": state.filter_text = "" state.message = "" state.scroll = 0 elif name == "quit": state.should_quit = True else: state.message = f"unknown command: {name}" state.command = "" state.command_mode = False return state def render_lines(state: TuiState, width: int = 100) -> list[str]: model = state.model or {} view = state.view if state.view in VIEWS else "status" if view == "status": lines = _render_status(model.get("status") or {}, width) elif view == "alerts": lines = _render_alerts(model.get("alerts") or [], width) elif view == "incidents": lines = _render_incidents(model.get("incidents") or [], width) elif view == "timeline": lines = _render_timelines(model.get("timeline") or [], width) elif view == "posture": lines = _render_posture(model.get("posture") or {}, width) elif view == "integrity": lines = _render_integrity(model.get("integrity") or {}, width) elif view == "rules": lines = _render_rules(model.get("rules") or {}, width) elif view == "events": lines = _render_events(model.get("events") or [], width) elif view == "response": 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()] if not lines: lines = [f"No rows match filter: {state.filter_text}"] return lines def run(cfg: Config) -> int: curses.wrapper(lambda stdscr: _loop(stdscr, cfg)) return 0 def main(argv=None) -> int: import argparse parser = argparse.ArgumentParser( prog="enodia-sentinel-tui", description="Terminal dashboard for Enodia Sentinel.", ) parser.add_argument("-c", "--config", help="path to TOML config") args = parser.parse_args(argv) return run(Config.load(args.config)) def _loop(stdscr, cfg: Config) -> None: curses.curs_set(0) stdscr.nodelay(False) state = TuiState(model=collect_model(cfg)) while not state.should_quit: _draw(stdscr, state) key = stdscr.getch() if state.command_mode: _handle_command_key(state, key) if not state.command_mode and not state.should_quit: if state.message == "refreshed": state.model = collect_model(cfg) state.command = "" continue if key in (ord("q"), ord("Q")): state.should_quit = True elif key in (ord("r"), ord("R")): state.model = collect_model(cfg) state.message = "refreshed" elif key in (ord("1"),): state.view, state.scroll = "status", 0 elif key in (ord("2"),): state.view, state.scroll = "alerts", 0 elif key in (ord("3"),): state.view, state.scroll = "incidents", 0 elif key in (ord("4"),): state.view, state.scroll = "timeline", 0 elif key in (ord("5"),): state.view, state.scroll = "posture", 0 elif key in (ord("6"),): state.view, state.scroll = "integrity", 0 elif key in (ord("7"),): state.view, state.scroll = "rules", 0 elif key in (ord("8"),): state.view, state.scroll = "events", 0 elif key in (ord("9"),): 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 = "" curses.curs_set(1) elif key == ord("/"): state.command_mode = True state.command = "filter " curses.curs_set(1) elif key in (curses.KEY_DOWN, ord("j")): state.scroll += 1 elif key in (curses.KEY_UP, ord("k")): state.scroll = max(0, state.scroll - 1) elif key == curses.KEY_NPAGE: state.scroll += 10 elif key == curses.KEY_PPAGE: state.scroll = max(0, state.scroll - 10) elif key == 12: # Ctrl-L stdscr.clear() def _handle_command_key(state: TuiState, key: int) -> None: if key in (10, 13, curses.KEY_ENTER): run_tui_command(state, state.command) curses.curs_set(0) elif key in (27,): state.command_mode = False state.command = "" curses.curs_set(0) elif key in (9,): head, sep, tail = state.command.partition(" ") if sep: return state.command = complete_command(head) elif key in (curses.KEY_BACKSPACE, 127, 8): state.command = state.command[:-1] elif 32 <= key <= 126: state.command += chr(key) def _draw(stdscr, state: TuiState) -> None: height, width = stdscr.getmaxyx() width = max(width, 20) stdscr.erase() stdscr.addnstr(0, 0, _header_text(state, width), width - 1, curses.A_REVERSE) 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) max_scroll = max(0, len(lines) - body_h) 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 "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))) stdscr.refresh() def _header_text(state: TuiState, width: int) -> str: title = f" Enodia Sentinel TUI v{__version__} " full_tabs = ( "1 Status 2 Alerts 3 Incidents 4 Timeline 5 Posture " "6 Integrity 7 Rules 8 Events 9 Response h Help r Refresh q Quit" ) compact_tabs = ( "1 Status 2 Alerts 3 Inc 4 Time 5 Post 6 Int 7 Rules 8 Event 9 Resp" ) if width >= len(title) + len(full_tabs) + 2: text = title + full_tabs elif width >= len(title) + len(compact_tabs) + 2: text = title + compact_tabs else: 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.", "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"): heartbeat += " (STALE)" counts = status.get("counts") or {} count_text = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items())) or "none" return [ f"Host: {status.get('host', '?')}", f"Version: {status.get('version', '?')}", f"Daemon: {'running' if status.get('running') else 'stopped'}", f"Heartbeat: {heartbeat}", f"Alerts: {status.get('total_alerts', 0)} total ({count_text})", f"Last alert: {status.get('last_alert') or 'none'}", f"eBPF: {status.get('ebpf', 'unknown')}", f"Exec probe: {status.get('ebpf_exec', 'unknown')}", f"Syscall: {status.get('ebpf_syscall', 'unknown')}", "", "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.", "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", "") line = ( f"{snap.get('time', '?')[:24]:<24} {alert.get('severity', '?'):<9} " f"{alert.get('sid', 0):<7} {alert.get('signature', '?')} " f"{detail[:max(20, width - 60)]}" ) lines.append(line) return lines def _render_incidents(incidents: list[dict], width: int) -> list[str]: if not incidents: 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( f"{inc.get('id', '?'):<28} {inc.get('last_seen', '?')[:24]:<24} " f"{inc.get('severity', '?'):<9} {inc.get('alert_count', 0):>3} {sigs}" ) return lines def _render_timelines(views: list[dict], width: int) -> list[str]: if not views: return [ "No incident timelines.", "Timelines need at least one recorded incident snapshot.", ] lines = [] for view in views: if view.get("error"): lines.append(f"{view.get('incident_id', '?')}: {view['error']}") lines.append("") continue inc = view.get("incident") or {} lines.append( f"{inc.get('id', '?')} severity={inc.get('severity', '?')} " f"alerts={inc.get('alert_count', 0)}" ) sigs = ", ".join(inc.get("signatures") or []) if sigs: lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}") correlations = inc.get("correlations") or [] for corr in correlations: lines.append( f" correlation sid={corr.get('sid', '?')} " f"{corr.get('severity', '?')} {corr.get('signature', '?')}" ) lines.append( f" {_clip(corr.get('summary', ''), max(20, width - 4))}" ) timeline = view.get("timeline") or [] if not timeline: lines.append(" No timeline rows.") for row in timeline: if row.get("missing"): lines.append( f" {row.get('time', '?'):<24} missing snapshot {row.get('snapshot', '?')}" ) continue sig_text = ", ".join(row.get("signatures") or []) pid_text = ",".join(str(p) for p in row.get("pids") or []) lines.append( f" {row.get('time', '?')[:24]:<24} {row.get('severity', '?'):<9} " f"{_clip(sig_text, max(20, width - 45))} pids={pid_text or '-'}" ) lines.append("") return lines def _render_posture(report: dict, width: int) -> list[str]: if report.get("error"): return [f"Posture unavailable: {report['error']}"] findings = report.get("findings") or [] counts = report.get("counts") or {} count_text = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items())) or "none" lines = [ f"Posture findings: {report.get('count', len(findings))} ({count_text})", "", ] if not 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", "") lines.append( f"{finding.get('severity', '?'):<9} {finding.get('sid', 0):<7} " f"{finding.get('signature', '?'):<22} {_clip(detail, max(20, width - 42))}" ) return lines def _render_integrity(report: dict, width: int) -> list[str]: if report.get("error"): return [f"Integrity unavailable: {report['error']}"] if not report: return ["No integrity report available."] checks = report.get("checks") or {} watchdog = report.get("watchdog") or {} anchors = report.get("anchors") or {} fim = anchors.get("fim_baseline") or {} pkgdb = anchors.get("pkgdb") or {} pacman = anchors.get("pacman") or {} footprint = report.get("sentinel_footprint") or {} reconciliation = report.get("reconciliation") or {} hash_chain = report.get("hash_chain") or {} lines = [ f"Overall: {report.get('status', 'unknown')}", f"Generated: {report.get('generated_at', '?')}", "", "Checks:", ] for name, state in sorted(checks.items()): lines.append(f" {name:<18} {state}") lines.extend([ "", "Watchdog:", f" Status: {'ok' if watchdog.get('ok') else 'review'}", f" Message: {_clip(watchdog.get('message', '?'), max(20, width - 17))}", f" Heartbeat age: {_format_age(watchdog.get('heartbeat_age'))}", f" Max age: {watchdog.get('max_age', '?')}s", f" Daemon: {'running' if watchdog.get('daemon_running') else 'stopped'}", "", "Anchors:", f" FIM baseline: {fim.get('status', 'unknown')} ({fim.get('path', '?')})", f" Package DB: {pkgdb.get('status', 'unknown')} prefix={pkgdb.get('fingerprint_prefix', '')}", f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}", f" SigLevel: {pacman.get('siglevel_status', 'unknown')}", f" Hash chain: {'present' if hash_chain.get('exists') else 'missing'} " f"count={hash_chain.get('count', 0)}", f" {_clip(hash_chain.get('path', '?'), max(20, width - 17))}", "", "Sentinel footprint:", f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}", f" Missing: {footprint.get('missing', 0)}", "", "Reconciliation:", f" Total: {reconciliation.get('total', 0)}", f" Stale: {reconciliation.get('stale', 0)}", ]) for item in reconciliation.get("stale_items") or []: lines.append( f" stale {item.get('kind', '?')} {item.get('target', '?')} - " f"{_clip(item.get('reason', ''), max(20, width - 20))}" ) return lines def _render_rules(catalog, width: int) -> list[str]: if isinstance(catalog, dict) and catalog.get("error"): return [f"Rules unavailable: {catalog['error']}"] rules = catalog.get("rules", []) if isinstance(catalog, dict) else catalog if not rules: return ["No rules loaded.", "This usually means no built-in rule metadata was available."] lines = [] if isinstance(catalog, dict): lines.append( "Rules: " f"{catalog.get('count', len(rules))} total; " f"events={_dict_counts(catalog.get('by_event') or {})}; " f"severity={_dict_counts(catalog.get('by_severity') or {})}" ) lines.append("") lines.append(f"{'SID':<7} {'EVENT':<8} {'SEV':<9} {'CLASSTYPE':<24} MESSAGE") for rule in rules: lines.append( f"{rule.get('sid', 0):<7} {rule.get('event', '?'):<8} " f"{rule.get('severity', '?'):<9} {rule.get('classtype', '?'):<24} " f"{rule.get('msg', '')[:max(20, width - 52)]}" ) return lines def _render_events(events: list[str], width: int) -> list[str]: if not 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.", "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: if plan.get("error"): lines.append(f"{plan.get('incident_id', '?')}: {plan['error']}") lines.append("") continue summary = plan.get("summary") or {} lines.append( f"{plan.get('incident_id', '?')} severity={summary.get('severity', '?')} " f"actions={summary.get('action_count', len(plan.get('actions') or []))} " f"mode={plan.get('mode', 'dry-run')}" ) sigs = ", ".join(summary.get("signatures") or []) if sigs: lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}") for action in plan.get("actions") or []: cmd = " ".join(str(part) for part in action.get("command") or []) lines.append( f" {action.get('id', '?'):<4} {action.get('category', '?'):<12} " f"{action.get('risk', '?'):<7} {_clip(action.get('title', ''), max(20, width - 31))}" ) lines.append(f" {_clip(cmd, max(20, width - 7))}") lines.append("") return lines 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", " / filter current view", " : command mode", " Tab complete command names in command mode", " Ctrl-L redraw", " q quit", "", "Commands:", ] for name, help_text in TUI_COMMANDS.items(): lines.append(f" {name:<10} {help_text}") return [wrapped for line in lines for wrapped in (textwrap.wrap(line, width) or [""])] def _recent_alerts(cfg: Config, limit: int = 50) -> list[dict]: rows = [] for path in sorted(cfg.log_dir.glob("alert-*.json"), reverse=True)[:limit]: try: data = json.loads(path.read_text()) except (OSError, ValueError): continue if isinstance(data, dict): data.setdefault("snapshot", path.name) rows.append(data) return rows def _response_plans(cfg: Config, incidents: list[dict], limit: int = 20) -> list[dict]: from .web import response_plan plans = [] for inc in incidents[:limit]: incident_id = inc.get("id") if not incident_id: continue try: plan = response_plan(cfg, str(incident_id)) except Exception as exc: # pragma: no cover - defensive TUI degradation plans.append({"incident_id": incident_id, "error": f"response: {exc}"}) continue if isinstance(plan, dict): plans.append(plan) return plans def _incident_views(cfg: Config, incidents: list[dict], limit: int = 20) -> list[dict]: from .web import get_incident views = [] for inc in incidents[:limit]: incident_id = inc.get("id") if not incident_id: continue try: view = get_incident(cfg, str(incident_id)) except Exception as exc: # pragma: no cover - defensive TUI degradation views.append({"incident_id": incident_id, "error": f"timeline: {exc}"}) continue if isinstance(view, dict): views.append(view) return views def _clip(text, width: int) -> str: text = str(text) if width <= 0 or len(text) <= width: return text if width <= 3: return text[:width] return text[:width - 3] + "..." def _format_age(value) -> str: if value is None: return "none" try: return f"{int(value)}s" except (TypeError, ValueError): return str(value) def _dict_counts(data: dict) -> str: return ", ".join(f"{k}:{v}" for k, v in sorted(data.items())) or "none" if __name__ == "__main__": raise SystemExit(main())