"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, seit 25.09.2026). Welche offenen Paket-Updates schließen eine Sicherheitslücke? • Debian und Ubuntu (Gäste und Proxmox-Host) liefern sie aus eigenen Suiten („trixie-security“); das zählt der Ausführer selbst (os_updates.sicherheit, host.updates_sicherheit). • Alpine (NPMplus) kennt keine solche Suite. Dafür führt Alpine eine Sicherheitsdatenbank (secdb.alpinelinux.org, je Zweig main und community): je Paket die Versionen, die Lücken schließen. Ein Update zählt, wenn eine davon nach der installierten und höchstens bei der neuen Version liegt. Die Datenbank lädt dieser Teil im Hintergrund und merkt sie zwölf Stunden (/alpine-secdb-v.json); bis sie da ist, heißt es „unbekannt“. Docker-Images prüft Arcane selbst (Trivy, sonntags 02:00): docker.py holt dessen Befunde je Image. """ import json import logging import re import threading import time from pathlib import Path import httpx from kern.einstellungen import einstellungen log = logging.getLogger(__name__) SECDB = "https://secdb.alpinelinux.org/v{zweig}/{repo}.json" NEU_LADEN_S = 12 * 3600 _SUFFIX = {"alpha": -4, "beta": -3, "pre": -2, "rc": -1, "cvs": 1, "svn": 1, "git": 1, "hg": 1, "p": 2} _lock = threading.Lock() _laedt: set[str] = set() _gemerkt: dict[str, tuple[float, dict]] = {} def apk_teile(version: str) -> tuple: """Eine Alpine-Version („1.37.0-r18“, „3.3.2_rc1-r0“, „1.2a-r1“) als vergleichbares Tupel: Zahlen (Buchstaben als kleine Zahlen), Zusätze (_alpha < _beta < _pre < _rc < ohne < _p), zuletzt die Paketrevision -rN.""" haupt, _, revision = version.partition("-r") kern, *zusaetze = haupt.split("_") teile = tuple(int(x) if x.isdigit() else ord(x[0]) - 96 for x in re.findall(r"\d+|[a-z]+", kern.lower())) suffixe = [] for z in zusaetze: m = re.fullmatch(r"([a-z]+)(\d*)", z) suffixe.append((_SUFFIX.get(m.group(1), 0), int(m.group(2) or 0)) if m else (0, 0)) return (teile, tuple(suffixe) or ((0, 0),), int(revision) if revision.isdigit() else 0) def _pfad(zweig: str) -> Path: return einstellungen().daten_dir / f"alpine-secdb-v{zweig}.json" def _laden(zweig: str) -> None: """Beide Repos des Zweigs holen und als {paket: [Versionen mit Fixes]} ablegen (eigener Faden).""" try: fixes: dict[str, list[str]] = {} for repo in ("main", "community"): r = httpx.get(SECDB.format(zweig=zweig, repo=repo), timeout=30, follow_redirects=True) r.raise_for_status() for eintrag in r.json().get("packages") or []: pkg = (eintrag or {}).get("pkg") or {} if pkg.get("name") and isinstance(pkg.get("secfixes"), dict): fixes.setdefault(pkg["name"], []).extend(v for v in pkg["secfixes"] if v != "0") pfad = _pfad(zweig) pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps({"geholt": time.time(), "fixes": fixes}), encoding="utf-8") tmp.replace(pfad) log.info("sicherheit: Alpine-Sicherheitsdatenbank v%s geladen (%d Pakete)", zweig, len(fixes)) except Exception: log.warning("sicherheit: Alpine-Sicherheitsdatenbank v%s nicht geladen", zweig, exc_info=True) finally: with _lock: _laedt.discard(zweig) def secdb(zweig: str, jetzt: float | None = None) -> dict | None: """{paket: [Versionen mit Fixes]} — aus der Datei, im Speicher gemerkt; ist sie alt oder fehlt, lädt ein Faden sie nach (bis dahin die alte oder None). Blockiert nie.""" jetzt = time.time() if jetzt is None else jetzt pfad = _pfad(zweig) daten = None try: mtime = pfad.stat().st_mtime with _lock: alt = _gemerkt.get(zweig) if alt and alt[0] == mtime: daten = alt[1] else: daten = json.loads(pfad.read_text(encoding="utf-8")) with _lock: _gemerkt[zweig] = (mtime, daten) except (OSError, ValueError): daten = None if daten is None or jetzt - float(daten.get("geholt") or 0) > NEU_LADEN_S: with _lock: starten = zweig not in _laedt _laedt.add(zweig) if starten: threading.Thread(target=_laden, args=(zweig,), name=f"secdb-{zweig}", daemon=True).start() return daten.get("fixes") if daten else None def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None: """Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt).""" pakete = os_stand.get("pakete") if not isinstance(pakete, list): return None if not pakete: return 0 if fixes is None: zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2]) if not re.fullmatch(r"\d+\.\d+", zweig): return None fixes = secdb(zweig) if fixes is None: return None anzahl = 0 for p in pakete: alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or "")) if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])): anzahl += 1 return anzahl