Files
SecureCheck/securecheck/app.py
2026-04-06 08:37:54 +02:00

426 lines
20 KiB
Python

from __future__ import annotations
import curses
import textwrap
from collections import defaultdict
from dataclasses import dataclass
from typing import Callable
from .assets import banner_text
from .models import Scenario, TaskDefinition, TaskResult
from .status import StatusItem
from .storage import ScenarioStore
from .summary_utils import SCORE_PREFIXES, clean_text, collect_details
from .system_info import SystemInfo
@dataclass
class AppSelection:
task_keys: list[str]
scenario_name: str | None = None
class Palette:
TITLE = 1
HEADER = 2
PANEL = 3
SELECTED = 4
SUCCESS = 5
ERROR = 6
MUTED = 7
HIGHLIGHT = 8
CATEGORY = 9
def _setup_colors() -> None:
if not curses.has_colors():
return
curses.start_color()
curses.use_default_colors()
curses.init_pair(Palette.TITLE, curses.COLOR_CYAN, -1)
curses.init_pair(Palette.HEADER, curses.COLOR_YELLOW, -1)
curses.init_pair(Palette.PANEL, curses.COLOR_WHITE, curses.COLOR_BLUE)
curses.init_pair(Palette.SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(Palette.SUCCESS, curses.COLOR_GREEN, -1)
curses.init_pair(Palette.ERROR, curses.COLOR_RED, -1)
curses.init_pair(Palette.MUTED, curses.COLOR_BLUE, -1)
curses.init_pair(Palette.HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(Palette.CATEGORY, curses.COLOR_MAGENTA, -1)
class SecureCheckTUI:
def __init__(
self,
system: SystemInfo,
tasks: list[TaskDefinition],
store: ScenarioStore,
*,
status_provider: Callable[[], list[StatusItem]],
initial_selected: set[str] | None = None,
initial_scenario_name: str | None = None,
initial_message: str | None = None,
) -> None:
self.system = system
self.tasks = tasks
self.store = store
self.status_provider = status_provider
self.index = 0
self.message = initial_message or "Sélectionnez les tâches puis lancez avec 'r'."
self.selected = initial_selected if initial_selected is not None else {task.key for task in tasks if task.default_selected}
self.scenario_name = initial_scenario_name
self.banner_lines = banner_text().splitlines()
self.status_items = self.status_provider()
def run(self) -> AppSelection | None:
return curses.wrapper(self._main)
def _main(self, stdscr: curses.window) -> AppSelection | None:
curses.curs_set(0)
stdscr.keypad(True)
_setup_colors()
while True:
self._draw(stdscr)
key = stdscr.getch()
if key in (ord("q"), 27):
return None
if key in (curses.KEY_UP, ord("k")):
self.index = max(0, self.index - 1)
elif key in (curses.KEY_DOWN, ord("j")):
self.index = min(len(self.tasks) - 1, self.index + 1)
elif key == ord(" "):
current = self.tasks[self.index].key
if current in self.selected:
self.selected.remove(current)
else:
self.selected.add(current)
elif key == ord("a"):
baseline = self.store.get("baseline_workstation")
if baseline:
self.selected = set(baseline.task_keys)
self.scenario_name = baseline.name
self.message = "Scénario baseline_workstation chargé."
elif key == ord("s"):
self._save_current(stdscr)
elif key == ord("l"):
self._load_scenario(stdscr)
elif key == ord("d"):
self._show_dashboard(stdscr)
elif key == ord("x"):
self._delete_scenario(stdscr)
elif key == ord("r"):
if not self.selected:
self.message = "Aucune tâche sélectionnée."
continue
return AppSelection(
task_keys=[task.key for task in self.tasks if task.key in self.selected],
scenario_name=self.scenario_name,
)
elif key == ord("?"):
self._show_help(stdscr)
def _draw(self, stdscr: curses.window) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
header_row = 1
for line in self.banner_lines[: min(6, max(0, height - 8))]:
stdscr.addnstr(header_row, 2, line, width - 4, curses.color_pair(Palette.TITLE) | curses.A_BOLD)
header_row += 1
scenario_hint = f" | scénario={self.scenario_name}" if self.scenario_name else ""
header = f"SecureCheck | {self.system.pretty_name} | user={self.system.target_user} | pkg={self.system.package_manager}{scenario_hint}"
stdscr.addnstr(header_row, 2, header, width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
stdscr.addnstr(
header_row + 1,
2,
"↑↓ naviguer Espace cocher s sauver l charger d état x supprimer r exécuter q quitter",
width - 4,
curses.color_pair(Palette.MUTED),
)
stdscr.hline(header_row + 2, 1, curses.ACS_HLINE, width - 2)
list_top = header_row + 4
desc_height = 5
status_width = 40 if width >= 120 else 0
task_width = width - 4 - status_width - (2 if status_width else 0)
left_x = 1
right_x = left_x + task_width + 2 if status_width else 0
content_height = max(8, height - list_top - desc_height - 2)
self._draw_box(stdscr, list_top, left_x, content_height, task_width, "Tâches")
self._draw_task_list(stdscr, list_top + 1, left_x + 1, content_height - 2, task_width - 2)
if status_width:
self._draw_box(stdscr, list_top, right_x, content_height, status_width, "Etat")
self._draw_status_panel(stdscr, list_top + 1, right_x + 1, content_height - 2, status_width - 2)
desc_top = list_top + content_height + 1
self._draw_box(stdscr, desc_top, 1, desc_height, width - 2, "Détail")
current = self.tasks[self.index]
count_selected = len(self.selected)
total_ok = sum(1 for item in self.status_items if item.ok)
total_ko = len(self.status_items) - total_ok
summary = f"Sélection: {count_selected}/{len(self.tasks)} | Etat: {total_ok} OK / {total_ko} KO"
stdscr.addnstr(desc_top + 1, 3, summary, width - 8, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
for offset, line in enumerate(textwrap.wrap(current.description, width - 8)[:2], start=desc_top + 2):
stdscr.addnstr(offset, 3, line, width - 8)
stdscr.addnstr(height - 1, 2, self.message, width - 4, curses.color_pair(Palette.MUTED) | curses.A_BOLD)
stdscr.refresh()
def _draw_box(self, stdscr: curses.window, top: int, left: int, height: int, width: int, title: str) -> None:
stdscr.attron(curses.color_pair(Palette.PANEL))
stdscr.addch(top, left, curses.ACS_ULCORNER)
stdscr.hline(top, left + 1, curses.ACS_HLINE, width - 2)
stdscr.addch(top, left + width - 1, curses.ACS_URCORNER)
for row in range(top + 1, top + height - 1):
stdscr.addch(row, left, curses.ACS_VLINE)
stdscr.addch(row, left + width - 1, curses.ACS_VLINE)
stdscr.addch(top + height - 1, left, curses.ACS_LLCORNER)
stdscr.hline(top + height - 1, left + 1, curses.ACS_HLINE, width - 2)
stdscr.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER)
stdscr.attroff(curses.color_pair(Palette.PANEL))
stdscr.addnstr(top, left + 2, f" {title} ", width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
def _draw_task_list(self, stdscr: curses.window, top: int, left: int, height: int, width: int) -> None:
window_start = max(0, min(self.index - (height // 2), max(0, len(self.tasks) - height)))
visible_tasks = self.tasks[window_start : window_start + height]
for offset in range(height):
stdscr.addnstr(top + offset, left, " " * width, width)
for row, task in enumerate(visible_tasks):
y = top + row
selected = task.key in self.selected
current = self.tasks[self.index].key == task.key
marker = "" if selected else " "
category = f"[{task.category}]"
base_attr = curses.color_pair(Palette.SELECTED) | curses.A_BOLD if current else curses.A_NORMAL
stdscr.addnstr(y, left, " " * width, width, base_attr)
led_attr = curses.color_pair(Palette.SUCCESS if selected else Palette.MUTED) | curses.A_BOLD
stdscr.addnstr(y, left, "", 1, led_attr | (curses.A_REVERSE if current else 0))
stdscr.addnstr(y, left + 2, f"[{marker}]", 3, base_attr | (curses.color_pair(Palette.SUCCESS) if selected else 0))
stdscr.addnstr(y, left + 6, task.label, max(1, width - 22), base_attr)
stdscr.addnstr(y, left + width - min(18, len(category) + 1), category, min(18, width - 1), curses.color_pair(Palette.CATEGORY) | (curses.A_BOLD if current else 0))
def _draw_status_panel(self, stdscr: curses.window, top: int, left: int, height: int, width: int) -> None:
self.status_items = self.status_provider()
items = self.status_items[:height]
for offset in range(height):
stdscr.addnstr(top + offset, left, " " * width, width)
for row, item in enumerate(items):
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR) | curses.A_BOLD
stdscr.addnstr(top + row, left, "", 1, color)
line = f" {item.label:<16} {item.detail}"
stdscr.addnstr(top + row, left + 2, line, width - 2)
def _prompt(self, stdscr: curses.window, prompt: str) -> str | None:
height, width = stdscr.getmaxyx()
curses.echo()
curses.curs_set(1)
stdscr.move(height - 1, 0)
stdscr.clrtoeol()
stdscr.addnstr(height - 1, 2, prompt, width - 4, curses.color_pair(Palette.HIGHLIGHT) | curses.A_BOLD)
value = stdscr.getstr(height - 1, min(len(prompt) + 2, width - 2), 60).decode("utf-8").strip()
curses.noecho()
curses.curs_set(0)
return value or None
def _pick_scenario(self, stdscr: curses.window, *, deletable_only: bool = False) -> Scenario | None:
scenarios = self.store.list_all()
if deletable_only:
scenarios = [scenario for scenario in scenarios if not scenario.builtin]
if not scenarios:
self.message = "Aucun scénario disponible."
return None
index = 0
while True:
stdscr.erase()
_setup_colors()
height, width = stdscr.getmaxyx()
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Scénarios")
stdscr.addnstr(1, 3, "Entrée valider | q retour", width - 6, curses.color_pair(Palette.MUTED))
for line_no, scenario in enumerate(scenarios[: height - 4], start=2):
prefix = ">" if line_no - 2 == index else " "
kind = "builtin" if scenario.builtin else "user"
attr = curses.color_pair(Palette.SELECTED) | curses.A_BOLD if line_no - 2 == index else curses.A_NORMAL
stdscr.addnstr(line_no + 1, 3, f"{prefix} {scenario.name} [{kind}] - {scenario.description}", width - 6, attr)
stdscr.refresh()
key = stdscr.getch()
if key in (ord("q"), 27):
return None
if key in (curses.KEY_UP, ord("k")):
index = max(0, index - 1)
elif key in (curses.KEY_DOWN, ord("j")):
index = min(len(scenarios) - 1, index + 1)
elif key in (10, 13, curses.KEY_ENTER):
return scenarios[index]
def _save_current(self, stdscr: curses.window) -> None:
name = self._prompt(stdscr, "Nom du scénario: ")
if not name:
self.message = "Sauvegarde annulée."
return
description = self._prompt(stdscr, "Description courte: ") or ""
self.store.save(Scenario(name=name, description=description, task_keys=sorted(self.selected)))
self.scenario_name = name
self.message = f"Scénario '{name}' enregistré."
def _load_scenario(self, stdscr: curses.window) -> None:
scenario = self._pick_scenario(stdscr)
if not scenario:
self.message = "Chargement annulé."
return
self.selected = set(scenario.task_keys)
self.scenario_name = scenario.name
self.message = f"Scénario '{scenario.name}' chargé."
def _delete_scenario(self, stdscr: curses.window) -> None:
scenario = self._pick_scenario(stdscr, deletable_only=True)
if not scenario:
self.message = "Suppression annulée."
return
if self.store.delete(scenario.name):
self.message = f"Scénario '{scenario.name}' supprimé."
else:
self.message = "Impossible de supprimer le scénario."
def _show_help(self, stdscr: curses.window) -> None:
lines = [
"SecureCheck - Aide rapide",
"",
"Chaque ligne correspond à une action automatisable.",
"Les scénarios permettent d'enregistrer un lot réutilisable.",
"Vous pouvez précharger un scénario puis ajuster les cases avant exécution.",
"La touche d affiche le tableau d'état du système avec indicateurs rouge/vert.",
"Après exécution, l'application affiche un résumé puis revient au menu.",
"",
"Appuyez sur une touche pour revenir.",
]
stdscr.erase()
_setup_colors()
height, width = stdscr.getmaxyx()
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Aide")
for index, line in enumerate(lines[: height - 1]):
stdscr.addnstr(index + 1, 3, line, width - 6, curses.color_pair(Palette.HEADER) if index == 0 else curses.A_NORMAL)
stdscr.refresh()
stdscr.getch()
def _show_dashboard(self, stdscr: curses.window) -> None:
self.status_items = self.status_provider()
groups: dict[str, list[StatusItem]] = defaultdict(list)
for item in self.status_items:
groups[item.category].append(item)
_setup_colors()
stdscr.erase()
height, width = stdscr.getmaxyx()
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Tableau d'état")
stdscr.addnstr(1, 3, "Vert = OK | Rouge = manquant/inactif | Appuyez sur une touche", width - 6, curses.color_pair(Palette.MUTED))
row = 3
for category, items in groups.items():
if row >= height - 1:
break
stdscr.addnstr(row, 3, f"[{category}]", width - 6, curses.color_pair(Palette.CATEGORY) | curses.A_BOLD)
row += 1
for item in items:
if row >= height - 1:
break
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR)
stdscr.addstr(row, 3, "", color | curses.A_BOLD)
stdscr.addnstr(row, 5, f"{item.label:<18} {item.detail}", width - 8)
row += 1
row += 1
stdscr.refresh()
stdscr.getch()
class RunSummaryTUI:
def __init__(self, results: list[TaskResult], status_items: list[StatusItem], run_log_path: str) -> None:
self.results = results
self.status_items = status_items
self.run_log_path = run_log_path
self.scroll_offset = 0
def run(self) -> None:
curses.wrapper(self._main)
def _main(self, stdscr: curses.window) -> None:
curses.curs_set(0)
stdscr.keypad(True)
_setup_colors()
while True:
self._draw(stdscr)
key = stdscr.getch()
if key in (ord("q"), 27, 10, 13, ord("m"), ord(" ")):
return
if key == curses.KEY_UP and self.scroll_offset > 0:
self.scroll_offset -= 1
elif key == curses.KEY_DOWN:
self.scroll_offset += 1
def _draw(self, stdscr: curses.window) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
ok_count = sum(1 for result in self.results if result.success)
ko_count = len(self.results) - ok_count
score_lines, notif_lines = collect_details(self.results)
entries: list[tuple[str, int]] = []
entries.append((f"OK: {ok_count} | ECHEC: {ko_count} | Appuie sur une touche pour revenir", curses.color_pair(Palette.HEADER) | curses.A_BOLD))
for result in self.results:
status = "OK" if result.success else "ECHEC"
color = curses.color_pair(Palette.SUCCESS if result.success else Palette.ERROR)
entries.append((f"{status:<4} {result.label} ({result.duration_seconds:.1f}s)", color | curses.A_BOLD))
for detail in result.details:
clean = clean_text(detail).strip()
if any(clean.startswith(prefix) for prefix in SCORE_PREFIXES):
continue
if clean.startswith("Modifications") or clean.startswith(""):
continue
wrapped = textwrap.wrap(clean, width - 9) or [""]
for line in wrapped:
entries.append((f" - {line}", 0))
if result.error:
entries.append((f" - {result.error}", curses.color_pair(Palette.ERROR)))
if score_lines:
entries.insert(1, ("Lynis", curses.color_pair(Palette.CATEGORY) | curses.A_BOLD))
for idx, line in enumerate(score_lines, start=2):
entries.insert(idx, (f" {line}", curses.color_pair(Palette.SUCCESS)))
entries.append((("", 0)))
entries.append(("Etat synthétique:", curses.color_pair(Palette.CATEGORY) | curses.A_BOLD))
for item in self.status_items:
attr = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR) | curses.A_BOLD
entries.append((f"● [{item.category}] {item.label}: {item.detail}", attr))
if notif_lines:
entries.append((("", 0)))
entries.append(("Modifications recommandées:", curses.color_pair(Palette.ERROR) | curses.A_BOLD))
for line in notif_lines:
clean = self._clean(line)
bullet = "" if clean.strip().startswith("") else "-"
entries.append((f" {bullet} {clean.lstrip('').strip()}", curses.color_pair(Palette.MUTED)))
entries.append(("", 0))
entries.append((f"Log: {self.run_log_path}", curses.color_pair(Palette.MUTED)))
available = height - 4
max_offset = max(0, len(entries) - available)
self.scroll_offset = min(max(self.scroll_offset, 0), max_offset)
visible = entries[self.scroll_offset : self.scroll_offset + available]
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Résumé d'exécution")
for idx, (line, attr) in enumerate(visible):
stdscr.addnstr(2 + idx, 3, line, width - 6, attr)
if max_offset:
bar_pos = int((self.scroll_offset / max_offset) * (available - 1)) if max_offset else 0
stdscr.addch(2 + min(bar_pos, available - 1), width - 3, curses.ACS_CKBOARD)
def _draw_box(self, stdscr: curses.window, top: int, left: int, height: int, width: int, title: str) -> None:
stdscr.attron(curses.color_pair(Palette.PANEL))
stdscr.addch(top, left, curses.ACS_ULCORNER)
stdscr.hline(top, left + 1, curses.ACS_HLINE, width - 2)
stdscr.addch(top, left + width - 1, curses.ACS_URCORNER)
for row in range(top + 1, top + height - 1):
stdscr.addch(row, left, curses.ACS_VLINE)
stdscr.addch(row, left + width - 1, curses.ACS_VLINE)
stdscr.addch(top + height - 1, left, curses.ACS_LLCORNER)
stdscr.hline(top + height - 1, left + 1, curses.ACS_HLINE, width - 2)
stdscr.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER)
stdscr.attroff(curses.color_pair(Palette.PANEL))
stdscr.addnstr(top, left + 2, f" {title} ", width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)