"""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)