Files
SecureCheck/securecheck/executor.py
2026-04-05 18:56:26 +02:00

369 lines
13 KiB
Python

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