#!/usr/bin/env python3 """security_watch.py — the nightly watch for security notices on everything Felhom ships (R-1303). WHY. Cloudflare's eMudhra notice (R-1300) reached us by mail; our own check (check-infra-pins.py, monthly, by hand) would have said only „a newer version exists", up to four weeks late. This job reads, every night, the places each program publishes its security notices, and sorts every pin into exactly one class: ADVISORY a notice affects the version we pin (with the fixed version when the notice gives one) NOT CHECKED a source this pin needs failed tonight — printed and counted, never a silent pass GAP the pin has no machine-readable source (a stated `gap:` line); BEHIND is still shown FLOATING the tag names no exact version (`redis:7-alpine`), so no range can be matched; BEHIND still shown BEHIND a newer version exists in the same channel, and no notice CURRENT none of the above WHAT IT READS (the spike that chose these: documentation/audits/security-watch-2026-10-26/SPIKE.md): github:O/R GitHub's per-repo security advisories (API; GH_ADVISORY_TOKEN when present) + the release feed (github.com/O/R/releases.atom — no API quota) for notes newer than our pin that name a CVE/GHSA/ vulnerability/security fix advisories:O/R the per-repo advisories of ANOTHER repo (Nextcloud) osv:Go/stdlib OSV.dev for our Go builder version feed:NAME a vendor feed (FEEDS below): an item in the last 30 days naming the program and a security word vulnnotes:NAME a vendor release-notes file whose security lines carry a marker (VULNNOTES below) registry tags Docker Hub / ghcr.io / lscr.io, for BEHIND (same channel = same tag shape, same major) runtime the image ITSELF (R-1327): Node.js and Python Brotli/brotlicffi inside it, from its registry config and, where needed, its layers (image_runtime.py), matched against security_watch_runtimes.json — a bug in the runtime under an app (HTTP2Shell, CVE-2026-111497) never reaches the app's own notices. Cached by image digest in the state file (`runtime_cache`), so a night reads only changed images. The list of pins: security_watch_inventory.py (ONE reader, shared with the catalog's and the controller's gates). MAILS (operator only, admin@felhom.eu, the CI's Resend path — no household ever gets one): * ADVISORY: one mail that night with every NEW advisory (and a reminder for each still open after 7 days); subject „[Felhom security] …"; each entry says what, our version, the fixed version, the severity, the link, and „Next: the security-fix lane (B23)". * BEHIND: one summary on Monday (or when the last one is older than 8 days), box programs first, then apps; GAP, FLOATING and tonight's NOT CHECKED are listed in it too. * NOT CHECKED: when the same source failed two nights in a row — once per streak. MEMORY: a JSON state file (`--state`), committed back to felhom.eu by the CI job (documentation/security-watch/ state.json; the reasons in the workflow). A missing state file means: seed (record tonight's advisories as known, mail nothing new) AND send one mail saying the memory was missing, with the open list — never a silent re-seed. USAGE python3 scripts/security_watch.py --state= [--catalog=] [--controller=] [--seed] [--no-mail] [--no-runtime] [--test-mail] [--today=YYYY-MM-DD] [--json=] Exit: 0 the run finished (whatever it found) · 1 a mail could not be sent · 2 the list of pins is unreadable. """ import concurrent.futures import datetime import email.utils import html import json import os import re import sys import urllib.error import urllib.request import zlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import security_watch_inventory as inv # noqa: E402 import image_runtime # noqa: E402 UA = "felhom-security-watch/1.0 (+https://felhom.eu)" MAIL_TO = "admin@felhom.eu" MAIL_FROM = "Felhom security watch " REMIND_DAYS = 7 FEED_DAYS = 30 FEEDS = { # The only automated way to Cloudflare's eMudhra notice (SPIKE.md, P1): the blog carries full text. "cloudflare-blog": {"url": "https://blog.cloudflare.com/rss/", "keywords": ("cloudflared",)}, } VULNNOTES = { # Cloudflare marks security fixes in cloudflared's notes with VULN- (2026.10.0, 2026.10.4). "cloudflared": {"url": "https://raw.githubusercontent.com/cloudflare/cloudflared/master/RELEASE_NOTES", "marker": "VULN-"}, } STRONG = re.compile(r"\bCVE-\d{4}-\d{3,}|\bGHSA(?:-[0-9a-z]{4}){3}\b|vulnerab|security (?:fix|issue|update|release|" r"advisory|patch|vulnerability)|\[security\]", re.I) FEED_WORDS = re.compile(r"vulnerab|compromis|\bCVE-\d{4}|security (?:update|fix|advisory|issue|notice)|exploit", re.I) class SourceError(Exception): pass # ── network (the tests replace Net) ────────────────────────────────────────────────────────────────────────────────── class Net: def __init__(self, gh_token=""): self.gh_token = gh_token self.requests = {} def _count(self, url): host = url.split("/")[2] self.requests[host] = self.requests.get(host, 0) + 1 def get(self, url, headers=None, timeout=30): self._count(url) h = {"User-Agent": UA} h.update(headers or {}) try: with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout) as r: return r.status, dict(r.headers), r.read() except urllib.error.HTTPError as e: return e.code, dict(e.headers or {}), e.read() if hasattr(e, "read") else b"" except Exception as e: # noqa: BLE001 — every transport failure is a NOT CHECKED, named raise SourceError("%s: %s" % (url.split("?")[0], e)) def post_json(self, url, body, timeout=30): self._count(url) req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", headers={"User-Agent": UA, "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.load(r) except Exception as e: # noqa: BLE001 raise SourceError("%s: %s" % (url, e)) # ── versions ───────────────────────────────────────────────────────────────────────────────────────────────────────── def version_of(tag): m = re.match(r"^v?(\d+(?:\.\d+)*)", tag or "") return tuple(int(x) for x in m.group(1).split(".")) if m else None def vcmp(a, b): n = max(len(a), len(b)) a, b = a + (0,) * (n - len(a)), b + (0,) * (n - len(b)) return (a > b) - (a < b) PRERELEASE = re.compile(r"(?:alpha|beta|rc|dev|nightly|preview|pre|ea)[.\d-]*$|-(?:a|b)\d", re.I) def is_floating(tag): """A tag with fewer than three numbers (`redis:7.4-alpine`, `postgres:16-…`) takes the line's newest at install — there is no exact version to match a notice's range against.""" v = version_of(tag) return v is None or len(v) < 3 def same_channel(ours, other): """Same major — except calendar versions (cloudflared 2026.x), whose first number is a year.""" return ours[0] >= 1000 or ours[0] == other[0] def range_affects(rng, ours, package=""): """GHSA's free-text range ('>= v3.0.0, <= v3.7.15', '≤ 1.36.0', 'v3.2.2', 'server <= 3.0.3') -> True/False/None. A clause of ONLY lower bounds with more than one of them is Nextcloud's per-branch shape ('>= 32.0.0, >= 33.0.0') and reads as OR; every other clause is AND. A bare version means 'up to and including'.""" if rng is None: return None pm = re.search(r"/v(\d+)$", package or "") if pm and int(pm.group(1)) > 1 and int(pm.group(1)) != ours[0]: return False rng = rng.replace("≤", "<=").replace("≥", ">=").replace("=", "=") any_clause = False for clause in rng.split("||"): cons = re.findall(r"(<=|>=|==|<|>|=)?\s*v?(\d+(?:\.\d+)+|\d+)(?![\d.])", clause) cons = [(op, v) for op, v in cons if op or "." in v] if not cons: continue any_clause = True res = [] for op, v in cons: c = vcmp(ours, tuple(int(x) for x in v.split("."))) res.append({"<=": c <= 0, ">=": c >= 0, "<": c < 0, ">": c > 0, "=": c == 0, "==": c == 0, "": c <= 0}[op]) lower_only = len(cons) > 1 and all(op in (">=", ">") for op, _ in cons) if (any(res) if lower_only else all(res)): return True return False if any_clause else None def patched_for(text, ours): """The fixed version that applies to OUR branch: the lowest named on our major; else the lowest named above us.""" vs = [tuple(int(x) for x in v.split(".")) for v in re.findall(r"v?(\d+(?:\.\d+)+)", text or "")] if not vs: return None same = [v for v in vs if v[0] == ours[0]] if same: return min(same) if all(v[0] < ours[0] for v in vs) and ours[0] < 1000: return max(vs) # every fix is on an older branch: ours is past all of them return min(vs) # A repo whose advisory list covers more than the program we run: only these package names count. PACKAGE_ONLY = {"nextcloud/security-advisories": re.compile(r"^server$", re.I)} def ghsa_affects(adv, ours, repo=""): """-> (affected True/False/None, fixed text). None = the range is unreadable (listed, never dropped).""" verdicts, fixed = [], [] only = PACKAGE_ONLY.get(repo) for vul in adv.get("vulnerabilities") or []: pkg = (vul.get("package") or {}).get("name") or "" if only and not only.search(pkg.strip()): continue # A GitHub Actions advisory is about the project's own CI workflow, never the image we run (R-1310: Home # Assistant's GHSA-jff5, 2023). Pinned: test W9. if ((vul.get("package") or {}).get("ecosystem") or "").lower() == "actions": verdicts.append(False) continue r = range_affects(vul.get("vulnerable_version_range"), ours, pkg) p = patched_for(vul.get("patched_versions"), ours) if p is not None and vcmp(ours, p) >= 0 and r is not False: r = False elif r is None and p is not None: r = vcmp(ours, p) < 0 verdicts.append(r) if r: pv = (vul.get("patched_versions") or "").strip() if not pv or pv.lower() in ("none", "tbd", "n/a"): # An empty fix with a range that ENDS at a version means „fixed after it" (R-1310: Vikunja's six, # `>= 2.3.0, <= 2.6.0`, were fixed in 2.7.0 and read „no fix"). Pinned: test W9. end = re.search(r"<=?\s*v?(\d+(?:\.\d+)+)\s*$", vul.get("vulnerable_version_range") or "") pv = ("a version after %s (the notice names none)" % end.group(1)) if end else "no fixed version named yet" fixed.append(pv) if any(v is True for v in verdicts): return True, ", ".join(sorted(set(fixed))) if not verdicts or all(v is False for v in verdicts): return False if verdicts else (None if not only else False), "" if verdicts else "the notice names no version range" return None, "the range is unreadable — check by hand" # ── sources ────────────────────────────────────────────────────────────────────────────────────────────────────────── RUNTIME_TABLE = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "security_watch_runtimes.json"), encoding="utf-8"))["rows"] def _vt(v): return tuple(int(x) for x in re.findall(r"\d+", re.split(r"[-+~]", v or "", maxsplit=1)[0])) def runtime_part(res, part): if ":" in part: db, name = part.split(":", 1) return (res.get(db) or {}).get(name) return res.get(part) def runtime_advisories(res, group): """The runtime table's verdicts for one image read (image_runtime.read_image's dict). Pure.""" out = [] for row in RUNTIME_TABLE: if row.get("groups") and group not in row["groups"]: continue ver = runtime_part(res, row["part"]) if not ver: continue v = _vt(ver) if not v or v < _vt(row.get("from", "0")): continue n = row.get("line_parts", 1) line = ".".join(str(x) for x in v[:n]) lines = sorted(row["fixed"], key=_vt) fixed = row["fixed"].get(line) if fixed: if v >= _vt(fixed): continue why, fx = "", fixed elif _vt(line) > _vt(lines[-1]): continue # newer than the notice's lines else: why, fx = " — no fixed release on the %s line (end of life): move to a supported line" % line, \ ", ".join(row["fixed"][k] for k in lines) out.append({"id": row["id"], "source": "runtime", "severity": row.get("severity", ""), "summary": "%s %s inside the image: %s%s" % (row["label"], ver, row["summary"], why), "fixed": fx, "link": row.get("link", ""), "unsure": False, "published": row.get("published", "")}) return out def cached_runtime_reader(cache, resolve=None, read=None): """A reader for run(): resolves the image's digest (cheap), answers from `cache` when known, else reads it and caches a COMPLETE result only (an unchecked one is tried again the next night). `cache` lives in the state file.""" resolve = resolve or image_runtime.resolve_digest read = read or image_runtime.read_image import threading import time lock = threading.Lock() stats = {"cached": 0, "read": 0, "read_s": 0.0, "not_checked": 0} def count(key, n=1): with lock: stats[key] += n def reader(ref): try: digest = resolve(ref) except image_runtime.Unreadable as e: return {"ref": ref, "unchecked": [str(e)]} except Exception as e: # noqa: BLE001 — a network error is a NOT CHECKED, never a crash of the whole watch return {"ref": ref, "unchecked": ["%s: %s" % (type(e).__name__, str(e)[:120])]} if digest and digest in cache: hit = dict(cache[digest], ref=ref) hit["_used"] = True cache[digest]["_seen"] = True count("cached") return hit t0 = time.monotonic() res = read(ref) count("read_s", time.monotonic() - t0) count("read") print("RUNTIME-READ %s %.0f s %s" % (ref, time.monotonic() - t0, "NOT CHECKED: " + "; ".join(res["unchecked"])[:160] if res.get("unchecked") else "ok"), flush=True) if digest and not res.get("unchecked"): cache[digest] = {k: v for k, v in res.items() if k != "ref"} cache[digest]["_seen"] = True else: count("not_checked") return res reader.stats = stats # the RUNTIME line of the log: how much was read tonight and how long it took (R-1327) return reader class Sources: def __init__(self, net, today, rotate): self.net, self.today, self.rotate = net, today, rotate self._adv, self._rel, self._feed, self._notes, self._osv, self._tags = {}, {}, {}, {}, {}, {} self.rate_limited = False def gh_advisories(self, repo): if repo in self._adv: return self._adv[repo] out = [] if self.rate_limited: raise SourceError("GitHub API rate limit reached earlier tonight") url = "https://api.github.com/repos/%s/security-advisories?per_page=100&state=published" % repo headers = {"Accept": "application/vnd.github+json"} if self.net.gh_token: headers["Authorization"] = "Bearer %s" % self.net.gh_token for _ in range(5): st, h, body = self.net.get(url, headers) if st in (403, 429) and str(h.get("X-RateLimit-Remaining", h.get("x-ratelimit-remaining", ""))) == "0": self.rate_limited = True raise SourceError("GitHub API rate limit (%s)" % st) if st != 200: raise SourceError("GitHub advisories for %s: HTTP %s" % (repo, st)) out += json.loads(body or b"[]") m = re.search(r'<([^>]+)>;\s*rel="next"', h.get("Link", h.get("link", "")) or "") if not m: break url = m.group(1) self._adv[repo] = out return out def release_feed(self, repo): if repo in self._rel: return self._rel[repo] st, _h, body = self.net.get("https://github.com/%s/releases.atom" % repo) if st != 200: raise SourceError("release feed of %s: HTTP %s" % (repo, st)) text = body.decode("utf-8", "replace") entries = [] for e in re.findall(r"(.*?)", text, re.S): link = re.search(r']*href="([^"]+)"', e) tag = link.group(1).rsplit("/", 1)[-1] if link else "" title = html.unescape(re.sub(r"<[^>]+>", "", (re.search(r"(.*?)", e, re.S) or [""] * 2)[1])) content = html.unescape(html.unescape((re.search(r"]*>(.*?)", e, re.S) or [""] * 2)[1])) content = re.sub(r"<[^>]+>", " ", content) entries.append({"tag": tag, "title": title.strip(), "content": content, "link": link.group(1) if link else "https://github.com/%s/releases" % repo}) self._rel[repo] = entries return entries def feed(self, name): if name in self._feed: return self._feed[name] st, _h, body = self.net.get(FEEDS[name]["url"]) if st != 200: raise SourceError("feed %s: HTTP %s" % (name, st)) text = body.decode("utf-8", "replace") items = [] for it in re.findall(r"(.*?)", text, re.S): def tag(t): m = re.search(r"<%s[^>]*>(.*?)" % (t, t), it, re.S) return html.unescape(re.sub(r"", "", m.group(1))) if m else "" items.append({"title": tag("title").strip(), "link": tag("link").strip(), "guid": tag("guid").strip() or tag("link").strip(), "date": tag("pubDate").strip(), "text": tag("title") + " " + tag("description") + " " + tag("content:encoded")}) self._feed[name] = items return items def vulnnotes(self, name): if name in self._notes: return self._notes[name] st, _h, body = self.net.get(VULNNOTES[name]["url"]) if st != 200: raise SourceError("release notes %s: HTTP %s" % (name, st)) secs, cur = {}, None for line in body.decode("utf-8", "replace").splitlines(): if re.match(r"^\d+(?:\.\d+)+\s*$", line): cur = line.strip() secs[cur] = [] elif cur and line.strip(): secs[cur].append(line.strip()) self._notes[name] = secs return secs def osv(self, eco_name, version): key = (eco_name, version) if key in self._osv: return self._osv[key] eco, name = eco_name.split("/", 1) d = self.net.post_json("https://api.osv.dev/v1/query", {"package": {"ecosystem": eco, "name": name}, "version": version}) self._osv[key] = d.get("vulns", []) return self._osv[key] def _registry_tags(self, path): st, _h, body = self.net.get("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull" % path) if st != 200: raise SourceError("Docker registry token for %s: HTTP %s" % (path, st)) tok = json.loads(body).get("token", "") st, _h, body = self.net.get("https://registry-1.docker.io/v2/%s/tags/list?n=10000" % path, {"Authorization": "Bearer %s" % tok}) if st != 200: raise SourceError("Docker registry tags of %s: HTTP %s" % (path, st)) return json.loads(body).get("tags") or [] def tags(self, image): if image in self._tags: return self._tags[image] reg, path = registry_of(image) names = [] if reg == "docker.io": url = "https://hub.docker.com/v2/repositories/%s/tags?page_size=100&ordering=last_updated" % path for _ in range(3): st, _h, body = self.net.get(url) if st == 404 and not names: names = self._registry_tags(path) # the web API misses some repos the registry serves (plant-it) break if st != 200: raise SourceError("Docker Hub tags of %s: HTTP %s" % (path, st)) d = json.loads(body) names += [t["name"] for t in d.get("results", [])] url = d.get("next") if not url: break elif reg == "ghcr.io": st, _h, body = self.net.get("https://ghcr.io/token?scope=repository:%s:pull" % path) if st != 200: raise SourceError("ghcr.io token for %s: HTTP %s" % (path, st)) tok = json.loads(body).get("token", "") url = "https://ghcr.io/v2/%s/tags/list?n=10000" % path for _ in range(50): # ghcr.io PAGES its list (Link: rel="next") — page 1 alone missed every newer tag (R-1327) st, h, body = self.net.get(url, {"Authorization": "Bearer %s" % tok}) if st != 200: raise SourceError("ghcr.io tags of %s: HTTP %s" % (path, st)) names += json.loads(body).get("tags") or [] nxt = re.search(r'<([^>]+)>;\s*rel="next"', (h or {}).get("Link", "") if hasattr(h, "get") else "") if not nxt: break url = "https://ghcr.io" + nxt.group(1) if nxt.group(1).startswith("/") else nxt.group(1) else: names = None # a registry the watch does not read (our own Gitea, GitLab): stated, not a failure self._tags[image] = names return names def registry_of(image): first = image.split("/", 1)[0] if "." in first or ":" in first: reg, path = first, image.split("/", 1)[1] if "/" in image else "" else: reg, path = "docker.io", image if reg in ("lscr.io", "index.docker.io", "registry-1.docker.io"): reg = "docker.io" if reg == "docker.io" and "/" not in path: path = "library/" + path return reg, path # ── one pin ────────────────────────────────────────────────────────────────────────────────────────────────────────── def newest_in_channel(tag, names): ours = version_of(tag) if not ours or not names: return None, None shape = re.compile("^" + re.sub(r"\d+", r"\\d+", re.escape(tag)) + "$") best, newer_major = None, None for n in names: if not shape.match(n): continue v = version_of(n) if not v or len(v) != len(ours): continue if same_channel(ours, v): if vcmp(v, ours) > 0 and (best is None or vcmp(v, version_of(best)) > 0): best = n elif v[0] > ours[0] and (newer_major is None or vcmp(v, version_of(newer_major)) > 0): newer_major = n return best, newer_major def check_item(it, src, today): """-> the item with `class`, `advisories`, `not_checked`, `behind`, `notes` filled in.""" if it.get("abandoned"): it.update({"advisories": [], "not_checked": [], "behind": None, "class": "GAP", "notes": ["abandoned upstream (lifecycle: abandoned) — no fix will ever come; not looked up"]}) if it.get("problems"): it["not_checked"].append({"source": "inventory", "why": "; ".join(it["problems"])}) it["class"] = "NOT CHECKED" return it ours = version_of(it["tag"]) floating = is_floating(it["tag"]) advs, nc, notes = [], [], [] kinds = [k for k, _ in it.get("sources") or []] for kind, arg in it.get("sources") or []: try: if kind in ("github", "advisories"): if src.rotate and it["group"] == "app" and (zlib.crc32(arg.encode()) + today.toordinal()) % 2: notes.append("advisories of %s: read tomorrow (no-key rotation)" % arg) it["adv_unread"] = True elif floating: notes.append("advisories of %s: not matched (floating tag)" % arg) else: for a in src.gh_advisories(arg): if a.get("withdrawn_at"): continue hit, fixed = ghsa_affects(a, ours, arg) if hit is False: continue advs.append({"id": a.get("ghsa_id") or a.get("cve_id") or "?", "source": "GitHub advisory", "severity": a.get("severity") or "", "summary": a.get("summary") or "", "fixed": fixed, "link": a.get("html_url") or "", "unsure": hit is None, "published": (a.get("published_at") or "")[:10]}) if kind == "github" and not floating: for e in src.release_feed(arg): v = version_of(e["tag"]) if not v or PRERELEASE.search(e["tag"]) or vcmp(v, ours) <= 0 or not same_channel(ours, v): continue m = STRONG.search(e["content"]) or STRONG.search(e["title"]) if m: line = next((ln.strip() for ln in re.split(r"[\n.]", e["content"]) if STRONG.search(ln)), m.group(0)) advs.append({"id": "release:%s@%s" % (arg, e["tag"]), "source": "release notes", "severity": "", "summary": line[:200], "fixed": e["tag"], "link": e["link"], "unsure": False, "published": ""}) elif kind == "osv" and not floating: ver = ".".join(str(x) for x in ours) for vuln in src.osv(arg, ver): fx = [] for a in vuln.get("affected", []): for r in a.get("ranges", []): fx += [e["fixed"] for e in r.get("events", []) if "fixed" in e] advs.append({"id": vuln.get("id", "?"), "source": "OSV (%s)" % arg, "severity": "", "summary": vuln.get("summary") or vuln.get("details", "")[:160], "fixed": ", ".join(sorted(set(fx))) or "?", "unsure": False, "link": "https://osv.dev/vulnerability/%s" % vuln.get("id", ""), "published": ""}) elif kind == "feed": cut = today - datetime.timedelta(days=FEED_DAYS) for item in src.feed(arg): try: d = email.utils.parsedate_to_datetime(item["date"]).date() except Exception: # noqa: BLE001 — an undated item is judged, never dropped d = today low = item["text"].lower() if d < cut or not any(k in low for k in FEEDS[arg]["keywords"]) or not FEED_WORDS.search(item["text"]): continue advs.append({"id": "feed:%s:%s" % (arg, item["guid"]), "source": "vendor notice (%s)" % arg, "severity": "", "summary": item["title"], "fixed": "read the notice", "unsure": True, "link": item["link"], "published": str(d), "no_reminder": True}) elif kind == "vulnnotes" and not floating: for ver, lines in src.vulnnotes(arg).items(): v = version_of(ver) if v and vcmp(v, ours) > 0: for ln in lines: if VULNNOTES[arg]["marker"] in ln: vid = re.search(r"%s\S+" % re.escape(VULNNOTES[arg]["marker"]), ln) advs.append({"id": "vulnnotes:%s@%s:%s" % (arg, ver, vid.group(0).rstrip(":") if vid else ""), "source": "release notes (%s)" % arg, "severity": "", "summary": ln[:200], "fixed": ver, "unsure": False, "published": "", "link": VULNNOTES[arg]["url"]}) except SourceError as e: nc.append({"source": source_name(kind, arg), "why": str(e)}) it["adv_unread"] = True behind, newer_major = None, None if it.get("no_registry"): gh = next((a for k, a in it.get("sources") or [] if k == "github"), None) try: names = [e["tag"].lstrip("v") for e in src.release_feed(gh)] if gh else None if names is None: notes.append("no registry and no release feed to compare with") else: behind, newer_major = newest_in_channel(it["tag"].lstrip("v"), names) except SourceError as e: nc.append({"source": "github", "why": str(e)}) elif "self" not in kinds: try: names = src.tags(it["image"]) if names is None: notes.append("registry not read (%s)" % registry_of(it["image"])[0]) else: behind, newer_major = newest_in_channel(it["tag"], names) except SourceError as e: nc.append({"source": "registry:%s" % registry_of(it["image"])[0], "why": str(e)}) if newer_major: notes.append("a newer major exists: %s" % newer_major) if getattr(src, "runtime", None) and not it.get("no_registry") and it.get("image") and it.get("tag"): res = src.runtime("%s:%s" % (it["image"], it["tag"])) unchecked = res.get("unchecked") or [] no_route = [u for u in unchecked if u.startswith("registry ") and "no anonymous token route" in u] if no_route and it["group"] != "dooplex": notes.append("runtime: %s" % no_route[0]) elif unchecked: nc.append({"source": "runtime", "why": "; ".join(unchecked)[:300]}) advs += runtime_advisories(res, it["group"]) it.update({"advisories": advs, "not_checked": nc, "behind": behind, "notes": notes}) if it.get("problems"): nc.append({"source": "inventory", "why": "; ".join(it["problems"])}) if advs: it["class"] = "ADVISORY" elif nc: it["class"] = "NOT CHECKED" elif "gap" in kinds and not [k for k in kinds if k != "gap"]: it["class"] = "GAP" elif "self" in kinds and len(kinds) == 1: it["class"] = "SELF" elif floating: it["class"] = "FLOATING" elif behind: it["class"] = "BEHIND" else: it["class"] = "CURRENT" return it def source_name(kind, arg): return {"github": "github", "advisories": "github", "osv": "osv", "feed": "feed:" + arg, "vulnnotes": "vulnnotes:" + arg}.get(kind, kind) # ── memory and mails (pure: the tests drive this directly) ────────────────────────────────────────────────────────── def adv_key(it, a): return "%s|%s" % (it["image"], a["id"]) def label(it): return "%s %s (%s)" % (it["image"], it["tag"], it["owner"]) def plan(items, state, today, seed=False): """-> (mails, new_state, would_have_mailed). A mail is {"kind","subject","text"}.""" st = json.loads(json.dumps(state or {})) st.setdefault("version", 1) known = st.setdefault("advisories", {}) fails = st.setdefault("failures", {}) iso = today.isoformat() mails, would = [], [] open_now, checked_images = {}, set() for it in items: if not it.get("not_checked"): checked_images.add(it["image"]) for a in it.get("advisories", []): open_now[adv_key(it, a)] = (it, a) # A seed that could not read some pins (no key: rotation; or a source down) leaves them PENDING: their first real # read is recorded silently too, so the first nights after a seed are no mail storm. A pin added later is never # pending, so its advisories mail normally. pending = set(st.get("seed_pending", [])) if seed: pending = {it["image"] for it in items if it.get("adv_unread")} else: for it in items: if it["image"] in pending and not it.get("adv_unread"): for a in it.get("advisories", []): k = adv_key(it, a) if k not in known: known[k] = {"first": iso, "last_mailed": iso, "program": label(it), "summary": a["summary"][:160], "silent": "first read after the seed"} would.append({"kind": "advisory", "subject": "(silent, first read after the seed) %s %s" % ( it["image"], a["id"]), "text": ""}) pending.discard(it["image"]) st["seed_pending"] = sorted(pending) new, remind = [], [] for k, (it, a) in sorted(open_now.items()): if k not in known: new.append((it, a)) known[k] = {"first": iso, "last_mailed": iso, "program": label(it), "summary": a["summary"][:160]} elif not a.get("no_reminder") and (today - datetime.date.fromisoformat(known[k]["last_mailed"])).days >= REMIND_DAYS: remind.append((it, a)) known[k]["last_mailed"] = iso for k in list(known): if k not in open_now and k.split("|", 1)[0] in checked_images: del known[k] # closed: its pin was checked tonight and the notice no longer applies if new or remind: m = advisory_mail(new, remind, today) if seed: would.append(m) else: mails.append(m) failed_now = {} for it in items: for f in it.get("not_checked", []): failed_now.setdefault(f["source"], []).append("%s — %s" % (label(it), f["why"])) streak_mail = [] for s in sorted(set(fails) | set(failed_now)): rec = fails.setdefault(s, {"streak": 0, "mailed": False}) if s in failed_now: rec["streak"] += 1 if rec["streak"] >= 2 and not rec["mailed"]: streak_mail.append((s, rec["streak"], failed_now[s])) rec["mailed"] = True else: del fails[s] if streak_mail and not seed: mails.append(not_checked_mail(streak_mail, today)) last_weekly = st.get("weekly_last") due = today.weekday() == 0 and last_weekly != iso if last_weekly and (today - datetime.date.fromisoformat(last_weekly)).days >= 8: due = True if seed: st["weekly_last"] = iso st["seeded_at"] = iso elif due: mails.append(weekly_mail(items, today)) st["weekly_last"] = iso return mails, st, would GROUP_ORDER = {"box": 0, "build": 0, "app": 1, "dooplex": 2} def _grp(it): return GROUP_ORDER.get(it["group"], 1) def _adv_lines(it, a, reminder=False): return ["%s%s — %s" % ("REMINDER (still open): " if reminder else "", it["image"], a["id"]), " What: %s" % (a["summary"] or "(no summary)"), " Our version: %s (%s; %s)" % (it["tag"], it["owner"], it["where"]), " Fixed in: %s" % (a["fixed"] or "?"), " Severity: %s" % (a["severity"] or "not given"), " Source: %s%s" % (a["source"], " — the range could not be read; check by hand" if a.get("unsure") and a["source"] == "GitHub advisory" else ""), " Link: %s" % (a["link"] or "-"), " Next: " + ("the homelab session updates this DooPlex workload (Felhom changes nothing on DooPlex; the " "list: documentation/security-watch/dooplex-workloads.txt)" if it["group"] == "dooplex" else "the security-fix lane (B23) — capability map B23; for a box program, a controller release"), ""] def advisory_mail(new, remind, today): allp = new + remind box = [it for it, _ in allp if _grp(it) == 0] names = [] for it, _ in sorted(allp, key=lambda p: (_grp(p[0]), p[0]["image"])): n = it["image"].rsplit("/", 1)[-1] if n not in names: names.append(n) subject = "[Felhom security] %s — %d new advisor%s%s" % ( ", ".join(names[:4]) + (" …" if len(names) > 4 else ""), len(new), "y" if len(new) == 1 else "ies", ", %d reminder%s" % (len(remind), "" if len(remind) == 1 else "s") if remind else "") lines = ["The nightly security watch (felhom.eu R-1303) found a notice that affects a version we ship.", "", "Box programs first%s." % ("" if box else " (none tonight)"), ""] for it, a in sorted(new, key=lambda p: (_grp(p[0]), p[0]["image"])): lines += _adv_lines(it, a) for it, a in sorted(remind, key=lambda p: (_grp(p[0]), p[0]["image"])): lines += _adv_lines(it, a, reminder=True) lines += ["A notice is mailed once, and again every %d days while it stays open. Night of %s." % (REMIND_DAYS, today)] return {"kind": "advisory", "subject": subject, "text": "\n".join(lines)} def not_checked_mail(streaks, today): lines = ["The security watch could not check these sources for two nights or more. Silence from the watch does", 'not mean "safe" while this lasts.', ""] for s, n, what in streaks: lines.append("%s — %d nights in a row:" % (s, n)) lines += [" " + w for w in what[:20]] + ([" … %d more" % (len(what) - 20)] if len(what) > 20 else []) + [""] lines.append("Night of %s. The run log names every failure." % today) return {"kind": "not_checked", "subject": "[Felhom security] the watch could not check %s" % ", ".join(s for s, _, _ in streaks), "text": "\n".join(lines)} def weekly_mail(items, today): def block(title, rows): return ([title] + [" " + r for r in rows] + [""]) if rows else [] lines = ["The weekly summary of the security watch (felhom.eu R-1303): newer versions with no security notice.", ""] for grp, name in ((0, "Box programs and builds"), (1, "Catalog apps"), (2, "DooPlex (homelab) — a list for the homelab session; Felhom changes nothing there")): sel = [it for it in items if _grp(it) == grp] lines += block(name + " — BEHIND:", ["%s %s -> %s (%s)" % (it["image"], it["tag"], it["behind"], it["owner"]) for it in sel if it.get("behind")]) lines += block("Open advisories (mailed already):", sorted({"%s %s — %s" % (it["image"], it["tag"], a["id"]) for it in items for a in it.get("advisories", [])})) lines += block("No machine-readable source (GAP) — not watched for notices:", sorted({"%s — %s" % (it["image"], next((v for k, v in it.get("sources", []) if k == "gap"), "; ".join(it.get("notes", [])))) for it in items if it.get("class") == "GAP"})) lines += block("Floating tags (no exact version to match a notice against):", sorted({"%s:%s (%s)" % (it["image"], it["tag"], it["owner"]) for it in items if it.get("class") == "FLOATING"})) lines += block("NOT CHECKED tonight:", ["%s — %s" % (label(it), "; ".join(f["why"] for f in it["not_checked"])) for it in items if it.get("not_checked")]) counts = {} for it in items: counts[it.get("class", "?")] = counts.get(it.get("class", "?"), 0) + 1 lines.append("Counts: " + ", ".join("%s %d" % kv for kv in sorted(counts.items()))) return {"kind": "weekly", "subject": "[Felhom security] weekly: %d behind, %d advisories open" % ( sum(1 for it in items if it.get("behind")), sum(len(it.get("advisories", [])) for it in items)), "text": "\n".join(lines)} def send(mail, key, test=False): body = json.dumps({"from": MAIL_FROM, "to": [MAIL_TO], "subject": mail["subject"], "text": mail["text"]}).encode() req = urllib.request.Request("https://api.resend.com/emails", data=body, method="POST", headers={ "Authorization": "Bearer %s" % key, "Content-Type": "application/json", # Cloudflare in front of api.resend.com refuses urllib's default agent (403 error 1010, measured 2026-08-02). "User-Agent": "felhom-ci/1.0"}) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r)["id"] # ── the run ────────────────────────────────────────────────────────────────────────────────────────────────────────── def run(items, net, today, rotate, runtime=None): """`runtime`: a reader (cached_runtime_reader) for the images' own runtime parts; None = not looked at (the tests of W1–W9). main() always hands one.""" src = Sources(net, today, rotate) src.runtime = runtime # Box programs first, one by one (they share sources with nothing); apps in a small pool. box = [it for it in items if _grp(it) == 0] apps = [it for it in items if _grp(it) != 0] # catalog apps and the DooPlex list share the pool for it in box: check_item(it, src, today) with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex: list(ex.map(lambda it: check_item(it, src, today), apps)) return items def table(items): order = {"ADVISORY": 0, "NOT CHECKED": 1, "BEHIND": 2, "GAP": 3, "FLOATING": 4, "SELF": 5, "CURRENT": 6} out = [] for it in sorted(items, key=lambda i: (order.get(i["class"], 9), i["group"] == "app", i["image"])): extra = "" if it["class"] == "ADVISORY": extra = "; ".join("%s (fixed %s)" % (a["id"], a["fixed"]) for a in it["advisories"][:4]) if len(it["advisories"]) > 4: extra += "; … %d more" % (len(it["advisories"]) - 4) elif it["class"] == "NOT CHECKED": extra = "; ".join(f["why"] for f in it["not_checked"])[:200] elif it.get("behind"): extra = "newest " + it["behind"] out.append("%-11s %-4s %-46s %-22s %s" % (it["class"], it["group"][:4], it["image"][:46], it["tag"][:22], extra)) return out def main(argv): opts = dict(a[2:].split("=", 1) if "=" in a else (a[2:], "1") for a in argv if a.startswith("--")) today = datetime.date.fromisoformat(opts["today"]) if "today" in opts else datetime.datetime.utcnow().date() key = os.environ.get("RESEND_API_KEY", "") if "test-mail" in opts: it = {"image": "TEST-program", "tag": "1.0.0", "owner": "a test entry", "where": "no file", "group": "box"} a = {"id": "TEST-0000", "summary": "THIS IS A TEST — not a real advisory (R-1303 live proof)", "fixed": "1.0.1", "severity": "none (test)", "source": "test", "link": "https://felhom.eu", "unsure": False} m = advisory_mail([(it, a)], [], today) m["subject"] = "[Felhom security] TEST — not a real advisory: " + m["subject"].split("] ", 1)[1] if not key: print("TEST MAIL NOT SENT: RESEND_API_KEY is empty") return 1 print("TEST-MAIL RESEND-ACCEPTED id=%s subject=%s" % (send(m, key), m["subject"])) return 0 try: items, problems = inv.full_inventory(catalog=opts.get("catalog"), controller=opts.get("controller")) except inv.InventoryError as e: print("THE LIST OF PINS IS UNREADABLE: %s" % e) return 2 token = os.environ.get("GH_ADVISORY_TOKEN", "") net = Net(token) print("security watch %s: %d pins (%d box/build, %d app, %d DooPlex); GitHub key %s" % ( today, len(items), sum(1 for i in items if _grp(i) == 0), sum(1 for i in items if i["group"] == "app"), sum(1 for i in items if i["group"] == "dooplex"), "PRESENT — every repo's advisories tonight" if token else "ABSENT — apps' advisories in a 2-night rotation")) state_path = opts.get("state") prior = {} if state_path and os.path.isfile(state_path): prior = json.load(open(state_path, encoding="utf-8")) rcache = {k: dict(v, _seen=False) for k, v in (prior.get("runtime_cache") or {}).items()} rreader = None if "no-runtime" in opts else cached_runtime_reader(rcache) run(items, net, today, rotate=not token, runtime=rreader) for line in table(items): print(line) counts = {} for it in items: counts[it["class"]] = counts.get(it["class"], 0) + 1 print("COUNTS " + " ".join("%s=%d" % (k.replace(" ", "_"), v) for k, v in sorted(counts.items()))) if rreader: st = rreader.stats print("RUNTIME read=%d (%.0f s) from-cache=%d not-checked=%d zstd-decoder=%s" % ( st["read"], st["read_s"], st["cached"], st["not_checked"], image_runtime.zstd_decoder_how() if st["read"] else "not needed")) print("REQUESTS " + " ".join("%s=%d" % kv for kv in sorted(net.requests.items()))) state_path = opts.get("state") state, missing = {}, True if state_path and os.path.isfile(state_path): state, missing = json.load(open(state_path, encoding="utf-8")), False seed = "seed" in opts or missing mails, new_state, would = plan(items, state, today, seed=seed) # the runtime cache rides the same state file; only digests seen tonight are kept (it cannot grow without bound) new_state["runtime_cache"] = {k: {x: y for x, y in v.items() if x != "_seen"} for k, v in rcache.items() if v.get("_seen")} if missing and "seed" not in opts: lines = ["The watch's memory (%s) was MISSING tonight, so it re-seeded: every advisory open tonight is now" % state_path, "recorded as known, and nothing was mailed as new. Read the list below — it is what is open.", ""] for w in would: lines += [w["subject"], w["text"], ""] mails.append({"kind": "state_missing", "subject": "[Felhom security] the watch's memory was missing — re-seeded", "text": "\n".join(lines) or "(no open advisory)"}) for w in would: print("WOULD HAVE MAILED (seed): %s" % w["subject"]) print(w["text"]) if "json" in opts: json.dump(items, open(opts["json"], "w", encoding="utf-8"), indent=1, ensure_ascii=False, default=str) rc = 0 for m in mails: if "no-mail" in opts: print("MAIL (not sent, --no-mail): %s" % m["subject"]) continue if not key: print("MAIL NOT SENT — RESEND_API_KEY is empty: %s" % m["subject"]) rc = 1 continue try: print("MAIL RESEND-ACCEPTED id=%s kind=%s subject=%s" % (send(m, key), m["kind"], m["subject"])) except Exception as e: # noqa: BLE001 print("MAIL FAILED (%s): %s" % (m["subject"], e)) rc = 1 if state_path and rc == 0: with open(state_path + ".tmp", "w", encoding="utf-8") as f: json.dump(new_state, f, indent=1, sort_keys=True, ensure_ascii=False) f.write("\n") os.replace(state_path + ".tmp", state_path) print("STATE written: %s (%d known advisories)" % (state_path, len(new_state.get("advisories", {})))) return rc if __name__ == "__main__": sys.exit(main(sys.argv[1:]))