Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user