Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""SecureCheck package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from .app import RunSummaryTUI, SecureCheckTUI
|
||||
from .catalog import builtin_scenarios, task_catalog
|
||||
from .config import build_paths, ensure_app_dirs
|
||||
from .executor import ExecutionContext, execute_tasks
|
||||
from .logging_utils import attach_run_handler, setup_logging
|
||||
from .status import collect_status
|
||||
from .storage import ScenarioStore
|
||||
from .system_info import detect_system
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="SecureCheck - console semi-graphique pour contrôles sécurité Linux")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Simule les commandes sans modifier le système")
|
||||
parser.add_argument("--run", action="store_true", help="Lance immédiatement les tâches passées via --tasks ou --scenario")
|
||||
parser.add_argument("--tasks", help="Liste de tâches séparées par des virgules")
|
||||
parser.add_argument("--scenario", help="Nom d'un scénario enregistré ou builtin")
|
||||
parser.add_argument("--list-scenarios", action="store_true", help="Affiche les scénarios disponibles")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_task_selection(args: argparse.Namespace, store: ScenarioStore, available_task_keys: set[str]) -> list[str]:
|
||||
if args.scenario:
|
||||
scenario = store.get(args.scenario)
|
||||
if not scenario:
|
||||
raise SystemExit(f"Scénario inconnu: {args.scenario}")
|
||||
return [key for key in scenario.task_keys if key in available_task_keys]
|
||||
|
||||
if args.tasks:
|
||||
selected = [key.strip() for key in args.tasks.split(",") if key.strip()]
|
||||
invalid = [key for key in selected if key not in available_task_keys]
|
||||
if invalid:
|
||||
raise SystemExit(f"Tâches inconnues: {', '.join(invalid)}")
|
||||
return selected
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def print_led_dashboard(system) -> None:
|
||||
print("")
|
||||
print("=== Etat du système ===")
|
||||
for item in collect_status(system):
|
||||
led = "\033[32m●\033[0m" if item.ok else "\033[31m●\033[0m"
|
||||
print(f"{led} [{item.category}] {item.label}: {item.detail}")
|
||||
|
||||
|
||||
def print_summary(results, run_log_path, system) -> None:
|
||||
ok_count = sum(1 for result in results if result.success)
|
||||
ko_count = len(results) - ok_count
|
||||
print("")
|
||||
print("=== Résumé ===")
|
||||
for result in results:
|
||||
status = "OK" if result.success else "ECHEC"
|
||||
suffix = f" | erreur: {result.error}" if result.error else ""
|
||||
print(f"- {status} | {result.label} | {result.duration_seconds:.1f}s{suffix}")
|
||||
for detail in result.details:
|
||||
print(f" {detail}")
|
||||
print_led_dashboard(system)
|
||||
print(f"Logs d'exécution: {run_log_path}")
|
||||
print(f"Total OK={ok_count} / ECHEC={ko_count}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
paths = build_paths()
|
||||
ensure_app_dirs(paths)
|
||||
logger = setup_logging(paths.app_log_file)
|
||||
system = detect_system()
|
||||
|
||||
tasks = task_catalog()
|
||||
task_by_key = {task.key: task for task in tasks}
|
||||
store = ScenarioStore(paths.scenario_file, builtin_scenarios())
|
||||
|
||||
if args.list_scenarios:
|
||||
for scenario in store.list_all():
|
||||
print(f"{scenario.name}: {scenario.description}")
|
||||
return 0
|
||||
|
||||
selected_keys = resolve_task_selection(args, store, set(task_by_key))
|
||||
interactive_mode = not args.run
|
||||
active_scenario_name = args.scenario
|
||||
menu_message: str | None = None
|
||||
|
||||
while True:
|
||||
if interactive_mode:
|
||||
tui = SecureCheckTUI(
|
||||
system,
|
||||
tasks,
|
||||
store,
|
||||
status_provider=lambda: collect_status(system),
|
||||
initial_selected=set(selected_keys) if selected_keys else None,
|
||||
initial_scenario_name=active_scenario_name,
|
||||
initial_message=menu_message,
|
||||
)
|
||||
selection = tui.run()
|
||||
if selection is None:
|
||||
return 0
|
||||
selected_keys = selection.task_keys
|
||||
active_scenario_name = selection.scenario_name
|
||||
|
||||
if not selected_keys:
|
||||
if interactive_mode:
|
||||
menu_message = "Aucune tâche sélectionnée."
|
||||
continue
|
||||
print("Aucune tâche sélectionnée.")
|
||||
return 1
|
||||
|
||||
selected_tasks = [task_by_key[key] for key in selected_keys]
|
||||
context = ExecutionContext(paths=paths, system=system, logger=logger, dry_run=args.dry_run)
|
||||
|
||||
run_log_path = paths.log_dir / f"run-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
|
||||
run_handler = attach_run_handler(logger, run_log_path)
|
||||
try:
|
||||
results = execute_tasks(context, selected_tasks)
|
||||
finally:
|
||||
logger.removeHandler(run_handler)
|
||||
run_handler.close()
|
||||
|
||||
if interactive_mode:
|
||||
status_items = collect_status(system)
|
||||
RunSummaryTUI(results, status_items, str(run_log_path)).run()
|
||||
ok_count = sum(1 for result in results if result.success)
|
||||
ko_count = len(results) - ok_count
|
||||
menu_message = f"Dernière exécution: {ok_count} OK / {ko_count} ECHEC. Sélection prête pour une nouvelle action."
|
||||
continue
|
||||
|
||||
print_summary(results, run_log_path, system)
|
||||
return 0 if all(result.success for result in results) else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,401 @@
|
||||
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 .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
|
||||
|
||||
def run(self) -> None:
|
||||
curses.wrapper(self._main)
|
||||
|
||||
def _main(self, stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
stdscr.timeout(5000)
|
||||
_setup_colors()
|
||||
while True:
|
||||
self._draw(stdscr)
|
||||
key = stdscr.getch()
|
||||
if key == -1 or key in (ord("q"), 27, 10, 13, ord("m"), ord(" ")):
|
||||
return
|
||||
|
||||
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
|
||||
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Résumé d'exécution")
|
||||
stdscr.addnstr(1, 3, f"OK: {ok_count} | ECHEC: {ko_count} | Retour menu auto dans 5s", width - 6, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|
||||
row = 3
|
||||
for result in self.results:
|
||||
if row >= height - 6:
|
||||
break
|
||||
color = curses.color_pair(Palette.SUCCESS if result.success else Palette.ERROR)
|
||||
status = "OK" if result.success else "ECHEC"
|
||||
line = f"{status:<5} {result.label} ({result.duration_seconds:.1f}s)"
|
||||
stdscr.addnstr(row, 3, line, width - 6, color | curses.A_BOLD)
|
||||
row += 1
|
||||
for detail in result.details[:2]:
|
||||
if row >= height - 6:
|
||||
break
|
||||
stdscr.addnstr(row, 6, f"- {detail}", width - 9)
|
||||
row += 1
|
||||
if result.error and row < height - 6:
|
||||
stdscr.addnstr(row, 6, f"- {result.error}", width - 9, curses.color_pair(Palette.ERROR))
|
||||
row += 1
|
||||
row += 1
|
||||
stdscr.addnstr(row, 3, "Etat synthétique:", width - 6, curses.color_pair(Palette.CATEGORY) | curses.A_BOLD)
|
||||
row += 1
|
||||
for item in self.status_items[: max(0, height - row - 2)]:
|
||||
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR)
|
||||
stdscr.addnstr(row, 3, "●", 1, color | curses.A_BOLD)
|
||||
stdscr.addnstr(row, 5, f"[{item.category}] {item.label}: {item.detail}", width - 8)
|
||||
row += 1
|
||||
stdscr.addnstr(height - 2, 3, f"Log: {self.run_log_path}", width - 6, curses.color_pair(Palette.MUTED))
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.resources import files
|
||||
|
||||
|
||||
def asset_path(name: str) -> str:
|
||||
return str(files("securecheck").joinpath("assets", name))
|
||||
|
||||
|
||||
def banner_text() -> str:
|
||||
return files("securecheck").joinpath("assets", "banner.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def asset_text(name: str) -> str:
|
||||
return files("securecheck").joinpath("assets", name).read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,6 @@
|
||||
_____ _____ _ _
|
||||
/ ____| / ____| | | |
|
||||
| (___ ___ ___ _ _ _ __ ___| | | |__ ___ ___| | __
|
||||
\___ \ / _ \/ __| | | | '__/ _ \ | | '_ \ / _ \/ __| |/ /
|
||||
____) | __/ (__| |_| | | | __/ |____| | | | __/ (__| <
|
||||
|_____/ \___|\___|\__,_|_| \___|\_____|_| |_|\___|\___|_|\_\
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="32" y1="28" x2="224" y2="228" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F172A"/>
|
||||
<stop offset="1" stop-color="#1E293B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shield" x1="128" y1="48" x2="128" y2="208" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DFF8F2"/>
|
||||
<stop offset="1" stop-color="#A7F3D0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="72" y1="76" x2="188" y2="188" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2DD4BF"/>
|
||||
<stop offset="1" stop-color="#14B8A6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#bg)"/>
|
||||
<rect x="28" y="28" width="200" height="200" rx="40" stroke="#334155" stroke-width="2"/>
|
||||
|
||||
<path d="M128 48L184 70V116C184 154 162 189 128 208C94 189 72 154 72 116V70L128 48Z" fill="url(#shield)"/>
|
||||
<path d="M128 58L176 76V114C176 148 157 179 128 196C99 179 80 148 80 114V76L128 58Z" fill="url(#accent)"/>
|
||||
|
||||
<rect x="92" y="92" width="72" height="52" rx="12" fill="#0F172A"/>
|
||||
<path d="M104 107L117 118L104 129" stroke="#F8FAFC" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M124 128H150" stroke="#F59E0B" stroke-width="8" stroke-linecap="round"/>
|
||||
|
||||
<path d="M97 154L117 174L160 131" stroke="#F8FAFC" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<path d="M184 92C194 103 200 117 200 132" stroke="#67E8F9" stroke-width="6" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M56 132C56 117 62 103 72 92" stroke="#67E8F9" stroke-width="6" stroke-linecap="round" opacity="0.35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import Scenario, TaskDefinition
|
||||
from .tasks import (
|
||||
automatic_updates,
|
||||
bind,
|
||||
docker_setup,
|
||||
firewall_setup,
|
||||
log_rotation,
|
||||
lynis_audit,
|
||||
rootkit_check,
|
||||
system_update,
|
||||
utilities_setup,
|
||||
zram_setup,
|
||||
zsh_setup,
|
||||
)
|
||||
|
||||
|
||||
def task_catalog() -> list[TaskDefinition]:
|
||||
base = [
|
||||
TaskDefinition(
|
||||
key="system_update",
|
||||
label="Mise à jour système",
|
||||
description="Met à jour le système et nettoie les paquets obsolètes.",
|
||||
category="Maintenance",
|
||||
handler=lambda context: None, # replaced by bind()
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="automatic_updates",
|
||||
label="Mises à jour automatiques",
|
||||
description="Configure unattended-upgrades ou dnf-automatic.",
|
||||
category="Maintenance",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="lynis_audit",
|
||||
label="Audit Lynis",
|
||||
description="Lance un audit sécurité automatisé avec Lynis.",
|
||||
category="Sécurité",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="rootkit_check",
|
||||
label="Vérification rootkits",
|
||||
description="Exécute rkhunter et chkrootkit.",
|
||||
category="Sécurité",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="log_rotation",
|
||||
label="Rotation des logs",
|
||||
description="Installe et configure logrotate pour SecureCheck.",
|
||||
category="Maintenance",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="zsh_setup",
|
||||
label="Installation et configuration zsh",
|
||||
description="Installe zsh et applique une configuration utilisateur propre.",
|
||||
category="Poste",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="utilities_setup",
|
||||
label="Utilitaires pratiques",
|
||||
description="Installe les outils usuels de maintenance et sécurité.",
|
||||
category="Poste",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="zram_setup",
|
||||
label="zram auto-configuré",
|
||||
description="Déploie un service zram dimensionné automatiquement.",
|
||||
category="Performance",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="firewall_setup",
|
||||
label="Vérification / autoconfig du firewall",
|
||||
description="Active et sécurise UFW ou firewalld.",
|
||||
category="Sécurité",
|
||||
handler=lambda context: None,
|
||||
default_selected=True,
|
||||
),
|
||||
TaskDefinition(
|
||||
key="docker_setup",
|
||||
label="Installation / check Docker",
|
||||
description="Installe Docker et configure la rotation de ses logs.",
|
||||
category="Services",
|
||||
handler=lambda context: None,
|
||||
default_selected=False,
|
||||
),
|
||||
]
|
||||
|
||||
handlers = {
|
||||
"system_update": system_update,
|
||||
"automatic_updates": automatic_updates,
|
||||
"lynis_audit": lynis_audit,
|
||||
"rootkit_check": rootkit_check,
|
||||
"log_rotation": log_rotation,
|
||||
"zsh_setup": zsh_setup,
|
||||
"utilities_setup": utilities_setup,
|
||||
"zram_setup": zram_setup,
|
||||
"firewall_setup": firewall_setup,
|
||||
"docker_setup": docker_setup,
|
||||
}
|
||||
return [bind(task, handlers[task.key]) for task in base]
|
||||
|
||||
|
||||
def builtin_scenarios() -> list[Scenario]:
|
||||
return [
|
||||
Scenario(
|
||||
name="baseline_workstation",
|
||||
description="Socle poste Linux durci et outillé.",
|
||||
task_keys=[
|
||||
"system_update",
|
||||
"automatic_updates",
|
||||
"log_rotation",
|
||||
"zsh_setup",
|
||||
"utilities_setup",
|
||||
"zram_setup",
|
||||
"firewall_setup",
|
||||
],
|
||||
builtin=True,
|
||||
),
|
||||
Scenario(
|
||||
name="security_audit",
|
||||
description="Audit et vérifications de sécurité.",
|
||||
task_keys=[
|
||||
"system_update",
|
||||
"lynis_audit",
|
||||
"rootkit_check",
|
||||
"firewall_setup",
|
||||
"log_rotation",
|
||||
],
|
||||
builtin=True,
|
||||
),
|
||||
Scenario(
|
||||
name="docker_host",
|
||||
description="Socle serveur avec Docker et pare-feu.",
|
||||
task_keys=[
|
||||
"system_update",
|
||||
"automatic_updates",
|
||||
"firewall_setup",
|
||||
"docker_setup",
|
||||
"log_rotation",
|
||||
],
|
||||
builtin=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppPaths:
|
||||
config_dir: Path
|
||||
state_dir: Path
|
||||
log_dir: Path
|
||||
report_dir: Path
|
||||
scenario_file: Path
|
||||
app_log_file: Path
|
||||
|
||||
|
||||
def _invoking_user() -> tuple[str, Path]:
|
||||
sudo_user = os.environ.get("SUDO_USER")
|
||||
if sudo_user:
|
||||
user_info = pwd.getpwnam(sudo_user)
|
||||
return sudo_user, Path(user_info.pw_dir)
|
||||
|
||||
user = os.environ.get("USER", "root")
|
||||
home = Path.home()
|
||||
return user, home
|
||||
|
||||
|
||||
def build_paths() -> AppPaths:
|
||||
_, user_home = _invoking_user()
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME", user_home / ".config"))
|
||||
state_home = Path(os.environ.get("XDG_STATE_HOME", user_home / ".local" / "state"))
|
||||
|
||||
config_dir = _select_writable_dir(
|
||||
[
|
||||
config_home / "securecheck",
|
||||
Path.cwd() / ".securecheck-runtime" / "config",
|
||||
Path(tempfile.gettempdir()) / "securecheck" / "config",
|
||||
]
|
||||
)
|
||||
state_dir = _select_writable_dir(
|
||||
[
|
||||
state_home / "securecheck",
|
||||
Path.cwd() / ".securecheck-runtime" / "state",
|
||||
Path(tempfile.gettempdir()) / "securecheck" / "state",
|
||||
]
|
||||
)
|
||||
|
||||
if os.geteuid() == 0 and _is_path_writable(Path("/var/log")):
|
||||
log_dir = Path("/var/log/securecheck")
|
||||
else:
|
||||
log_dir = _select_writable_dir(
|
||||
[
|
||||
state_dir / "logs",
|
||||
Path.cwd() / ".securecheck-runtime" / "logs",
|
||||
Path(tempfile.gettempdir()) / "securecheck" / "logs",
|
||||
]
|
||||
)
|
||||
|
||||
report_dir = log_dir / "reports"
|
||||
scenario_file = config_dir / "scenarios.json"
|
||||
app_log_file = log_dir / "securecheck.log"
|
||||
return AppPaths(
|
||||
config_dir=config_dir,
|
||||
state_dir=state_dir,
|
||||
log_dir=log_dir,
|
||||
report_dir=report_dir,
|
||||
scenario_file=scenario_file,
|
||||
app_log_file=app_log_file,
|
||||
)
|
||||
|
||||
|
||||
def ensure_app_dirs(paths: AppPaths) -> None:
|
||||
for directory in (paths.config_dir, paths.state_dir, paths.log_dir, paths.report_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _is_path_writable(path: Path) -> bool:
|
||||
target = path if path.exists() else path.parent
|
||||
return os.access(target, os.W_OK)
|
||||
|
||||
|
||||
def _select_writable_dir(candidates: list[Path]) -> Path:
|
||||
for candidate in candidates:
|
||||
try:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
probe = candidate / ".write-test"
|
||||
with probe.open("w", encoding="utf-8") as handle:
|
||||
handle.write("ok")
|
||||
probe.unlink()
|
||||
return candidate
|
||||
except OSError:
|
||||
continue
|
||||
raise OSError("Aucun emplacement inscriptible disponible pour SecureCheck")
|
||||
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import AppPaths
|
||||
from .models import TaskDefinition, TaskResult
|
||||
from .system_info import SystemInfo
|
||||
|
||||
|
||||
class SecureCheckError(RuntimeError):
|
||||
"""Raised when a task cannot be completed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
command: list[str]
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageOperation:
|
||||
requested: list[str]
|
||||
installed: list[str]
|
||||
already_present: list[str]
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.installed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
paths: AppPaths
|
||||
system: SystemInfo
|
||||
logger: logging.Logger
|
||||
dry_run: bool = False
|
||||
assume_yes: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.runner = CommandRunner(self)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
print(message)
|
||||
self.logger.info(message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
print(f"WARNING: {message}")
|
||||
self.logger.warning(message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
print(f"ERROR: {message}")
|
||||
self.logger.error(message)
|
||||
|
||||
def make_result(
|
||||
self,
|
||||
task: TaskDefinition,
|
||||
*,
|
||||
success: bool,
|
||||
changed: bool,
|
||||
started_at: datetime,
|
||||
details: list[str] | None = None,
|
||||
error: str | None = None,
|
||||
) -> TaskResult:
|
||||
return TaskResult(
|
||||
key=task.key,
|
||||
label=task.label,
|
||||
success=success,
|
||||
changed=changed,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(),
|
||||
details=details or [],
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
def __init__(self, context: ExecutionContext) -> None:
|
||||
self.context = context
|
||||
self._package_index_updated = False
|
||||
|
||||
def command_exists(self, command: str) -> bool:
|
||||
return shutil.which(command) is not None
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
*,
|
||||
requires_root: bool = False,
|
||||
run_as_user: str | None = None,
|
||||
check: bool = True,
|
||||
capture_output: bool = True,
|
||||
env: dict[str, str] | None = None,
|
||||
input_text: str | None = None,
|
||||
) -> CommandResult:
|
||||
final_command = list(command)
|
||||
if run_as_user and os.geteuid() == 0 and run_as_user != "root":
|
||||
final_command = ["sudo", "-u", run_as_user] + final_command
|
||||
elif requires_root and os.geteuid() != 0:
|
||||
final_command = ["sudo"] + final_command
|
||||
|
||||
rendered = " ".join(final_command)
|
||||
self.context.logger.info("Commande: %s", rendered)
|
||||
if self.context.dry_run:
|
||||
self.context.info(f"[dry-run] {rendered}")
|
||||
return CommandResult(command=final_command, returncode=0, stdout="", stderr="")
|
||||
|
||||
completed = subprocess.run(
|
||||
final_command,
|
||||
text=True,
|
||||
capture_output=capture_output,
|
||||
env=env,
|
||||
input=input_text,
|
||||
check=False,
|
||||
)
|
||||
stdout = completed.stdout or ""
|
||||
stderr = completed.stderr or ""
|
||||
if stdout.strip():
|
||||
self.context.logger.info("stdout:\n%s", stdout.rstrip())
|
||||
if stderr.strip():
|
||||
self.context.logger.warning("stderr:\n%s", stderr.rstrip())
|
||||
|
||||
if check and completed.returncode != 0:
|
||||
raise SecureCheckError(f"Echec de la commande ({completed.returncode}): {rendered}")
|
||||
|
||||
return CommandResult(
|
||||
command=final_command,
|
||||
returncode=completed.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
def update_package_index(self) -> None:
|
||||
if self._package_index_updated:
|
||||
return
|
||||
|
||||
manager = self.context.system.package_manager
|
||||
if manager == "apt-get":
|
||||
self.run(["apt-get", "update"], requires_root=True)
|
||||
elif manager in {"dnf", "yum"}:
|
||||
self.run([manager, "makecache"], requires_root=True)
|
||||
elif manager == "pacman":
|
||||
self.run(["pacman", "-Sy"], requires_root=True)
|
||||
else:
|
||||
raise SecureCheckError("Gestionnaire de paquets non supporté")
|
||||
self._package_index_updated = True
|
||||
|
||||
def is_package_installed(self, package: str) -> bool:
|
||||
manager = self.context.system.package_manager
|
||||
if manager == "apt-get":
|
||||
return subprocess.run(["dpkg", "-s", package], capture_output=True).returncode == 0
|
||||
if manager in {"dnf", "yum"}:
|
||||
return subprocess.run(["rpm", "-q", package], capture_output=True).returncode == 0
|
||||
if manager == "pacman":
|
||||
return subprocess.run(["pacman", "-Q", package], capture_output=True).returncode == 0
|
||||
return False
|
||||
|
||||
def package_available(self, package: str) -> bool:
|
||||
manager = self.context.system.package_manager
|
||||
if manager == "apt-get":
|
||||
self.update_package_index()
|
||||
return subprocess.run(["apt-cache", "show", package], capture_output=True).returncode == 0
|
||||
if manager in {"dnf", "yum"}:
|
||||
return subprocess.run([manager, "info", package], capture_output=True).returncode == 0
|
||||
if manager == "pacman":
|
||||
return subprocess.run(["pacman", "-Si", package], capture_output=True).returncode == 0
|
||||
return False
|
||||
|
||||
def ensure_packages(self, packages: list[str]) -> bool:
|
||||
return self.ensure_packages_report(packages).changed
|
||||
|
||||
def ensure_packages_report(self, packages: list[str]) -> PackageOperation:
|
||||
if not packages:
|
||||
return PackageOperation(requested=[], installed=[], already_present=[])
|
||||
|
||||
manager = self.context.system.package_manager
|
||||
self.update_package_index()
|
||||
already_present = [package for package in packages if self.is_package_installed(package)]
|
||||
missing = [package for package in packages if package not in already_present]
|
||||
|
||||
if not missing:
|
||||
return PackageOperation(requested=packages, installed=[], already_present=already_present)
|
||||
|
||||
if manager == "apt-get":
|
||||
command = ["apt-get", "install", "-y", *missing]
|
||||
elif manager in {"dnf", "yum"}:
|
||||
command = [manager, "install", "-y", *missing]
|
||||
elif manager == "pacman":
|
||||
command = ["pacman", "-S", "--noconfirm", *missing]
|
||||
else:
|
||||
raise SecureCheckError("Installation de paquets non supportée")
|
||||
|
||||
self.run(command, requires_root=True)
|
||||
return PackageOperation(requested=packages, installed=missing, already_present=already_present)
|
||||
|
||||
def upgrade_system(self) -> None:
|
||||
manager = self.context.system.package_manager
|
||||
if manager == "apt-get":
|
||||
self.run(["apt-get", "dist-upgrade", "-y"], requires_root=True)
|
||||
self.run(["apt-get", "autoremove", "-y"], requires_root=True)
|
||||
self.run(["apt-get", "autoclean"], requires_root=True)
|
||||
elif manager in {"dnf", "yum"}:
|
||||
self.run([manager, "upgrade", "-y", "--refresh"], requires_root=True)
|
||||
elif manager == "pacman":
|
||||
self.run(["pacman", "-Syu", "--noconfirm"], requires_root=True)
|
||||
else:
|
||||
raise SecureCheckError("Mise à jour système non supportée")
|
||||
|
||||
def ensure_directory(self, path: Path, mode: int = 0o755, *, requires_root: bool = False) -> None:
|
||||
if self.context.dry_run:
|
||||
self.context.info(f"[dry-run] mkdir -p {path}")
|
||||
return
|
||||
if requires_root and os.geteuid() != 0:
|
||||
self.run(["install", "-d", "-m", f"{mode:o}", str(path)], requires_root=True)
|
||||
return
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
path.chmod(mode)
|
||||
if requires_root and os.geteuid() == 0:
|
||||
os.chown(path, 0, 0)
|
||||
|
||||
def write_text_file(
|
||||
self,
|
||||
path: Path,
|
||||
content: str,
|
||||
*,
|
||||
mode: int = 0o644,
|
||||
requires_root: bool = False,
|
||||
owner_uid: int | None = None,
|
||||
owner_gid: int | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
current = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
except OSError:
|
||||
current = None
|
||||
if current == content:
|
||||
return False
|
||||
|
||||
self.context.logger.info("Ecriture du fichier %s", path)
|
||||
if self.context.dry_run:
|
||||
self.context.info(f"[dry-run] write {path}")
|
||||
return True
|
||||
|
||||
if requires_root and os.geteuid() != 0:
|
||||
self._write_text_file_as_root(path, content, mode=mode, owner_uid=owner_uid, owner_gid=owner_gid)
|
||||
return True
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
os.chmod(path, mode)
|
||||
|
||||
if requires_root and os.geteuid() == 0:
|
||||
os.chown(path, 0, 0)
|
||||
elif owner_uid is not None and owner_gid is not None and os.geteuid() == 0:
|
||||
os.chown(path, owner_uid, owner_gid)
|
||||
return True
|
||||
|
||||
def _write_text_file_as_root(
|
||||
self,
|
||||
path: Path,
|
||||
content: str,
|
||||
*,
|
||||
mode: int,
|
||||
owner_uid: int | None,
|
||||
owner_gid: int | None,
|
||||
) -> None:
|
||||
self.ensure_directory(path.parent, requires_root=True)
|
||||
tmp_dir = self.context.paths.state_dir
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=tmp_dir) as handle:
|
||||
handle.write(content)
|
||||
tmp_path = Path(handle.name)
|
||||
self.run(["install", "-m", f"{mode:o}", str(tmp_path), str(path)], requires_root=True)
|
||||
if owner_uid is not None and owner_gid is not None:
|
||||
self.run(["chown", f"{owner_uid}:{owner_gid}", str(path)], requires_root=True)
|
||||
finally:
|
||||
if tmp_path and tmp_path.exists():
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def ensure_user_shell(self, shell_path: str) -> bool:
|
||||
passwd_file = Path("/etc/passwd").read_text(encoding="utf-8")
|
||||
for line in passwd_file.splitlines():
|
||||
if line.startswith(f"{self.context.system.target_user}:"):
|
||||
current_shell = line.rsplit(":", 1)[-1]
|
||||
if current_shell == shell_path:
|
||||
return False
|
||||
break
|
||||
|
||||
self.run(["chsh", "-s", shell_path, self.context.system.target_user], requires_root=os.geteuid() == 0)
|
||||
return True
|
||||
|
||||
def enable_service(self, service: str) -> None:
|
||||
self.run(["systemctl", "enable", "--now", service], requires_root=True)
|
||||
|
||||
def restart_service(self, service: str) -> None:
|
||||
self.run(["systemctl", "restart", service], requires_root=True)
|
||||
|
||||
def service_is_active(self, service: str) -> bool:
|
||||
result = self.run(["systemctl", "is-active", service], requires_root=True, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def read_memory_mb(self) -> int:
|
||||
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("MemTotal:"):
|
||||
parts = line.split()
|
||||
return int(parts[1]) // 1024
|
||||
return 1024
|
||||
|
||||
def write_json_file(self, path: Path, payload: dict, *, mode: int = 0o644, requires_root: bool = False) -> bool:
|
||||
content = json.dumps(payload, indent=2) + "\n"
|
||||
return self.write_text_file(path, content, mode=mode, requires_root=requires_root)
|
||||
|
||||
def ensure_executable(self, path: Path, *, requires_root: bool = False) -> None:
|
||||
if self.context.dry_run:
|
||||
return
|
||||
current = stat.S_IMODE(path.stat().st_mode)
|
||||
path.chmod(current | 0o111)
|
||||
if requires_root and os.geteuid() == 0:
|
||||
os.chown(path, 0, 0)
|
||||
|
||||
def download_text(self, url: str, *, timeout: int = 20) -> str:
|
||||
self.context.logger.info("Téléchargement: %s", url)
|
||||
if self.context.dry_run:
|
||||
self.context.info(f"[dry-run] download {url}")
|
||||
return ""
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
return response.read().decode("utf-8")
|
||||
|
||||
|
||||
def execute_tasks(context: ExecutionContext, tasks: list[TaskDefinition]) -> list[TaskResult]:
|
||||
results: list[TaskResult] = []
|
||||
total = len(tasks)
|
||||
|
||||
for index, task in enumerate(tasks, start=1):
|
||||
started_at = datetime.now()
|
||||
context.info(f"[{index}/{total}] {task.label}")
|
||||
try:
|
||||
result = task.handler(context)
|
||||
results.append(result)
|
||||
status = "OK" if result.success else "ECHEC"
|
||||
context.info(f" -> {status} ({result.duration_seconds:.1f}s)")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
context.logger.exception("Task failed: %s", task.key)
|
||||
results.append(
|
||||
context.make_result(
|
||||
task,
|
||||
success=False,
|
||||
changed=False,
|
||||
started_at=started_at,
|
||||
details=[],
|
||||
error=str(exc),
|
||||
)
|
||||
)
|
||||
context.error(f" -> ECHEC: {exc}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def setup_logging(log_file: Path) -> logging.Logger:
|
||||
logger = logging.getLogger("securecheck")
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
return logger
|
||||
|
||||
|
||||
def attach_run_handler(logger: logging.Logger, run_log_file: Path) -> RotatingFileHandler:
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = RotatingFileHandler(run_log_file, maxBytes=2_000_000, backupCount=2, encoding="utf-8")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return handler
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .executor import ExecutionContext
|
||||
|
||||
|
||||
TaskHandler = Callable[["ExecutionContext"], "TaskResult"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinition:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
category: str
|
||||
handler: TaskHandler
|
||||
requires_root: bool = True
|
||||
default_selected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskResult:
|
||||
key: str
|
||||
label: str
|
||||
success: bool
|
||||
changed: bool
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
details: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return (self.finished_at - self.started_at).total_seconds()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
name: str
|
||||
task_keys: list[str]
|
||||
description: str = ""
|
||||
builtin: bool = False
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .system_info import SystemInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusItem:
|
||||
category: str
|
||||
label: str
|
||||
ok: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _command_exists(command: str) -> bool:
|
||||
return shutil.which(command) is not None
|
||||
|
||||
|
||||
def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, text=True, capture_output=True, check=False)
|
||||
|
||||
|
||||
def _service_active(service: str) -> bool:
|
||||
if not _command_exists("systemctl"):
|
||||
return False
|
||||
return _run(["systemctl", "is-active", service]).returncode == 0
|
||||
|
||||
|
||||
def _binary_status(category: str, label: str, command: str) -> StatusItem:
|
||||
exists = _command_exists(command)
|
||||
return StatusItem(category=category, label=label, ok=exists, detail="installé" if exists else "absent")
|
||||
|
||||
|
||||
def collect_status(system: SystemInfo) -> list[StatusItem]:
|
||||
maintenance: list[StatusItem] = []
|
||||
security: list[StatusItem] = []
|
||||
services: list[StatusItem] = []
|
||||
performance: list[StatusItem] = []
|
||||
poste: list[StatusItem] = []
|
||||
|
||||
p10k_path = system.target_home / ".p10k.zsh"
|
||||
poste.append(StatusItem("Poste", "Config p10k", p10k_path.exists(), str(p10k_path if p10k_path.exists() else "absente")))
|
||||
|
||||
unattended_ok = False
|
||||
unattended_detail = "non configuré"
|
||||
if system.package_manager == "apt-get":
|
||||
unattended_ok = _service_active("unattended-upgrades.service") and Path("/etc/apt/apt.conf.d/20auto-upgrades").exists()
|
||||
unattended_detail = "service actif" if unattended_ok else "service inactif"
|
||||
elif system.package_manager in {"dnf", "yum"}:
|
||||
unattended_ok = _service_active("dnf-automatic.timer")
|
||||
unattended_detail = "timer actif" if unattended_ok else "timer inactif"
|
||||
maintenance.append(StatusItem("Maintenance", "MAJ auto", unattended_ok, unattended_detail))
|
||||
|
||||
logrotate_ok = Path("/etc/logrotate.d/securecheck").exists()
|
||||
maintenance.append(StatusItem("Maintenance", "Rotation logs", logrotate_ok, "config présente" if logrotate_ok else "config absente"))
|
||||
|
||||
zram_ok = _service_active("securecheck-zram.service") or ("zram" in _run(["swapon", "--show"]).stdout if _command_exists("swapon") else False)
|
||||
performance.append(StatusItem("Performance", "zram", zram_ok, "actif" if zram_ok else "inactif"))
|
||||
|
||||
if _command_exists("ufw"):
|
||||
ufw_result = _run(["ufw", "status"])
|
||||
firewall_ok = "Status: active" in ufw_result.stdout
|
||||
firewall_detail = "ufw actif" if firewall_ok else "ufw inactif"
|
||||
elif _command_exists("firewall-cmd"):
|
||||
firewall_ok = _service_active("firewalld.service")
|
||||
firewall_detail = "firewalld actif" if firewall_ok else "firewalld inactif"
|
||||
else:
|
||||
firewall_ok = False
|
||||
firewall_detail = "pare-feu absent"
|
||||
security.append(StatusItem("Sécurité", "Firewall", firewall_ok, firewall_detail))
|
||||
|
||||
security.append(_binary_status("Sécurité", "Lynis", "lynis"))
|
||||
security.append(_binary_status("Sécurité", "rkhunter", "rkhunter"))
|
||||
security.append(_binary_status("Sécurité", "chkrootkit", "chkrootkit"))
|
||||
|
||||
docker_active = _command_exists("docker") and _service_active("docker.service")
|
||||
fail2ban_active = _command_exists("fail2ban-client") and _service_active("fail2ban.service")
|
||||
services.append(StatusItem("Services", "Service Docker", docker_active, "actif" if docker_active else "inactif"))
|
||||
services.append(StatusItem("Services", "Service fail2ban", fail2ban_active, "actif" if fail2ban_active else "inactif"))
|
||||
services.append(_binary_status("Services", "Docker", "docker"))
|
||||
services.append(_binary_status("Services", "fail2ban", "fail2ban-client"))
|
||||
|
||||
poste.extend([
|
||||
_binary_status("Poste", "zsh", "zsh"),
|
||||
_binary_status("Poste", "git", "git"),
|
||||
_binary_status("Poste", "curl", "curl"),
|
||||
_binary_status("Poste", "ncdu", "ncdu"),
|
||||
_binary_status("Poste", "needrestart", "needrestart"),
|
||||
_binary_status("Poste", "htop", "htop"),
|
||||
_binary_status("Poste", "nmon", "nmon"),
|
||||
_binary_status("Poste", "duf", "duf"),
|
||||
_binary_status("Poste", "net-tools", "ifconfig"),
|
||||
])
|
||||
|
||||
ordered = maintenance + security + performance + services + poste
|
||||
return ordered
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Scenario
|
||||
|
||||
|
||||
class ScenarioStore:
|
||||
def __init__(self, scenario_file: Path, builtin_scenarios: list[Scenario]) -> None:
|
||||
self._scenario_file = scenario_file
|
||||
self._builtin = {scenario.name: scenario for scenario in builtin_scenarios}
|
||||
|
||||
def _load_user_scenarios(self) -> dict[str, Scenario]:
|
||||
if not self._scenario_file.exists():
|
||||
return {}
|
||||
|
||||
raw = json.loads(self._scenario_file.read_text(encoding="utf-8"))
|
||||
scenarios: dict[str, Scenario] = {}
|
||||
for item in raw.get("scenarios", []):
|
||||
scenario = Scenario(
|
||||
name=item["name"],
|
||||
description=item.get("description", ""),
|
||||
task_keys=list(item.get("task_keys", [])),
|
||||
builtin=False,
|
||||
)
|
||||
scenarios[scenario.name] = scenario
|
||||
return scenarios
|
||||
|
||||
def list_all(self) -> list[Scenario]:
|
||||
merged = dict(self._builtin)
|
||||
merged.update(self._load_user_scenarios())
|
||||
return sorted(merged.values(), key=lambda scenario: (scenario.builtin is False, scenario.name.lower()))
|
||||
|
||||
def get(self, name: str) -> Scenario | None:
|
||||
return {scenario.name: scenario for scenario in self.list_all()}.get(name)
|
||||
|
||||
def save(self, scenario: Scenario) -> None:
|
||||
scenarios = self._load_user_scenarios()
|
||||
scenarios[scenario.name] = Scenario(
|
||||
name=scenario.name,
|
||||
description=scenario.description,
|
||||
task_keys=scenario.task_keys,
|
||||
builtin=False,
|
||||
)
|
||||
payload = {
|
||||
"scenarios": [
|
||||
{
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
"task_keys": item.task_keys,
|
||||
}
|
||||
for item in sorted(scenarios.values(), key=lambda s: s.name.lower())
|
||||
]
|
||||
}
|
||||
self._scenario_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
scenarios = self._load_user_scenarios()
|
||||
if name not in scenarios:
|
||||
return False
|
||||
del scenarios[name]
|
||||
payload = {
|
||||
"scenarios": [
|
||||
{
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
"task_keys": item.task_keys,
|
||||
}
|
||||
for item in sorted(scenarios.values(), key=lambda s: s.name.lower())
|
||||
]
|
||||
}
|
||||
self._scenario_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemInfo:
|
||||
distro_id: str
|
||||
pretty_name: str
|
||||
package_manager: str
|
||||
target_user: str
|
||||
target_home: Path
|
||||
target_uid: int
|
||||
target_gid: int
|
||||
|
||||
|
||||
def _read_os_release() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
release_file = Path("/etc/os-release")
|
||||
if not release_file.exists():
|
||||
return values
|
||||
|
||||
for line in release_file.read_text(encoding="utf-8").splitlines():
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key] = value.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def detect_package_manager() -> str:
|
||||
for candidate in ("apt-get", "dnf", "yum", "pacman"):
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return "unknown"
|
||||
|
||||
|
||||
def resolve_target_user() -> tuple[str, Path, int, int]:
|
||||
user_name = os.environ.get("SUDO_USER") or os.environ.get("USER") or "root"
|
||||
user_info = pwd.getpwnam(user_name)
|
||||
return user_name, Path(user_info.pw_dir), user_info.pw_uid, user_info.pw_gid
|
||||
|
||||
|
||||
def detect_system() -> SystemInfo:
|
||||
values = _read_os_release()
|
||||
package_manager = detect_package_manager()
|
||||
user_name, user_home, uid, gid = resolve_target_user()
|
||||
return SystemInfo(
|
||||
distro_id=values.get("ID", "unknown"),
|
||||
pretty_name=values.get("PRETTY_NAME", "Linux"),
|
||||
package_manager=package_manager,
|
||||
target_user=user_name,
|
||||
target_home=user_home,
|
||||
target_uid=uid,
|
||||
target_gid=gid,
|
||||
)
|
||||
@@ -0,0 +1,449 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .assets import asset_text
|
||||
from .executor import ExecutionContext, SecureCheckError
|
||||
from .models import TaskDefinition, TaskResult
|
||||
|
||||
P10K_REMOTE_URL = "https://git.h3campus.fr/Johnny/Install_zsh/raw/branch/main/.p10k.zsh"
|
||||
P10K_THEME_GIT_URL = "https://github.com/romkatv/powerlevel10k.git"
|
||||
|
||||
|
||||
def _result(
|
||||
context: ExecutionContext,
|
||||
task: TaskDefinition,
|
||||
started_at: datetime,
|
||||
*,
|
||||
changed: bool,
|
||||
details: list[str] | None = None,
|
||||
) -> TaskResult:
|
||||
return context.make_result(task, success=True, changed=changed, started_at=started_at, details=details or [])
|
||||
|
||||
|
||||
def _write_report(context: ExecutionContext, name: str, content: str) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
report_path = context.paths.report_dir / f"{timestamp}-{name}.log"
|
||||
context.runner.write_text_file(report_path, content, mode=0o640, requires_root=False)
|
||||
return report_path
|
||||
|
||||
|
||||
def _append_package_details(context: ExecutionContext, details: list[str], report) -> bool:
|
||||
changed = report.changed
|
||||
added_label = "Seraient ajoutés" if context.dry_run else "Ajoutés"
|
||||
if report.already_present:
|
||||
details.append(f"Déjà présents: {', '.join(report.already_present)}")
|
||||
if report.installed:
|
||||
details.append(f"{added_label}: {', '.join(report.installed)}")
|
||||
return changed
|
||||
|
||||
|
||||
def system_update(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
context.runner.update_package_index()
|
||||
context.runner.upgrade_system()
|
||||
details = ["Index des paquets rafraîchi", "Mises à jour système appliquées"]
|
||||
return _result(context, task, started_at, changed=True, details=details)
|
||||
|
||||
|
||||
def automatic_updates(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
manager = context.system.package_manager
|
||||
changed = False
|
||||
details: list[str] = []
|
||||
|
||||
if manager == "apt-get":
|
||||
pkg_report = context.runner.ensure_packages_report(["unattended-upgrades", "apt-listchanges"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
content_20 = """APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Download-Upgradeable-Packages "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
"""
|
||||
content_52 = """Unattended-Upgrade::Automatic-Reboot "false";
|
||||
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
|
||||
"""
|
||||
changed |= context.runner.write_text_file(Path("/etc/apt/apt.conf.d/20auto-upgrades"), content_20, requires_root=True)
|
||||
changed |= context.runner.write_text_file(
|
||||
Path("/etc/apt/apt.conf.d/52securecheck-unattended-upgrades"),
|
||||
content_52,
|
||||
requires_root=True,
|
||||
)
|
||||
context.runner.enable_service("unattended-upgrades.service")
|
||||
details.append("Mises à jour automatiques APT configurées")
|
||||
elif manager in {"dnf", "yum"}:
|
||||
pkg_report = context.runner.ensure_packages_report(["dnf-automatic"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
changed |= context.runner.write_text_file(
|
||||
Path("/etc/dnf/automatic.conf"),
|
||||
"""[commands]
|
||||
apply_updates = yes
|
||||
upgrade_type = default
|
||||
|
||||
[emitters]
|
||||
emit_via = stdio
|
||||
system_name = securecheck
|
||||
""",
|
||||
requires_root=True,
|
||||
)
|
||||
context.runner.enable_service("dnf-automatic.timer")
|
||||
details.append("Mises à jour automatiques DNF configurées")
|
||||
else:
|
||||
raise SecureCheckError("Les mises à jour automatiques ne sont pas prises en charge sur ce système")
|
||||
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def lynis_audit(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
details: list[str] = []
|
||||
pkg_report = context.runner.ensure_packages_report(["lynis"])
|
||||
changed = _append_package_details(context, details, pkg_report)
|
||||
result = context.runner.run(["lynis", "audit", "system", "--quick"], requires_root=True, check=False)
|
||||
report_body = "\n".join(
|
||||
[
|
||||
"=== SecureCheck / Lynis ===",
|
||||
f"Return code: {result.returncode}",
|
||||
"",
|
||||
result.stdout,
|
||||
result.stderr,
|
||||
]
|
||||
).strip() + "\n"
|
||||
report_path = _write_report(context, "lynis", report_body)
|
||||
details.append(f"Rapport Lynis: {report_path}")
|
||||
success = result.returncode == 0
|
||||
return context.make_result(task, success=success, changed=changed, started_at=started_at, details=details, error=None if success else "Lynis a remonté une erreur")
|
||||
|
||||
|
||||
def rootkit_check(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
details: list[str] = []
|
||||
pkg_report = context.runner.ensure_packages_report(["rkhunter", "chkrootkit"])
|
||||
changed = _append_package_details(context, details, pkg_report)
|
||||
|
||||
update_result = context.runner.run(["rkhunter", "--update"], requires_root=True, check=False)
|
||||
details.append(f"rkhunter update rc={update_result.returncode}")
|
||||
|
||||
propupd_result = context.runner.run(["rkhunter", "--propupd"], requires_root=True, check=False)
|
||||
details.append(f"rkhunter propupd rc={propupd_result.returncode}")
|
||||
|
||||
rkhunter_result = context.runner.run(
|
||||
["rkhunter", "--check", "--skip-keypress", "--report-warnings-only"],
|
||||
requires_root=True,
|
||||
check=False,
|
||||
)
|
||||
chkrootkit_result = context.runner.run(["chkrootkit", "-q"], requires_root=True, check=False)
|
||||
|
||||
report_payload = {
|
||||
"rkhunter_check_returncode": rkhunter_result.returncode,
|
||||
"chkrootkit_returncode": chkrootkit_result.returncode,
|
||||
"rkhunter_stdout": rkhunter_result.stdout,
|
||||
"rkhunter_stderr": rkhunter_result.stderr,
|
||||
"chkrootkit_stdout": chkrootkit_result.stdout,
|
||||
"chkrootkit_stderr": chkrootkit_result.stderr,
|
||||
}
|
||||
report_path = context.paths.report_dir / f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-rootkit-report.json"
|
||||
context.runner.write_text_file(report_path, json.dumps(report_payload, indent=2) + "\n", mode=0o640, requires_root=False)
|
||||
details.append(f"Rapport rootkits: {report_path}")
|
||||
success = rkhunter_result.returncode == 0 and chkrootkit_result.returncode == 0
|
||||
return context.make_result(task, success=success, changed=changed, started_at=started_at, details=details, error=None if success else "Vérification rootkit incomplète ou avec alertes")
|
||||
|
||||
|
||||
def log_rotation(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
details: list[str] = []
|
||||
pkg_report = context.runner.ensure_packages_report(["logrotate"])
|
||||
changed = _append_package_details(context, details, pkg_report)
|
||||
log_target = "/var/log/securecheck/*.log"
|
||||
report_target = "/var/log/securecheck/reports/*"
|
||||
content = f"""{log_target} {report_target} {{
|
||||
rotate 7
|
||||
daily
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
create 0640 root adm
|
||||
}}
|
||||
"""
|
||||
changed |= context.runner.write_text_file(Path("/etc/logrotate.d/securecheck"), content, requires_root=True)
|
||||
details.append("Rotation des logs SecureCheck configurée")
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def zsh_setup(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
manager = context.system.package_manager
|
||||
details: list[str] = []
|
||||
packages = ["zsh", "git", "curl"]
|
||||
if manager == "apt-get":
|
||||
packages += ["zsh-autosuggestions", "zsh-syntax-highlighting"]
|
||||
for optional in ("zsh-theme-powerlevel10k", "fonts-powerline"):
|
||||
if context.runner.package_available(optional):
|
||||
packages.append(optional)
|
||||
elif manager == "pacman":
|
||||
for optional in ("zsh-autosuggestions", "zsh-syntax-highlighting", "zsh-theme-powerlevel10k"):
|
||||
if context.runner.package_available(optional):
|
||||
packages.append(optional)
|
||||
pkg_report = context.runner.ensure_packages_report(packages)
|
||||
changed = _append_package_details(context, details, pkg_report)
|
||||
|
||||
try:
|
||||
p10k_content = context.runner.download_text(P10K_REMOTE_URL)
|
||||
p10k_source = f"source distante: {P10K_REMOTE_URL}"
|
||||
except Exception: # noqa: BLE001
|
||||
p10k_content = asset_text("p10k.zsh")
|
||||
p10k_source = "source locale embarquée: assets/p10k.zsh"
|
||||
|
||||
if not p10k_content:
|
||||
p10k_content = asset_text("p10k.zsh")
|
||||
p10k_source = "source locale embarquée: assets/p10k.zsh"
|
||||
|
||||
zshrc_path = context.system.target_home / ".zshrc"
|
||||
p10k_path = context.system.target_home / ".p10k.zsh"
|
||||
theme_repo_path = context.system.target_home / ".powerlevel10k"
|
||||
theme_system_paths = [
|
||||
Path("/usr/share/powerlevel10k/powerlevel10k.zsh-theme"),
|
||||
Path("/usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme"),
|
||||
]
|
||||
if not any(path.exists() for path in theme_system_paths):
|
||||
if not theme_repo_path.exists():
|
||||
context.runner.run(
|
||||
["git", "clone", "--depth=1", P10K_THEME_GIT_URL, str(theme_repo_path)],
|
||||
run_as_user=context.system.target_user,
|
||||
)
|
||||
changed = True
|
||||
details.append(f"Theme powerlevel10k cloné dans {theme_repo_path}")
|
||||
else:
|
||||
details.append(f"Theme powerlevel10k déjà présent dans {theme_repo_path}")
|
||||
|
||||
zshrc_content = """# Fichier généré par SecureCheck
|
||||
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
|
||||
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
|
||||
fi
|
||||
|
||||
export HISTFILE="$HOME/.zsh_history"
|
||||
export HISTSIZE=10000
|
||||
export SAVEHIST=10000
|
||||
|
||||
setopt appendhistory
|
||||
setopt histignoredups
|
||||
setopt sharehistory
|
||||
setopt autocd
|
||||
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
|
||||
if [ -f /usr/share/powerlevel10k/powerlevel10k.zsh-theme ]; then
|
||||
source /usr/share/powerlevel10k/powerlevel10k.zsh-theme
|
||||
elif [ -f /usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme ]; then
|
||||
source /usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme
|
||||
elif [ -f "$HOME/.powerlevel10k/powerlevel10k.zsh-theme" ]; then
|
||||
source "$HOME/.powerlevel10k/powerlevel10k.zsh-theme"
|
||||
fi
|
||||
|
||||
if [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then
|
||||
source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
||||
fi
|
||||
|
||||
if [ -f /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]; then
|
||||
source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
||||
fi
|
||||
|
||||
bindkey '^[[A' history-search-backward
|
||||
bindkey '^[[B' history-search-forward
|
||||
|
||||
alias ll='ls -alF'
|
||||
alias la='ls -A'
|
||||
alias l='ls -CF'
|
||||
alias update-system='sudo apt-get update && sudo apt-get dist-upgrade -y'
|
||||
|
||||
[[ -f "$HOME/.p10k.zsh" ]] && source "$HOME/.p10k.zsh"
|
||||
"""
|
||||
changed |= context.runner.write_text_file(
|
||||
p10k_path,
|
||||
p10k_content,
|
||||
mode=0o644,
|
||||
owner_uid=context.system.target_uid,
|
||||
owner_gid=context.system.target_gid,
|
||||
)
|
||||
changed |= context.runner.write_text_file(
|
||||
zshrc_path,
|
||||
zshrc_content,
|
||||
mode=0o644,
|
||||
owner_uid=context.system.target_uid,
|
||||
owner_gid=context.system.target_gid,
|
||||
)
|
||||
|
||||
zsh_path = "/usr/bin/zsh" if Path("/usr/bin/zsh").exists() else "/bin/zsh"
|
||||
if Path(zsh_path).exists():
|
||||
changed |= context.runner.ensure_user_shell(zsh_path)
|
||||
|
||||
details.append(f"Configuration zsh appliquée pour {context.system.target_user}")
|
||||
details.append(f"Fichier p10k copié vers {p10k_path}")
|
||||
details.append(p10k_source)
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def utilities_setup(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
manager = context.system.package_manager
|
||||
if manager == "apt-get":
|
||||
packages = [
|
||||
"ncdu",
|
||||
"needrestart",
|
||||
"git",
|
||||
"curl",
|
||||
"fail2ban",
|
||||
"htop",
|
||||
"nmon",
|
||||
"duf",
|
||||
"net-tools",
|
||||
"tmux",
|
||||
"tree",
|
||||
"vim",
|
||||
"ca-certificates",
|
||||
]
|
||||
elif manager in {"dnf", "yum"}:
|
||||
packages = ["ncdu", "git", "curl", "fail2ban", "htop", "nmon", "duf", "net-tools", "tmux", "tree", "vim-enhanced"]
|
||||
else:
|
||||
packages = ["ncdu", "git", "curl", "htop", "nmon", "duf", "net-tools", "tmux", "tree", "vim"]
|
||||
|
||||
details: list[str] = []
|
||||
pkg_report = context.runner.ensure_packages_report(packages)
|
||||
changed = _append_package_details(context, details, pkg_report)
|
||||
if context.runner.command_exists("systemctl") and context.runner.command_exists("fail2ban-client"):
|
||||
context.runner.enable_service("fail2ban.service")
|
||||
details.append("Utilitaires système et sécurité installés / vérifiés")
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def zram_setup(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
changed = False
|
||||
details: list[str] = []
|
||||
ram_mb = context.runner.read_memory_mb()
|
||||
zram_mb = max(512, ram_mb // 2)
|
||||
|
||||
defaults_content = f"""# Géré par SecureCheck
|
||||
ALGO=zstd
|
||||
PERCENT=50
|
||||
PRIORITY=100
|
||||
ZRAM_SIZE={zram_mb}
|
||||
"""
|
||||
start_script = """#!/bin/sh
|
||||
set -eu
|
||||
modprobe zram || true
|
||||
sleep 1
|
||||
echo zstd > /sys/block/zram0/comp_algorithm
|
||||
echo ${ZRAM_SIZE}M > /sys/block/zram0/disksize
|
||||
mkswap /dev/zram0
|
||||
swapon -p 100 /dev/zram0
|
||||
"""
|
||||
stop_script = """#!/bin/sh
|
||||
swapoff /dev/zram0 2>/dev/null || true
|
||||
echo 1 > /sys/block/zram0/reset 2>/dev/null || true
|
||||
rmmod zram 2>/dev/null || true
|
||||
"""
|
||||
service_content = """[Unit]
|
||||
Description=SecureCheck zram swap
|
||||
After=local-fs.target
|
||||
Before=swap.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
EnvironmentFile=/etc/default/securecheck-zram
|
||||
ExecStart=/usr/local/bin/securecheck-zram-start
|
||||
ExecStop=/usr/local/bin/securecheck-zram-stop
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
changed |= context.runner.write_text_file(Path("/etc/default/securecheck-zram"), defaults_content, requires_root=True)
|
||||
changed |= context.runner.write_text_file(Path("/usr/local/bin/securecheck-zram-start"), start_script, mode=0o755, requires_root=True)
|
||||
changed |= context.runner.write_text_file(Path("/usr/local/bin/securecheck-zram-stop"), stop_script, mode=0o755, requires_root=True)
|
||||
changed |= context.runner.write_text_file(Path("/etc/systemd/system/securecheck-zram.service"), service_content, requires_root=True)
|
||||
context.runner.run(["systemctl", "daemon-reload"], requires_root=True)
|
||||
context.runner.enable_service("securecheck-zram.service")
|
||||
details.append(f"zram configuré à {zram_mb} Mo")
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def firewall_setup(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
manager = context.system.package_manager
|
||||
changed = False
|
||||
details: list[str] = []
|
||||
|
||||
if manager == "apt-get":
|
||||
pkg_report = context.runner.ensure_packages_report(["ufw"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
context.runner.run(["ufw", "default", "deny", "incoming"], requires_root=True)
|
||||
context.runner.run(["ufw", "default", "allow", "outgoing"], requires_root=True)
|
||||
ssh_rule = context.runner.run(["ufw", "status"], requires_root=True, check=False)
|
||||
if "22/tcp" not in ssh_rule.stdout and "OpenSSH" not in ssh_rule.stdout:
|
||||
context.runner.run(["ufw", "allow", "22/tcp"], requires_root=True)
|
||||
changed = True
|
||||
context.runner.run(["ufw", "--force", "enable"], requires_root=True)
|
||||
details.append("Pare-feu UFW activé")
|
||||
elif manager in {"dnf", "yum"}:
|
||||
pkg_report = context.runner.ensure_packages_report(["firewalld"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
context.runner.enable_service("firewalld.service")
|
||||
context.runner.run(["firewall-cmd", "--permanent", "--add-service=ssh"], requires_root=True)
|
||||
context.runner.run(["firewall-cmd", "--reload"], requires_root=True)
|
||||
details.append("Pare-feu firewalld activé")
|
||||
else:
|
||||
raise SecureCheckError("Pare-feu automatique non pris en charge sur ce système")
|
||||
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def docker_setup(context: ExecutionContext, task: TaskDefinition) -> TaskResult:
|
||||
started_at = datetime.now()
|
||||
manager = context.system.package_manager
|
||||
changed = False
|
||||
details: list[str] = []
|
||||
|
||||
if manager == "apt-get":
|
||||
pkg_report = context.runner.ensure_packages_report(["docker.io", "docker-compose-v2"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
elif manager in {"dnf", "yum", "pacman"}:
|
||||
pkg_report = context.runner.ensure_packages_report(["docker"])
|
||||
changed |= _append_package_details(context, details, pkg_report)
|
||||
else:
|
||||
raise SecureCheckError("Docker n'est pas pris en charge sur ce système")
|
||||
|
||||
daemon_payload = {
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "10m",
|
||||
"max-file": "3",
|
||||
},
|
||||
}
|
||||
changed |= context.runner.write_json_file(Path("/etc/docker/daemon.json"), daemon_payload, requires_root=True)
|
||||
context.runner.enable_service("docker.service")
|
||||
context.runner.run(["usermod", "-aG", "docker", context.system.target_user], requires_root=True, check=False)
|
||||
|
||||
version_result = context.runner.run(["docker", "--version"], requires_root=False, check=False)
|
||||
details.append(version_result.stdout.strip() or "Docker installé / vérifié")
|
||||
return _result(context, task, started_at, changed=changed, details=details)
|
||||
|
||||
|
||||
def bind(task: TaskDefinition, func) -> TaskDefinition:
|
||||
return TaskDefinition(
|
||||
key=task.key,
|
||||
label=task.label,
|
||||
description=task.description,
|
||||
category=task.category,
|
||||
requires_root=task.requires_root,
|
||||
default_selected=task.default_selected,
|
||||
handler=lambda context, _task=task, _func=func: _func(context, _task),
|
||||
)
|
||||
Reference in New Issue
Block a user