git/orchard/main
updater/security.py
213 lines6 KB last change raw · history
"""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)