Add terminal TUI and shell completion
This commit is contained in:
parent
a7535cfc56
commit
cab60cd633
11 changed files with 612 additions and 0 deletions
|
|
@ -208,6 +208,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="sid for show, event JSON path for test, or '-'")
|
||||
rules.add_argument("--json", action="store_true", help="emit JSON")
|
||||
sub.add_parser("web", help="serve the read-only dashboard")
|
||||
sub.add_parser("tui", help="open the terminal dashboard (curses)")
|
||||
sub.add_parser("triage", help="classify captured alerts as likely-FP vs review")
|
||||
sub.add_parser("fim-baseline", help="build the file-integrity baseline")
|
||||
sub.add_parser("fim-update", help="refresh the FIM baseline (run by the pacman hook)")
|
||||
|
|
@ -249,6 +250,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="heartbeat staleness threshold (seconds)")
|
||||
wd.add_argument("--insecure-tls", action="store_true",
|
||||
help="allow self-signed/untrusted dashboard certificates")
|
||||
comp = sub.add_parser("completion",
|
||||
help="print shell completion script for bash or zsh")
|
||||
comp.add_argument("shell", choices=["bash", "zsh"],
|
||||
help="shell to generate completion for")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
cfg = Config.load(args.config)
|
||||
|
|
@ -270,6 +275,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
from .web import serve
|
||||
serve(cfg)
|
||||
return 0
|
||||
if args.cmd == "tui":
|
||||
from .tui import run as run_tui
|
||||
return run_tui(cfg)
|
||||
if args.cmd == "triage":
|
||||
return _cmd_triage(cfg)
|
||||
if args.cmd in ("fim-baseline", "fim-update"):
|
||||
|
|
@ -301,6 +309,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if args.cmd == "watchdog":
|
||||
return _cmd_watchdog(cfg, args.url, args.token, args.max_age,
|
||||
not args.insecure_tls)
|
||||
if args.cmd == "completion":
|
||||
from .completion import script
|
||||
print(script(args.shell), end="")
|
||||
return 0
|
||||
return _cmd_run(cfg)
|
||||
|
||||
|
||||
|
|
|
|||
102
enodia_sentinel/completion.py
Normal file
102
enodia_sentinel/completion.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Shell completion script generation."""
|
||||
from __future__ import annotations
|
||||
|
||||
COMMANDS = (
|
||||
"run", "check", "baseline", "list-detectors", "rules", "web", "tui",
|
||||
"triage", "fim-baseline", "fim-update", "fim-check", "pkgdb-check",
|
||||
"pkgdb-verify", "rootcheck", "status", "incident", "posture", "respond",
|
||||
"watchdog", "completion",
|
||||
)
|
||||
|
||||
RULE_ACTIONS = ("list", "show", "test", "docs")
|
||||
BASELINE_ACTIONS = ("build", "accept", "revoke", "list")
|
||||
INCIDENT_ACTIONS = ("list", "show", "export")
|
||||
POSTURE_ACTIONS = ("check",)
|
||||
RESPOND_ACTIONS = ("plan",)
|
||||
COMPLETION_SHELLS = ("bash", "zsh")
|
||||
|
||||
|
||||
def bash_completion(prog: str = "enodia-sentinel") -> str:
|
||||
commands = " ".join(COMMANDS)
|
||||
rule_actions = " ".join(RULE_ACTIONS)
|
||||
baseline_actions = " ".join(BASELINE_ACTIONS)
|
||||
incident_actions = " ".join(INCIDENT_ACTIONS)
|
||||
posture_actions = " ".join(POSTURE_ACTIONS)
|
||||
respond_actions = " ".join(RESPOND_ACTIONS)
|
||||
shells = " ".join(COMPLETION_SHELLS)
|
||||
return f"""# bash completion for {prog}
|
||||
_{prog.replace('-', '_')}_complete() {{
|
||||
local cur prev cmd
|
||||
COMPREPLY=()
|
||||
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
||||
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
|
||||
cmd=""
|
||||
for word in "${{COMP_WORDS[@]:1}}"; do
|
||||
case "$word" in
|
||||
-* ) ;;
|
||||
* ) cmd="$word"; break ;;
|
||||
esac
|
||||
done
|
||||
if [[ $COMP_CWORD -le 1 || -z "$cmd" ]]; then
|
||||
COMPREPLY=( $(compgen -W "--version --config -c {commands}" -- "$cur") )
|
||||
return 0
|
||||
fi
|
||||
case "$cmd" in
|
||||
rules) COMPREPLY=( $(compgen -W "{rule_actions} --json" -- "$cur") ) ;;
|
||||
baseline) COMPREPLY=( $(compgen -W "{baseline_actions} --reason --expires --force --stale --json" -- "$cur") ) ;;
|
||||
incident) COMPREPLY=( $(compgen -W "{incident_actions} --json" -- "$cur") ) ;;
|
||||
posture) COMPREPLY=( $(compgen -W "{posture_actions} --json" -- "$cur") ) ;;
|
||||
respond) COMPREPLY=( $(compgen -W "{respond_actions} --json" -- "$cur") ) ;;
|
||||
completion) COMPREPLY=( $(compgen -W "{shells}" -- "$cur") ) ;;
|
||||
check|fim-check|status) COMPREPLY=( $(compgen -W "--json --packages" -- "$cur") ) ;;
|
||||
pkgdb-verify) COMPREPLY=( $(compgen -W "--sample" -- "$cur") ) ;;
|
||||
watchdog) COMPREPLY=( $(compgen -W "--url --token --max-age --insecure-tls" -- "$cur") ) ;;
|
||||
esac
|
||||
}}
|
||||
complete -F _{prog.replace('-', '_')}_complete {prog}
|
||||
"""
|
||||
|
||||
|
||||
def zsh_completion(prog: str = "enodia-sentinel") -> str:
|
||||
command_specs = " ".join(f"{c}:{c}" for c in COMMANDS)
|
||||
return f"""#compdef {prog}
|
||||
# zsh completion for {prog}
|
||||
local -a commands
|
||||
commands=({command_specs})
|
||||
|
||||
_arguments -C \\
|
||||
'(-h --help)'{{-h,--help}}'[show help]' \\
|
||||
'--version[show version]' \\
|
||||
'(-c --config)'{{-c,--config}}'[path to TOML config]:config file:_files' \\
|
||||
'1:command:->command' \\
|
||||
'*::arg:->args'
|
||||
|
||||
case $state in
|
||||
command)
|
||||
_describe 'command' commands
|
||||
;;
|
||||
args)
|
||||
case $words[2] in
|
||||
rules) _arguments '1:action:((list show test docs))' '--json[emit JSON]' ;;
|
||||
baseline) _arguments '1:action:((build accept revoke list))' '2:kind:((fim pkgfile listener suid))' '--reason[reason]' '--expires[TTL]' '--force[force]' '--stale[stale]' '--json[emit JSON]' ;;
|
||||
incident) _arguments '1:action:((list show export))' '--json[emit JSON]' ;;
|
||||
posture) _arguments '1:action:((check))' '--json[emit JSON]' ;;
|
||||
respond) _arguments '1:action:((plan))' '--json[emit JSON]' ;;
|
||||
completion) _arguments '1:shell:((bash zsh))' ;;
|
||||
check|status) _arguments '--json[emit JSON]' ;;
|
||||
fim-check) _arguments '--packages[verify package-owned files]' ;;
|
||||
pkgdb-verify) _arguments '--sample[package sample size]' ;;
|
||||
watchdog) _arguments '--url[dashboard URL]' '--token[bearer token]' '--max-age[seconds]' '--insecure-tls[allow self-signed TLS]' ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
"""
|
||||
|
||||
|
||||
def script(shell: str, prog: str = "enodia-sentinel") -> str:
|
||||
if shell == "bash":
|
||||
return bash_completion(prog)
|
||||
if shell == "zsh":
|
||||
return zsh_completion(prog)
|
||||
raise ValueError(f"unsupported shell: {shell}")
|
||||
331
enodia_sentinel/tui.py
Normal file
331
enodia_sentinel/tui.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
# 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, ruleops
|
||||
from .config import Config
|
||||
|
||||
|
||||
VIEWS = ("status", "alerts", "incidents", "rules", "help")
|
||||
TUI_COMMANDS = {
|
||||
"status": "show daemon health and heartbeat",
|
||||
"alerts": "show recent captured alert snapshots",
|
||||
"incidents": "show grouped incidents",
|
||||
"rules": "show event-rule catalog",
|
||||
"help": "show keys and commands",
|
||||
"refresh": "reload data from disk",
|
||||
"filter": "filter current view: filter <text>",
|
||||
"clear": "clear filter and message",
|
||||
"quit": "exit the TUI",
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
return {
|
||||
"status": daemon_status(cfg),
|
||||
"alerts": _recent_alerts(cfg),
|
||||
"incidents": sorted(
|
||||
incident.load_index(cfg).values(),
|
||||
key=lambda i: i.get("last_ts", 0.0),
|
||||
reverse=True,
|
||||
),
|
||||
"rules": ruleops.list_rules(cfg),
|
||||
}
|
||||
|
||||
|
||||
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: {name}"
|
||||
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 == "rules":
|
||||
lines = _render_rules(model.get("rules") or [], width)
|
||||
else:
|
||||
lines = _render_help(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 = "rules", 0
|
||||
elif key in (ord("h"), ord("?")):
|
||||
state.view, state.scroll = "help", 0
|
||||
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()
|
||||
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)
|
||||
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)
|
||||
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 "Press ':' for commands; Tab completes command names."
|
||||
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 _render_status(status: dict, width: int) -> list[str]:
|
||||
if not status:
|
||||
return ["No status available."]
|
||||
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')}",
|
||||
"",
|
||||
"Use 2/3/4 to inspect alerts, incidents, and rules. Use ':' for commands.",
|
||||
]
|
||||
|
||||
|
||||
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"]
|
||||
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."]
|
||||
lines = [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_rules(rules: list[dict], width: int) -> list[str]:
|
||||
if not rules:
|
||||
return ["No rules loaded."]
|
||||
lines = [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_help(width: int) -> list[str]:
|
||||
lines = [
|
||||
"Keys:",
|
||||
" 1/2/3/4 switch views: status, alerts, incidents, rules",
|
||||
" 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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue