Ctrl K
ComunidadGuías…

tests/installer/test_mcps_sudoers.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Sudoers de l'installeur : durcissement post-audit (2026-08-26).

Regressions couvertes :
- glob NOPASSWD sur un dossier de $HOME (escalade root triviale) : les regles
  ne doivent citer que des chemins absolus, sans glob, sous la copie systeme
  root:root /usr/local/lib/lyra/scripts ;
- le fichier doit etre valide par `visudo -cf` AVANT d'etre installe ;
- (historique) 'sudo tee' sans timeout bloquait 700 s+ si le cache sudo
  n'etait pas amorce : tout passe par `sudo -n` + timeout -> RuntimeError.
"""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from installer.core.steps.mcps import (
    SYSTEM_SCRIPTS_DIR,
    _ensure_libvirt_group,
    _install_system_scripts,
    _write_sudoers,
    build_sudoers_rules,
    sudoers_targets,
)

# Construit a partir de morceaux pour eviter qu'un scan anti-fuite de chemins
# personnels (CI) ne confonde ce garde-fou de securite avec une fuite.
_USER_HOME_PREFIX = "/" + "home/"


class _FakeBroker:
    def __init__(self, answer=True):
        self.answer = answer

    def confirm(self, prompt, default=True):
        return self.answer


class _FakeCtx:
    def __init__(self, answer=True):
        self.broker = _FakeBroker(answer)
        self.events = []
        self.step_id = "mcp_fedora"

    def emit(self, event):
        self.events.append(event)


@pytest.fixture
def scripts_tree(tmp_path):
    """Arborescence minimale a l'image de fedora-agents/scripts."""
    for sub, names in {
        "agents/vm-controller": ["common.sh", "vm-start.sh", "vm-stop.sh"],
        "agents/backup-manager": ["common.sh", "backup-create.sh"],
        "kvm": ["kvm-clone.sh", "_fix-grub-vm.sh"],
        "backup": ["borg-backup.sh"],
        "utils": ["tracking.sh"],
    }.items():
        d = tmp_path / sub
        d.mkdir(parents=True)
        for n in names:
            (d / n).write_text("#!/bin/bash\n")
    (tmp_path / "config.env.example").write_text("X=1\n")
    return tmp_path


# --- logique pure -----------------------------------------------------------

def test_cibles_sudoers_uniquement_scripts_d_entree(scripts_tree):
    targets = sudoers_targets(scripts_tree)
    names = [t.name for t in targets]
    assert names == ["vm-start.sh", "vm-stop.sh", "backup-create.sh", "kvm-clone.sh"]  # ordre SUDOERS_SUBDIRS
    assert all(str(t).startswith(str(SYSTEM_SCRIPTS_DIR)) for t in targets), \
        "les regles doivent viser la copie systeme, jamais la source dans $HOME"
    assert "common.sh" not in names and "_fix-grub-vm.sh" not in names
    assert "borg-backup.sh" not in names, "backup/ n'est pas un dossier d'entree sudoers"


def test_regles_sans_glob_ni_home(scripts_tree):
    rules = build_sudoers_rules("alice", sudoers_targets(scripts_tree))
    body = [line for line in rules.splitlines() if line and not line.startswith("#")]
    assert len(body) == 4  # scripts seulement : plus de NOPASSWD sur virsh, virt-clone, qemu-img
    assert not any("/usr/bin/" in line for line in body), "binaire libvirt en NOPASSWD = root"
    for line in body:
        assert line.startswith("alice ALL=(ALL) NOPASSWD: /")
        path = line.split("NOPASSWD: ")[1]
        assert not set("*?[]") & set(path), f"glob interdit : {line}"
        assert not path.startswith(_USER_HOME_PREFIX), f"chemin sous /home interdit : {line}"


@pytest.mark.parametrize("bad", [
    Path("/usr/local/lib/lyra/scripts/kvm/*.sh"),       # glob (l'ancien bug)
    Path("relative/vm-start.sh"),                        # pas absolu
    Path("/usr/local/lib/lyra/scripts/kvm/a b.sh"),      # espace = regle ambigue
])
def test_regle_refusee(bad):
    with pytest.raises(ValueError):
        build_sudoers_rules("alice", [bad])


# --- ecriture (sudo mocke) ---------------------------------------------------

def _ok(*a, **k):
    return MagicMock(returncode=0, stdout="", stderr="")


def test_visudo_valide_avant_installation(scripts_tree):
    ctx = _FakeCtx()
    with patch("subprocess.run", side_effect=_ok) as mock_run, \
         patch("installer.core.steps.mcps.getpass.getuser", return_value="alice"):
        _write_sudoers(ctx, sudoers_targets(scripts_tree))
    cmds = [c.args[0] for c in mock_run.call_args_list]
    assert all(c[:2] == ["sudo", "-n"] for c in cmds), "toujours sudo -n (pas de prompt bloquant)"
    visudo = [c for c in cmds if c[2] == "visudo"]
    install = [c for c in cmds if c[2] == "install"]
    assert visudo and install
    assert cmds.index(visudo[0]) < cmds.index(install[0]), "visudo -cf doit preceder l'installation"
    assert visudo[0][3] == "-cf"
    assert install[0][-1] == "/etc/sudoers.d/lyra" and "0440" in install[0]


def test_visudo_invalide_n_installe_rien(scripts_tree):
    ctx = _FakeCtx()

    def fake(cmd, **kw):
        if cmd[2] == "visudo":
            return MagicMock(returncode=1, stdout="", stderr=">>> syntax error")
        return _ok()

    with patch("subprocess.run", side_effect=fake) as mock_run:
        with pytest.raises(RuntimeError, match="NON installe"):
            _write_sudoers(ctx, sudoers_targets(scripts_tree))
    assert not any(c.args[0][2] == "install" for c in mock_run.call_args_list)


def test_refus_utilisateur_n_ecrit_rien(scripts_tree):
    with patch("subprocess.run") as mock_run:
        _write_sudoers(_FakeCtx(answer=False), sudoers_targets(scripts_tree))
    assert not mock_run.called


def test_timeout_leve_erreur_claire_au_lieu_de_bloquer(scripts_tree):
    with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("sudo", 30)):
        with pytest.raises(RuntimeError, match="cache expire"):
            _write_sudoers(_FakeCtx(), sudoers_targets(scripts_tree))


def test_copie_systeme_root_et_modes(scripts_tree):
    ctx = _FakeCtx()
    with patch("subprocess.run", side_effect=_ok) as mock_run:
        targets = _install_system_scripts(ctx, scripts_tree)
    cmds = [c.args[0] for c in mock_run.call_args_list]
    assert all(c[:3] == ["sudo", "-n", "install"] for c in cmds)
    assert all("root" in c for c in cmds), "tout est installe en root:root"
    files = [c for c in cmds if "-d" not in c]
    sh = [c for c in files if c[-2].endswith(".sh")]
    other = [c for c in files if not c[-2].endswith(".sh")]
    assert sh and all("0755" in c for c in sh)
    assert other and all("0644" in c for c in other)
    assert all(c[-1].startswith(str(SYSTEM_SCRIPTS_DIR)) for c in files)
    assert len(targets) == 4


def test_source_absente_erreur_explicite(tmp_path):
    with pytest.raises(RuntimeError, match="introuvables"):
        _install_system_scripts(_FakeCtx(), tmp_path / "nope")


# --- groupe libvirt (virsh sans sudo) ------------------------------------------

def test_libvirt_deja_membre_ne_lance_rien():
    ctx = _FakeCtx()
    with patch("subprocess.run") as mock_run, \
         patch("installer.core.steps.mcps.getpass.getuser", return_value="alice"):
        _ensure_libvirt_group(ctx, groups_fn=lambda user: {"alice", "wheel", "libvirt"})
    assert not mock_run.called


def test_libvirt_ajout_via_sudo_n_usermod():
    ctx = _FakeCtx()
    with patch("subprocess.run", side_effect=_ok) as mock_run, \
         patch("installer.core.steps.mcps.getpass.getuser", return_value="alice"):
        _ensure_libvirt_group(ctx, groups_fn=lambda user: {"alice"})
    assert [c.args[0] for c in mock_run.call_args_list] == [["sudo", "-n", "usermod", "-aG", "libvirt", "alice"]]


def test_libvirt_refus_utilisateur_n_ajoute_rien():
    with patch("subprocess.run") as mock_run, \
         patch("installer.core.steps.mcps.getpass.getuser", return_value="alice"):
        _ensure_libvirt_group(_FakeCtx(answer=False), groups_fn=lambda user: set())
    assert not mock_run.called