"""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