"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, 25.09.2026): Debian-Suiten „-security“ (Ausführer), Alpine über die Sicherheitsdatenbank (NPMplus, Alpine 3.24), Lücken-Scans aus Arcane je Container.""" import importlib.util import json import time from pathlib import Path import pytest from kern import einstellungen as einstellungen_mod from services.homelab import arcane, docker, einstellungen, sicherheit AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" @pytest.fixture(scope="module") def a(): spec = importlib.util.spec_from_file_location("ausfuehrer_sicherheit", AUSFUEHRER) modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) return modul def test_alpine_versionen_vergleichen(): t = sicherheit.apk_teile assert t("1.37.0-r18") < t("1.37.0-r19") < t("1.37.1-r0") assert t("3.3.2_rc1-r0") < t("3.3.2-r0") < t("3.3.2_p1-r0") assert t("1.2-r0") < t("1.2a-r0") and t("2.042-r0") > t("2.41-r9") def test_alpine_sicherheitsupdates_zaehlen(): fixes = {"busybox": ["1.37.0-r19", "1.36.1-r2"], "py3-requests": ["2.32.0-r0"]} stand = {"alpine": "3.24.2", "pakete": [ {"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}, # schließt eine Lücke {"name": "py3-requests", "alt": "2.32.3-r0", "neu": "2.32.4-r0"}, # Fix war schon älter {"name": "curl", "alt": "8.14.1-r0", "neu": "8.14.1-r1"}]} # ohne Eintrag assert sicherheit.alpine_sicherheit(stand, fixes) == 1 assert sicherheit.alpine_sicherheit({"pakete": []}) == 0 assert sicherheit.alpine_sicherheit({"anzahl": 3}) is None # Debian-Stand: nicht hier @pytest.fixture def daten(tmp_path, monkeypatch): monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) einstellungen_mod.einstellungen.cache_clear() sicherheit._gemerkt.clear() yield tmp_path einstellungen_mod.einstellungen.cache_clear() sicherheit._gemerkt.clear() def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch): geladen: list[str] = [] monkeypatch.setattr(sicherheit, "_laden", lambda zweig: geladen.append(zweig) or sicherheit._laedt.discard(zweig)) monkeypatch.setattr(sicherheit.threading, "Thread", lambda target, args, name, daemon: type( "T", (), {"start": lambda self: target(*args)})()) assert sicherheit.secdb("3.24") is None and geladen == ["3.24"] # fehlt: nachladen, bis dahin None (daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": time.time(), "fixes": {"busybox": ["1-r1"]}}), encoding="utf-8") assert sicherheit.secdb("3.24") == {"busybox": ["1-r1"]} and geladen == ["3.24"] alt = time.time() - sicherheit.NEU_LADEN_S - 60 (daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": alt, "fixes": {"x": []}}), encoding="utf-8") assert sicherheit.secdb("3.24") == {"x": []} and geladen == ["3.24", "3.24"] # alt: liefern und nachladen # --- Ausführer --------------------------------------------------------------------------------------------------- def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch): monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n")) assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178} monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: ( 0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n")) alpine = a._os_updates(101, "alpine") assert (alpine["anzahl"], alpine["alpine"], alpine["pakete"]) == ( 1, "3.24.2", [{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}]) liste = ("Listing...\ncurl/stable-security,stable 8.14.1-2+deb13u1 amd64 [upgradable from: 8.14.1-2]\n" "pve-manager/stable 9.2.20 all [upgradable from: 9.2.19]\n") assert a.sicherheit_auf_dem_host(liste) == ["curl"] # --- Homelab-Teil ------------------------------------------------------------------------------------------------ def test_bausteine_tragen_die_markierung(monkeypatch): from services.homelab import inventar gast = {"vmid": 104, "art": "lxc", "os_updates": {"anzahl": 12, "sicherheit": 3, "listen_stand": time.time()}, "snapshot_moeglich": True} stand = inventar._os_baustein(gast, "ct-104", time.time()) assert (stand.sicherheit, stand.kurz) == (3, "12 Pakete") and "(davon 3 Sicherheitsupdates)" in stand.aktion.frage host = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "curl"}, {"paket": "vim"}], "updates_sicherheit": ["curl"]}) assert host.bausteine[0].sicherheit == 1 ohne = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "vim"}]}) assert ohne.bausteine[0].sicherheit is None # älterer Ausführer: unbekannt def test_docker_mit_luecken_aus_arcane(monkeypatch): monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane") monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel") monkeypatch.setattr(arcane, "container_liste", lambda url: [ {"id": "a1", "names": ["/nerdquiz"], "imageId": "sha256:q", "state": "running", "labels": {}}, {"id": "a2", "names": ["/arcane"], "imageId": "sha256:a", "state": "running", "labels": {}}]) gefragt: list[tuple] = [] monkeypatch.setattr(arcane, "luecken", lambda url, ids: gefragt.append(ids) or { "sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 9, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}}) zeilen = {c["name"]: c for s in docker.lage()["stacks"] for c in s["container"]} assert gefragt == [("sha256:a", "sha256:q")] assert zeilen["nerdquiz"]["luecken"]["kritisch"] == 2 and zeilen["arcane"]["luecken"] is None def test_arcane_luecken_auslesen(monkeypatch): monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel") monkeypatch.setattr(arcane, "umgebungen", lambda url, s=None: [{"id": 0}]) arcane.cache_leeren() class Antwort: def raise_for_status(self): pass def json(self): return {"data": {"summaries": { "sha256:q": {"scanTime": "2026-09-27T02:04:00Z", "summary": {"critical": 2, "high": 5, "total": 30}}, "sha256:n": {"status": "not_scanned", "summary": {}}}}} monkeypatch.setattr(arcane.httpx, "post", lambda url, headers, json, timeout: Antwort()) assert arcane.luecken("http://arcane", ("sha256:n", "sha256:q")) == { "sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 0, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}} assert arcane.luecken("http://arcane", ()) == {}