from __future__ import annotations import curses import re 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 .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: ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") 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 @classmethod def _clean(cls, text: str) -> str: return cls.ANSI_RE.sub("", text) 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: list[str] = [] notif_lines: list[str] = [] 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 = self._clean(detail) if clean.startswith("Score Lynis") or clean.startswith("Hardening index"): score_lines.append(clean) continue if clean.startswith("Modifications") or clean.strip().startswith("•"): notif_lines.append(clean) 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)