Initial commit
This commit is contained in:
5
securecheck/__init__.py
Normal file
5
securecheck/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""SecureCheck package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
137
securecheck/__main__.py
Normal file
137
securecheck/__main__.py
Normal file
@@ -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())
|
||||
BIN
securecheck/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/__main__.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/__main__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/app.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/assets.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/assets.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/catalog.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/catalog.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/config.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/executor.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/executor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/logging_utils.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/logging_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/models.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/status.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/status.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/storage.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/storage.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/system_info.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/system_info.cpython-313.pyc
Normal file
Binary file not shown.
BIN
securecheck/__pycache__/tasks.cpython-313.pyc
Normal file
BIN
securecheck/__pycache__/tasks.cpython-313.pyc
Normal file
Binary file not shown.
401
securecheck/app.py
Normal file
401
securecheck/app.py
Normal file
@@ -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)
|
||||
15
securecheck/assets.py
Normal file
15
securecheck/assets.py
Normal file
@@ -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")
|
||||
6
securecheck/assets/banner.txt
Normal file
6
securecheck/assets/banner.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
_____ _____ _ _
|
||||
/ ____| / ____| | | |
|
||||
| (___ ___ ___ _ _ _ __ ___| | | |__ ___ ___| | __
|
||||
\___ \ / _ \/ __| | | | '__/ _ \ | | '_ \ / _ \/ __| |/ /
|
||||
____) | __/ (__| |_| | | | __/ |____| | | | __/ (__| <
|
||||
|_____/ \___|\___|\__,_|_| \___|\_____|_| |_|\___|\___|_|\_\
|
||||
1840
securecheck/assets/p10k.zsh
Normal file
1840
securecheck/assets/p10k.zsh
Normal file
File diff suppressed because it is too large
Load Diff
BIN
securecheck/assets/securecheck-icon.ico
Normal file
BIN
securecheck/assets/securecheck-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
securecheck/assets/securecheck-icon.png
Normal file
BIN
securecheck/assets/securecheck-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
31
securecheck/assets/securecheck-icon.svg
Normal file
31
securecheck/assets/securecheck-icon.svg
Normal file
@@ -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 |
158
securecheck/catalog.py
Normal file
158
securecheck/catalog.py
Normal file
@@ -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,
|
||||
),
|
||||
]
|
||||
96
securecheck/config.py
Normal file
96
securecheck/config.py
Normal file
@@ -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")
|
||||
368
securecheck/executor.py
Normal file
368
securecheck/executor.py
Normal file
@@ -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
|
||||
34
securecheck/logging_utils.py
Normal file
34
securecheck/logging_utils.py
Normal file
@@ -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
|
||||
46
securecheck/models.py
Normal file
46
securecheck/models.py
Normal file
@@ -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
|
||||
100
securecheck/status.py
Normal file
100
securecheck/status.py
Normal file
@@ -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
|
||||
74
securecheck/storage.py
Normal file
74
securecheck/storage.py
Normal file
@@ -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
|
||||
60
securecheck/system_info.py
Normal file
60
securecheck/system_info.py
Normal file
@@ -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,
|
||||
)
|
||||
449
securecheck/tasks.py
Normal file
449
securecheck/tasks.py
Normal file
@@ -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