800 lines
29 KiB
Python
800 lines
29 KiB
Python
# 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 <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
|
|
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())
|