"""Known vulnerabilities, via `arch-audit`. `arch-audit --json` gives far more than a list of names: the advisory id, every affected package, severity, whether a fix exists, and the CVE list. That is enough to build a real drill-down rather than a count, so this module keeps the structured form and derives the flat lists the plan needs from it. """ from __future__ import annotations import json import shutil import subprocess import time from dataclasses import dataclass, field from pathlib import Path TIMEOUT = 120 #: severity strings arch-audit uses, worst first SEVERITY_ORDER = ( "Critical risk", "High risk", "Medium risk", "Low risk", "Unknown", ) @dataclass(frozen=True) class Advisory: id: str packages: tuple[str, ...] = () severity: str = "Unknown" status: str = "" kind: str = "" fixed: str | None = None issues: tuple[str, ...] = () @property def url(self) -> str: return f"https://security.archlinux.org/{self.id}" @property def has_fix(self) -> bool: return bool(self.fixed) @property def severity_rank(self) -> int: try: return SEVERITY_ORDER.index(self.severity) except ValueError: return len(SEVERITY_ORDER) @classmethod def from_json(cls, raw: dict) -> Advisory: fixed = raw.get("fixed") return cls( id=raw.get("name", ""), packages=tuple(raw.get("packages") or ()), severity=raw.get("severity") or "Unknown", status=raw.get("status") or "", kind=raw.get("type") or "", fixed=str(fixed) if fixed else None, issues=tuple(raw.get("issues") or ()), ) @dataclass class Vulnerabilities: available: bool = False reason: str = "" advisories: list[Advisory] = field(default_factory=list) # -- derived views ----------------------------------------------------- @property def by_package(self) -> dict[str, list[Advisory]]: out: dict[str, list[Advisory]] = {} for advisory in self.advisories: for package in advisory.packages: out.setdefault(package, []).append(advisory) return out def _packages(self, *, fixed: bool) -> list[str]: names = {p for a in self.advisories if a.has_fix is fixed for p in a.packages} return sorted(names) @property def fixable(self) -> list[str]: """Packages where a pending update resolves the advisory.""" return self._packages(fixed=True) @property def unfixable(self) -> list[str]: """Packages with a known advisory and no available fix.""" return self._packages(fixed=False) @property def total(self) -> int: return len(set(self.fixable) | set(self.unfixable)) @property def clean(self) -> bool: return self.available and not self.advisories def sorted_advisories(self) -> list[Advisory]: """Fixable first, then worst severity, then id. An update you are about to apply can only resolve the fixable ones, so those belong at the top; the rest is reference material. """ return sorted( self.advisories, key=lambda a: (not a.has_fix, a.severity_rank, a.id), ) def for_package(self, package: str) -> list[Advisory]: return sorted( (a for a in self.advisories if package in a.packages), key=lambda a: (not a.has_fix, a.severity_rank, a.id), ) def binary() -> str | None: return shutil.which("arch-audit") def _run(args: list[str], *, timeout: int = TIMEOUT) -> tuple[int, str, str]: exe = binary() if exe is None: return 127, "", "arch-audit is not installed" try: proc = subprocess.run( [exe, *args], capture_output=True, text=True, errors="replace", timeout=timeout, check=False, ) except subprocess.TimeoutExpired: return 124, "", f"arch-audit timed out after {timeout}s" except OSError as exc: return 126, "", f"could not run arch-audit: {exc}" return proc.returncode, proc.stdout, proc.stderr #: the tracker publishes a few times a day, so an hour-old copy is not stale in #: any way that matters, and re-running arch-audit costs over a second SCAN_TTL = 3600 def cached_scan_path() -> Path: from . import fetch return fetch.cache_dir() / "arch-audit.json" def scan(*, timeout: int = TIMEOUT, use_cache: bool = True, force: bool = False) -> Vulnerabilities: """One call answers everything the plan and the drill-down need. Cached to disk. `arch-audit` re-fetches the tracker's whole dataset every time it runs -- a second and a half here -- and the drill-down asks for it once per package. Re-running it for every keypress is what made opening a package feel slow, and the answer cannot have changed in between. """ if binary() is None: return Vulnerabilities(reason="arch-audit is not installed") path = cached_scan_path() if use_cache and not force: try: if time.time() - path.stat().st_mtime < SCAN_TTL: cached = json.loads(path.read_text()) if isinstance(cached, list): return Vulnerabilities( available=True, advisories=[Advisory.from_json(i) for i in cached if isinstance(i, dict)], ) except (OSError, ValueError): pass code, out, err = _run(["--json"], timeout=timeout) if code != 0: detail = (err.strip().splitlines() or [f"exit status {code}"])[0] return Vulnerabilities(reason=detail) try: raw = json.loads(out) except ValueError as exc: return Vulnerabilities(reason=f"could not parse arch-audit output: {exc}") if not isinstance(raw, list): return Vulnerabilities(reason="unexpected arch-audit output shape") try: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(out) tmp.replace(path) except OSError: pass # a cache that cannot be written is not a failure return Vulnerabilities( available=True, advisories=[Advisory.from_json(item) for item in raw if isinstance(item, dict)], ) #: kept so older call sites and tests keep working def check(*, timeout: int = TIMEOUT) -> Vulnerabilities: return scan(timeout=timeout)