From e64e3d69feecc77cbeb924d7c89cf9332ddc97bb Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:21:40 +0200 Subject: [PATCH] core: locale-pinned pacman parsing, fail-closed scanners, root-helper hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_c_locale() helper (LC_ALL=C) for all pacman output parsing; fixes the kernel-modules scanner offering to delete the RUNNING kernel's modules on non-English locales, wrong -Qi sizes and the -Ss [installed] flag - SystemKernelsScanner fails closed when pacman -Qo yields no owners - taninux-helper: systemctl operands must be unit names (no paths — closes the `systemctl enable /path` escalation primitive); user/group operands reject / and ..; exec via fixed system dirs instead of PATH lookup - wine.safe_to_delete resolves symlinks/..; new_prefix_path rejects bad names - kernel screen: exact variant matching (linux-rt-lts no longer marks linux-rt and linux-lts as running) - update sources log failures to stderr instead of reporting "up to date" - drop dead bootloader.prune_argv (helper allowlist rejected its argv) Co-Authored-By: Claude Fable 5 --- src/taninux/core/bootloader.py | 11 ----- src/taninux/core/pacman.py | 16 +++++-- src/taninux/core/search.py | 7 ++-- src/taninux/core/wine.py | 24 +++++++---- src/taninux/gui/helper/taninux-helper | 55 ++++++++++++++++++++++--- src/taninux/kernel/screen.py | 23 +++++++++-- src/taninux/maintain/scanners/pacman.py | 18 +++----- src/taninux/maintain/scanners/system.py | 15 ++++++- src/taninux/update/sources/__init__.py | 10 +++-- 9 files changed, 127 insertions(+), 52 deletions(-) diff --git a/src/taninux/core/bootloader.py b/src/taninux/core/bootloader.py index 671ca7b..43676d8 100644 --- a/src/taninux/core/bootloader.py +++ b/src/taninux/core/bootloader.py @@ -129,14 +129,3 @@ def set_default_argv(bl: BootloaderInfo, entry) -> list[str] | None: # entry-id für bootctl ist der Dateiname des .conf-Eintrags entry_id = entry.name if isinstance(entry, Path) else str(entry) return ["bootctl", "set-default", entry_id] - - -def prune_argv(keep: int) -> list[str]: - """ARGV zum Aufräumen alter zwischengespeicherter Pakete via `paccache`. - - Meint den pacman-Cache (/var/cache/pacman/pkg) — darin liegen alte Versionen - aller Pakete, inkl. alter Kernel. `paccache -rk ` entfernt alle bis auf - die `keep` jüngsten Versionen pro Paket; idiomatischer Arch-Weg. - core eskaliert nicht selbst — der Aufrufer führt das privilegiert aus. - """ - return ["paccache", "-rk", str(max(0, int(keep)))] diff --git a/src/taninux/core/pacman.py b/src/taninux/core/pacman.py index 14a734c..eaa7606 100644 --- a/src/taninux/core/pacman.py +++ b/src/taninux/core/pacman.py @@ -4,17 +4,25 @@ Zentral statt in jedem Pillar einen Subprocess-Call. Cache wird gecleart nach jedem write-Aktion (install/remove).""" from __future__ import annotations +import os import subprocess from functools import lru_cache +def run_c_locale(argv: list[str], timeout: float | None = None) -> subprocess.CompletedProcess[str]: + """subprocess.run mit LC_ALL=C — für alles, dessen Output-Parsing auf + englische Meldungen angewiesen ist (pacman/paru "is owned by", + "Installed Size", "[installed]" …).""" + return subprocess.run( + argv, capture_output=True, text=True, check=False, timeout=timeout, + env={**os.environ, "LC_ALL": "C"}, + ) + + @lru_cache(maxsize=1) def installed_packages() -> frozenset[str]: try: - r = subprocess.run( - ["pacman", "-Qq"], - capture_output=True, text=True, check=False, timeout=10, - ) + r = run_c_locale(["pacman", "-Qq"], timeout=10) except (FileNotFoundError, subprocess.TimeoutExpired): return frozenset() return frozenset(l.strip() for l in r.stdout.splitlines() if l.strip()) diff --git a/src/taninux/core/search.py b/src/taninux/core/search.py index d53f1da..bb73a2b 100644 --- a/src/taninux/core/search.py +++ b/src/taninux/core/search.py @@ -14,6 +14,8 @@ import shutil import subprocess from dataclasses import dataclass, field +from taninux.core.pacman import run_c_locale + MAX_RESULTS = 60 _HEAD = re.compile(r"^(?P[\w.-]+)/(?P\S+)\s+(?P\S+)(?P.*)$") @@ -42,9 +44,8 @@ def _search_pacman(query: str, include_aur: bool) -> list[Result]: helper = _helper() if include_aur else None argv = [helper, "-Ss", query] if helper else ["pacman", "-Ss", query] try: - r = subprocess.run( - argv, capture_output=True, text=True, check=False, timeout=45, - ) + # LC_ALL=C: der "[installed"-Tag im -Ss-Output ist lokalisiert. + r = run_c_locale(argv, timeout=45) except (FileNotFoundError, subprocess.TimeoutExpired): return [] if r.returncode not in (0, 1): diff --git a/src/taninux/core/wine.py b/src/taninux/core/wine.py index 049b10b..0f30835 100644 --- a/src/taninux/core/wine.py +++ b/src/taninux/core/wine.py @@ -110,16 +110,26 @@ def winetricks_argv(prefix: Path, *verbs: str) -> list[str]: def new_prefix_path(name: str) -> Path: + if not name or "/" in name or ".." in name: + raise ValueError(f"invalid prefix name: {name!r}") PREFIXES_DIR.mkdir(parents=True, exist_ok=True) return PREFIXES_DIR / name def safe_to_delete(path: Path) -> bool: - """Only allow deleting paths inside PREFIXES_DIR or the default ~/.wine.""" - default = Path.home() / ".wine" + """Only allow deleting paths inside PREFIXES_DIR or the default ~/.wine. + + Resolves symlinks/`..` first so a crafted name can't escape lexically.""" try: - path.relative_to(PREFIXES_DIR) - return True - except ValueError: - pass - return path == default + resolved = path.resolve() + root = PREFIXES_DIR.resolve() + default = (Path.home() / ".wine").resolve() + except OSError: + return False + if resolved != root: + try: + resolved.relative_to(root) + return True + except ValueError: + pass + return resolved == default diff --git a/src/taninux/gui/helper/taninux-helper b/src/taninux/gui/helper/taninux-helper index dc8daa0..04e734e 100755 --- a/src/taninux/gui/helper/taninux-helper +++ b/src/taninux/gui/helper/taninux-helper @@ -21,6 +21,7 @@ taninux-Paket aus dem Repo heraus läuft. from __future__ import annotations import os +import re import sys # ALLOWLIST: erste argv-Komponente → erlaubte Operationen. @@ -32,8 +33,9 @@ import sys # pacman/bootctl validieren die selbst, und sie können kein anderes Programm # starten. # -# Wichtig: KEINE Shell, kein eval. Wir führen via os.execvp() exakt das -# übergebene argv aus, ohne Interpretation durch eine Shell. +# Wichtig: KEINE Shell, kein eval. Wir führen via os.execv() exakt das +# übergebene argv aus (Binary fest aus /usr/bin & co. aufgelöst, kein PATH), +# ohne Interpretation durch eine Shell. ALLOWLIST: dict[str, dict[str, set[str]]] = { # Pakete (de)installieren + Vollupgrade. --noconfirm erlaubt, damit das # GUI-Frontend nicht in einem TTY-Prompt hängen bleibt. @@ -127,6 +129,30 @@ class Rejected(Exception): """Argv hat die Allowlist nicht passiert.""" +# systemd-Unit-Namen enthalten nie Slashes — Pfad-Operanden (z.B. eine Unit- +# Datei ausserhalb der Unit-Suchpfade) werden abgelehnt. +_UNIT_NAME_RE = re.compile(r"^[A-Za-z0-9@._:\-]+$") + +# Kommandos, deren Operanden Benutzer-/Gruppennamen (bzw. bei useradd -c ein +# Kommentar) sind — nie Pfade. Ohne den Check könnte z.B. `useradd -m ../x` +# ein Home-Verzeichnis an unerwarteter Stelle anlegen. +_NAME_OPERAND_CMDS = {"useradd", "userdel", "passwd", "gpasswd"} + +# Erlaubte Verzeichnisse zum Auflösen des Kommandos — der Helper läuft als +# root, PATH darf die Wahl des Binaries nicht beeinflussen. +_SAFE_EXEC_DIRS = ("/usr/bin", "/usr/sbin", "/bin", "/sbin") + + +def _resolve_command(cmd: str) -> str | None: + """Löst ein Allowlist-Kommando zu einem absoluten Pfad in den festen + System-Verzeichnissen auf (kein PATH-Lookup).""" + for d in _SAFE_EXEC_DIRS: + candidate = os.path.join(d, cmd) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def validate(argv: list[str]) -> list[str]: """Prüft argv gegen die ALLOWLIST. Gibt das (unveränderte) argv zurück oder wirft Rejected mit einer Begründung.""" @@ -152,7 +178,14 @@ def validate(argv: list[str]) -> list[str]: # ein Flag/Operator, der weder Operation noch erlaubtes Flag ist raise Rejected(f"option nicht erlaubt für {cmd}: {tok!r}") # ein Operand (Paketname, Boot-Eintrag) — keine Optionen-Injektion - # möglich, da kein "-" am Anfang. Operanden sind frei. + # möglich, da kein "-" am Anfang. + if cmd == "systemctl": + if "/" in tok or ".." in tok or not _UNIT_NAME_RE.match(tok): + raise Rejected(f"ungültiger unit-name: {tok!r}") + elif cmd in _NAME_OPERAND_CMDS: + if "/" in tok or ".." in tok: + raise Rejected(f"ungültiger operand für {cmd}: {tok!r}") + # Alle anderen Operanden sind frei — pacman/bootctl/… validieren selbst. # Kommandos mit erlaubten Operationen müssen eine davon nennen. Kommandos # OHNE Ops (z.B. cupsenable/cupsdisable: nur Operand = Druckername) sind @@ -185,11 +218,21 @@ def main(raw_argv: list[str]) -> int: print(f"taninux-helper: ok: {' '.join(argv)}") return 0 - # erlaubtes Kommando ausführen — execvp ersetzt den Helper-Prozess, + # erlaubtes Kommando ausführen — exec ersetzt den Helper-Prozess, # damit pkexec den echten Exit-Code des Kommandos durchreicht. + # Kein execvp/PATH-Lookup: das Binary wird ausschliesslich in festen + # System-Verzeichnissen aufgelöst. + exe = _resolve_command(argv[0]) + if exe is None: + print( + f"taninux-helper: kommando {argv[0]!r} nicht gefunden in " + f"{':'.join(_SAFE_EXEC_DIRS)}", + file=sys.stderr, + ) + return 127 try: - os.execvp(argv[0], argv) - except OSError as err: # Programm nicht gefunden o.ä. + os.execv(exe, argv) + except OSError as err: print(f"taninux-helper: exec fehlgeschlagen: {err}", file=sys.stderr) return 127 diff --git a/src/taninux/kernel/screen.py b/src/taninux/kernel/screen.py index d157678..284d8f2 100644 --- a/src/taninux/kernel/screen.py +++ b/src/taninux/kernel/screen.py @@ -57,11 +57,26 @@ def _installed_with_versions() -> dict[str, str]: return out +# Variant-Suffixe, längste zuerst — damit "-rt-lts" vor "-rt"/"-lts" matcht. +_VARIANT_SUFFIXES = sorted( + (name.removeprefix("linux-") for name, _ in KERNEL_VARIANTS if name != "linux"), + key=len, reverse=True, +) + + +def _running_variant(running: str) -> str: + """Kernel-Paketname zur laufenden Release (`uname -r`). + + Arch hängt den Variant-Suffix ans Ende der Release (z.B. + "6.9.7-zen1-1-zen", "6.6.30-rt30-1-rt-lts"); vanilla "linux" hat keinen.""" + for suffix in _VARIANT_SUFFIXES: + if running.endswith(f"-{suffix}"): + return f"linux-{suffix}" + return "linux" + + def _is_running(variant: str, running: str) -> bool: - if variant == "linux": - return not any(s in running for s in ("zen", "lts", "hardened", "rt")) - suffix = variant.removeprefix("linux-") - return suffix in running + return variant == _running_variant(running) def list_kernels() -> tuple[list[KernelInfo], bootloader.BootloaderInfo]: diff --git a/src/taninux/maintain/scanners/pacman.py b/src/taninux/maintain/scanners/pacman.py index ec51530..e5a8a18 100644 --- a/src/taninux/maintain/scanners/pacman.py +++ b/src/taninux/maintain/scanners/pacman.py @@ -6,6 +6,7 @@ import re import subprocess from pathlib import Path +from taninux.core.pacman import run_c_locale from taninux.maintain.types import Category, Classification, Finding @@ -35,10 +36,7 @@ class PacmanOrphansScanner: def scan(self) -> list[Finding]: try: - r = subprocess.run( - ["pacman", "-Qdtq"], - capture_output=True, text=True, check=False, timeout=10, - ) + r = run_c_locale(["pacman", "-Qdtq"], timeout=10) except (FileNotFoundError, subprocess.TimeoutExpired): return [] pkgs = [p.strip() for p in r.stdout.splitlines() if p.strip()] @@ -64,10 +62,9 @@ class PacmanOrphansScanner: @staticmethod def _installed_sizes(pkgs: list[str]) -> dict[str, int]: try: - r = subprocess.run( - ["pacman", "-Qi", *pkgs], - capture_output=True, text=True, check=False, timeout=30, - ) + # LC_ALL=C: das Parsing unten keyt auf die englischen Feldnamen + # "Name" / "Installed Size". + r = run_c_locale(["pacman", "-Qi", *pkgs], timeout=30) except (FileNotFoundError, subprocess.TimeoutExpired): return {} sizes: dict[str, int] = {} @@ -192,10 +189,7 @@ class PacmanCacheScanner: @staticmethod def _installed_packages() -> set[str]: try: - r = subprocess.run( - ["pacman", "-Qq"], - capture_output=True, text=True, check=False, timeout=10, - ) + r = run_c_locale(["pacman", "-Qq"], timeout=10) return {l.strip() for l in r.stdout.splitlines() if l.strip()} except (FileNotFoundError, subprocess.TimeoutExpired): return set() diff --git a/src/taninux/maintain/scanners/system.py b/src/taninux/maintain/scanners/system.py index af5dd6a..71d3ed0 100644 --- a/src/taninux/maintain/scanners/system.py +++ b/src/taninux/maintain/scanners/system.py @@ -8,6 +8,7 @@ import time from datetime import datetime, timezone from pathlib import Path +from taninux.core.pacman import run_c_locale from taninux.maintain.scanners._util import dir_size from taninux.maintain.types import Category, Classification, Finding @@ -194,9 +195,12 @@ class SystemKernelsScanner: return [] try: - r = subprocess.run( + # LC_ALL=C: das "is owned by"-Parsing unten braucht englische + # pacman-Meldungen — sonst matcht nichts und ALLE Kernel-Module + # (inkl. laufender Kernel!) würden als SAFE eingestuft. + r = run_c_locale( ["pacman", "-Qo", *[str(d) for d in kernel_dirs]], - capture_output=True, text=True, check=False, timeout=30, + timeout=30, ) except (FileNotFoundError, subprocess.TimeoutExpired): return [] @@ -209,6 +213,13 @@ class SystemKernelsScanner: if m: owned.add(m.group(1).rstrip("/")) + # Fail closed: es gibt Kernel-Verzeichnisse, aber pacman meldet für + # KEINES einen Owner? Dann ist eher die Ownership-Abfrage kaputt + # (Locale, pacman-Fehler) als dass wirklich alles verwaist ist — + # nichts als löschbar einstufen. + if not owned: + return [] + findings: list[Finding] = [] for d in kernel_dirs: key = str(d).rstrip("/") diff --git a/src/taninux/update/sources/__init__.py b/src/taninux/update/sources/__init__.py index ac35925..94fe731 100644 --- a/src/taninux/update/sources/__init__.py +++ b/src/taninux/update/sources/__init__.py @@ -1,6 +1,8 @@ """Update-Sources Registry.""" from __future__ import annotations +import sys + from taninux.update.sources import aur, flatpak, fwupd, pacman from taninux.update.types import UpdateItem @@ -15,9 +17,11 @@ SOURCES = { def fetch_all() -> list[UpdateItem]: items: list[UpdateItem] = [] - for fn in SOURCES.values(): + for name, fn in SOURCES.items(): try: items += fn() - except Exception: # noqa: BLE001 - pass + except Exception as e: # noqa: BLE001 + # Nicht stumm schlucken — sonst sieht eine kaputte Source wie + # "system up to date" aus. API bleibt stabil (Liste der Items). + print(f"taninux: update source {name} failed: {e}", file=sys.stderr) return items