#!/usr/bin/env python3 """check-updates.py - is the offline bundle current? Run before building a USB. * offline-packages/*.deb against Ubuntu 24.04 (noble, noble-updates, noble-security; main + universe): which have a newer version, and which of those are security updates. * webapp/requirements.txt pins against PyPI (newer release?) and osv.dev (known vulnerabilities in the pinned version). Exit 0 = nothing outstanding; 1 = security updates or vulnerable pins found (the details are printed); 2 = could not check (no network, bad input). Needs internet (Ubuntu archive, pypi.org, api.osv.dev); read-only. Usage: scripts/check-updates.py [--debs DIR] [--reqs FILE] [--all] """ import argparse import glob import gzip import json import os import re import subprocess import sys import urllib.request ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ARCHIVE = "http://archive.ubuntu.com/ubuntu/dists" SUITES = ("noble", "noble-updates", "noble-security") COMPONENTS = ("main", "universe") def fetch(url, timeout=60): with urllib.request.urlopen(url, timeout=timeout) as r: return r.read() def newer(a, b): """True if Debian version a > b.""" return subprocess.run(["dpkg", "--compare-versions", a, "gt", b]).returncode == 0 def ubuntu_index(): """({package: newest version}, {package: newest security version}).""" best, sec = {}, {} for suite in SUITES: for comp in COMPONENTS: text = gzip.decompress(fetch(f"{ARCHIVE}/{suite}/{comp}/binary-amd64/Packages.gz")).decode("utf-8", "replace") pkg = None for line in text.splitlines(): if line.startswith("Package: "): pkg = line[9:].strip() elif line.startswith("Version: ") and pkg: v = line[9:].strip() if pkg not in best or newer(v, best[pkg]): best[pkg] = v if suite.endswith("security") and (pkg not in sec or newer(v, sec[pkg])): sec[pkg] = v return best, sec def check_debs(deb_dir, show_all): debs = glob.glob(os.path.join(deb_dir, "*.deb")) if not debs: print(f"no .deb files in {deb_dir}: nothing to check (a failed download?)") return 1 best, sec = ubuntu_index() stale, security = [], [] for d in debs: parts = os.path.basename(d)[:-4].split("_") name, ver = parts[0], parts[1].replace("%3a", ":") if name in best and newer(best[name], ver): is_sec = name in sec and newer(sec[name], ver) (security if is_sec else stale).append((name, ver, best[name])) print(f"Ubuntu packages: {len(debs)} in {deb_dir}; {len(security) + len(stale)} have newer versions, " f"{len(security)} of them security updates") for n, v, nv in sorted(security): print(f" SECURITY {n:34} {v:30} -> {nv}") if show_all: for n, v, nv in sorted(stale): print(f" update {n:34} {v:30} -> {nv}") return len(security) def check_pins(reqs): pins = re.findall(r"^([A-Za-z0-9_.-]+)==([^\s\\]+)", open(reqs).read(), re.M) if not pins: print(f"no pinned packages in {reqs}") return 0 q = {"queries": [{"package": {"name": n, "ecosystem": "PyPI"}, "version": v} for n, v in pins]} req = urllib.request.Request("https://api.osv.dev/v1/querybatch", data=json.dumps(q).encode(), headers={"Content-Type": "application/json"}) results = json.load(urllib.request.urlopen(req, timeout=60))["results"] bad = 0 print(f"Python pins in {os.path.relpath(reqs, ROOT)}:") for (n, v), r in zip(pins, results): try: latest = json.loads(fetch(f"https://pypi.org/pypi/{n}/json", 30))["info"]["version"] except Exception: # report, keep going latest = "?" vulns = [x["id"] for x in r.get("vulns", [])] bad += bool(vulns) flag = "VULNERABLE" if vulns else ("newer " if latest not in (v, "?") else "ok ") print(f" {flag} {n:14} {v:10} latest {latest:10} {', '.join(vulns)}") return bad def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--debs", default=os.path.join(ROOT, "offline-packages")) ap.add_argument("--reqs", default=os.path.join(ROOT, "webapp", "requirements.txt")) ap.add_argument("--all", action="store_true", help="also list non-security updates") a = ap.parse_args() try: problems = check_debs(a.debs, a.all) + check_pins(a.reqs) except OSError as ex: print(f"could not check: {ex}", file=sys.stderr) return 2 print("PASSED: nothing outstanding" if not problems else f"ATTENTION: {problems} item(s) need updating") return 1 if problems else 0 if __name__ == "__main__": sys.exit(main())