Der Ausführer zählt je Gast, wie viele offene Updates aus einer Sicherheits-Suite kommen („-security“ in apt list), beim Proxmox-Host ebenso; für Alpine (NPMplus) schickt er die Paketliste, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab (im Hintergrund geladen, zwölf Stunden gemerkt, Alpine-Versionsvergleich). Die Update-Tabelle, die Kacheln und die Übersicht zeigen „· 3 Sicherheit“, die Rückfrage nennt es. Je Docker-Container steht, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image fand, kritische und hohe. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
5.2 KiB
Python
121 lines
5.2 KiB
Python
"""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 (<Datenordner>/alpine-secdb-v<zweig>.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
|