Expand TUI and package recovery planning

This commit is contained in:
Luna 2026-07-09 05:51:59 -07:00
parent cab60cd633
commit 51d52b5229
No known key found for this signature in database
10 changed files with 511 additions and 41 deletions

View file

@ -8,16 +8,32 @@ import textwrap
from dataclasses import dataclass, field
from typing import Iterable
from . import __version__, incident, ruleops
from . import __version__, incident
from .config import Config
VIEWS = ("status", "alerts", "incidents", "rules", "help")
VIEWS = (
"status",
"alerts",
"incidents",
"timeline",
"posture",
"integrity",
"rules",
"events",
"response",
"help",
)
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",
"refresh": "reload data from disk",
"filter": "filter current view: filter <text>",
@ -57,20 +73,50 @@ def complete_command(prefix: str, commands: Iterable[str] = TUI_COMMANDS) -> str
def collect_model(cfg: Config) -> dict:
from .web import daemon_status
from .web import (
daemon_status,
integrity_report,
posture_report,
rules_catalog,
tail_events,
)
return {
"status": daemon_status(cfg),
"alerts": _recent_alerts(cfg),
"incidents": sorted(
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,
),
"rules": ruleops.list_rules(cfg),
[],
)
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:
@ -109,8 +155,18 @@ def render_lines(state: TuiState, width: int = 100) -> list[str]:
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)
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 state.filter_text:
@ -164,7 +220,17 @@ def _loop(stdscr, cfg: Config) -> None:
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 == ord(":"):
@ -210,9 +276,7 @@ def _draw(stdscr, state: TuiState) -> None:
height, width = stdscr.getmaxyx()
width = max(width, 20)
stdscr.erase()
title = f" Enodia Sentinel TUI v{__version__} "
tabs = " 1 Status 2 Alerts 3 Incidents 4 Rules h Help r Refresh q Quit"
stdscr.addnstr(0, 0, (title + tabs)[:width - 1], width - 1, curses.A_REVERSE)
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}"
stdscr.addnstr(1, 0, meta[:width - 1], width - 1)
lines = render_lines(state, width)
@ -228,6 +292,24 @@ def _draw(stdscr, state: TuiState) -> None:
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={state.view} 1-9 switch h help r refresh q quit"
return text[:max(0, width - 1)]
def _render_status(status: dict, width: int) -> list[str]:
if not status:
return ["No status available."]
@ -248,7 +330,8 @@ 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/3/4 to inspect alerts, incidents, and rules. Use ':' for commands.",
"Use 2-9 to inspect alerts, incidents, timelines, posture, integrity, rules, events, and response plans.",
"Use ':' for commands.",
]
@ -281,10 +364,133 @@ def _render_incidents(incidents: list[dict], width: int) -> list[str]:
return lines
def _render_rules(rules: list[dict], width: int) -> list[str]:
def _render_timelines(views: list[dict], width: int) -> list[str]:
if not views:
return ["No incident timelines."]
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))}")
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.")
return lines
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 {}
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')}",
"",
"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."]
lines = [f"{'SID':<7} {'EVENT':<8} {'SEV':<9} {'CLASSTYPE':<24} MESSAGE"]
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} "
@ -294,10 +500,48 @@ def _render_rules(rules: list[dict], width: int) -> list[str]:
return lines
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]
def _render_response(plans: list[dict], width: int) -> list[str]:
if not plans:
return ["No incidents with response plans."]
lines = [
"Read-only response previews. Commands are not executed from the TUI.",
"",
]
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 = [
"Keys:",
" 1/2/3/4 switch views: status, alerts, incidents, rules",
" 1-9 switch views: status, alerts, incidents, timeline, posture, integrity, rules, events, response",
" j/k or arrows scroll",
" PgUp/PgDn page scroll",
" r refresh",
@ -327,5 +571,63 @@ def _recent_alerts(cfg: Config, limit: int = 50) -> list[dict]:
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())