security watch sees runtimes (R-1327) and DooPlex (R-1328); HTTP2Shell rows R-1329–R-1332; R-1326 closed
gates / gates (push) Successful in 3m27s

- scripts/image_runtime.py: Node / Python Brotli / brotlicffi / apk / dpkg read from an image's registry config and
  layers (no pull; HEAD for the nightly digest; zstd via the zstd program where present; quay.io, index.docker.io).
- security_watch.py: a „runtime" source (security_watch_runtimes.json), cached by digest in state.json (154 images,
  filled on DooPlex tonight); ghcr.io tag lists are paged; the DooPlex group (own block, homelab next-step).
- dooplex_inventory.py + documentation/security-watch/dooplex-workloads.txt (89 images, homelab-manifests 5aeec8d).
- Tests W10–W12 (58 checks) + test_image_runtime.py (13), each red-proved.
- audits/http2shell-2026-10-27: the Home Assistant lane walk (proven), HOMELAB-LIST.md (19 to update), the run log.
- 08 §11 design line; 09 §3 decision 276 (HA monthly = same main version; decided by CC unattended).
This commit is contained in:
admin committed 2026-10-27 23:23:02 +01:00
1 parent 503692a1da
commit f5a4995da5
21 files changed
+3498 -29

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""dooplex_inventory.py — the DooPlex (homelab) workload images, as a LIST for the security watch (R-1328). Read only.
WHY. HTTP2Shell (CVE-2026-111497) put a bad Brotli/Node inside about ten public apps on DooPlex, the host that runs the
hub, Gitea, the registry and the backups — and nothing watched DooPlex's apps at all. Felhom does not run DooPlex's
workloads (the homelab session does), so this is a LIST: the watch reads each image (registry tags + the runtime
inside it) and mails the operator; nothing on DooPlex is ever changed from here.
WHAT IT READS. Every `image:` line in the homelab-manifests clone (default ~/git/homelab-manifests; HOMELAB_MANIFESTS
overrides), one line per distinct image:tag, with the files that name it. NOT read: images a Helm chart chooses inside
its own templates (Longhorn, ArgoCD, cert-manager, the ingress controller, …) — they appear in no `image:` line of the
repo; the list file says so in its header.
OUTPUT: documentation/security-watch/dooplex-workloads.txt — `IMAGE:TAG<TAB>WHERE` lines under a dated header. The watch
reads the FILE (CI cannot read the homelab repo); re-run this to refresh it.
USAGE: python3 scripts/dooplex_inventory.py [--check] (--check: exit 1 when the committed list differs from the repo)
"""
import datetime
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(HERE), "documentation", "security-watch", "dooplex-workloads.txt")
SRC = os.environ.get("HOMELAB_MANIFESTS") or os.path.expanduser("~/git/homelab-manifests")
IMAGE_RX = re.compile(r"^\s*-?\s*image:\s*[\"']?([^\"'\s#]+)")
def collect(root):
found = {}
for d, dirs, files in os.walk(root):
dirs[:] = [x for x in dirs if x != ".git"]
for f in files:
if not f.endswith((".yaml", ".yml")):
continue
p = os.path.join(d, f)
try:
lines = open(p, encoding="utf-8", errors="replace").read().splitlines()
except OSError:
continue
for ln in lines:
m = IMAGE_RX.match(ln)
if not m or "{{" in m.group(1) or "$" in m.group(1):
continue
ref = m.group(1).split("@", 1)[0] # a digest pin is kept as its tag (the watch reads the tag)
if ":" not in ref.rsplit("/", 1)[-1]:
ref += ":latest"
found.setdefault(ref, set()).add(os.path.relpath(p, root))
return found
def render(found, head):
out = ["# DooPlex (homelab) workload images — the security watch's „dooplex\" group (R-1328). GENERATED by",
"# scripts/dooplex_inventory.py from homelab-manifests %s — read only; nothing on DooPlex is changed from" % head,
"# here. NOT in this list: images a Helm chart picks inside its own templates (Longhorn, ArgoCD, cert-manager,",
"# the ingress controller and the like) — they are in no `image:` line of the repo."]
for ref in sorted(found):
out.append("%s\t%s" % (ref, ", ".join(sorted(found[ref]))[:300]))
return "\n".join(out) + "\n"
def main(argv):
if not os.path.isdir(SRC):
print("homelab-manifests not found at %s" % SRC)
return 2
head = subprocess.run(["git", "-C", SRC, "log", "-1", "--format=%h (%cd)", "--date=short"], capture_output=True,
text=True).stdout.strip() or "(no git)"
text = render(collect(SRC), head)
if "--check" in argv:
old = open(OUT, encoding="utf-8").read() if os.path.isfile(OUT) else ""
strip = lambda t: [l for l in t.splitlines() if not l.startswith("#")] # noqa: E731
same = strip(old) == strip(text)
print("dooplex list %s" % ("is current" if same else "DIFFERS from homelab-manifests %s" % head))
return 0 if same else 1
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
f.write(text)
print("wrote %s: %d images (homelab-manifests %s, %s)" % (OUT, text.count("\n") - 4, head,
datetime.date.today().isoformat()))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""image_runtime.py — which runtime parts are INSIDE an image, read from its registry without a pull (R-1327).
WHY. HTTP2Shell (CVE-2026-111497, 2026-10-21) is a bug in the Brotli decoder that Node.js and Python's `brotli` /
`brotlicffi` carry. An app's own security notices never mention it — the app did nothing wrong — so the nightly watch
(security_watch.py, R-1303) read every app notice and still could not see that Home Assistant and RomM on the test
boxes ran the bad code. This module reads what the image itself carries:
1. the image CONFIG (one small JSON blob): `NODE_VERSION`, `PYTHON_VERSION` in its Env;
2. only where that does not answer, the LAYERS, streamed top-down and never stored: the Alpine package database
(`lib/apk/db/installed`), the Debian one (`var/lib/dpkg/status`), Node's `include/node/node_version.h`, and the
`brotli-*` / `brotlicffi-*` `.dist-info` folders of Python. The topmost layer that holds a file wins (a later layer
replaces an earlier one's copy).
What it cannot read is said, never guessed: a registry it has no token route for, a zstd layer where no `zstd` program exists (Python 3.13 has no zstd),
a layer over the byte budget — each makes the result `unchecked` with its reason, and the watch prints NOT CHECKED.
USAGE (also a library: `read_image(ref)` -> dict)
python3 scripts/image_runtime.py IMAGE[:TAG] ... [--budget-mb=N] [--json]
"""
import json
import re
import shutil
import subprocess
import sys
import zlib
import tarfile
import urllib.error
import urllib.request
UA = "felhom-image-runtime/1.0 (+https://felhom.eu)"
ACCEPT = ", ".join([
"application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json"])
DEFAULT_BUDGET = int(__import__("os").environ.get("IMAGE_RUNTIME_BUDGET_MB", "1500")) * 1024 * 1024
# ^ bytes of compressed layers one image may stream before the rest is unchecked (the filling run on DooPlex raises it)
NODE_H = re.compile(r"(^|/)usr/(local/)?include/node/node_version\.h$")
PYDIST = re.compile(r"site-packages/(brotli|Brotli|brotlicffi)-([0-9][0-9A-Za-z.\-+]*)\.dist-info/(METADATA|RECORD)$")
class Unreadable(Exception):
pass
def split_ref(ref):
"""'rommapp/romm:5.4.1' -> (registry, path, tag). lscr.io is Docker Hub's mirror (the watch's rule)."""
tag = "latest"
name = ref
if "@" in name:
name, tag = name.split("@", 1)
elif re.search(r":[^/]+$", name):
name, tag = name.rsplit(":", 1)
first = name.split("/", 1)[0]
if "." in first or ":" in first:
reg, path = first, name.split("/", 1)[1]
else:
reg, path = "docker.io", name
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, tag
def _get(url, headers=None, stream=False):
req = urllib.request.Request(url, headers=dict({"User-Agent": UA}, **(headers or {})))
try:
r = urllib.request.urlopen(req, timeout=60)
except urllib.error.HTTPError as e:
return e.code, e.headers, b""
if stream:
return r.status, r.headers, r
return r.status, r.headers, r.read()
class Registry:
BASES = {"docker.io": "https://registry-1.docker.io", "ghcr.io": "https://ghcr.io", "quay.io": "https://quay.io"}
def __init__(self, reg, path):
if reg not in self.BASES:
raise Unreadable("registry %s: no anonymous token route" % reg)
self.base, self.path = self.BASES[reg], path
hdr = {}
if reg == "docker.io":
url = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull" % path
# Optional Docker Hub login (the filling run on DooPlex: a login has its own pull limit; CI stays anonymous).
# The NAMES of the variables are read here; the values are never printed.
import base64
import os
u, p = os.environ.get("DOCKERHUB_USER", ""), os.environ.get("DOCKERHUB_TOKEN", "")
if u and p:
hdr = {"Authorization": "Basic " + base64.b64encode(("%s:%s" % (u, p)).encode()).decode()}
elif reg == "quay.io":
url = "https://quay.io/v2/auth?service=quay.io&scope=repository:%s:pull" % path
else:
url = "https://ghcr.io/token?scope=repository:%s:pull" % path
st, _h, body = _get(url, hdr)
if st == 401 and hdr:
st, _h, body = _get(url) # a login that is not Docker Hub's: anonymous, as before
if st != 200:
raise Unreadable("%s token for %s: HTTP %s" % (reg, path, st))
self.auth = {"Authorization": "Bearer %s" % json.loads(body).get("token", "")}
def manifest(self, ref):
st, h, body = _get("%s/v2/%s/manifests/%s" % (self.base, self.path, ref), dict(self.auth, Accept=ACCEPT))
if st != 200:
raise Unreadable("manifest %s:%s: HTTP %s" % (self.path, ref, st))
return json.loads(body), h.get("Docker-Content-Digest", "")
def blob(self, digest, stream=False):
st, _h, body = _get("%s/v2/%s/blobs/%s" % (self.base, self.path, digest), self.auth, stream=stream)
if st != 200:
raise Unreadable("blob %s: HTTP %s" % (digest[:19], st))
return body
def _platform_manifest(reg, ref):
m, digest = reg.manifest(ref)
if "manifests" in m: # an index: take linux/amd64 (every Felhom box is amd64)
pick = [x for x in m["manifests"] if (x.get("platform") or {}).get("os") == "linux"
and (x.get("platform") or {}).get("architecture") == "amd64"]
if not pick:
raise Unreadable("no linux/amd64 image in the index")
digest = pick[0]["digest"]
m, _ = reg.manifest(digest)
return m, digest
def parse_apk(text):
out, name = {}, None
for line in text.splitlines():
if line.startswith("P:"):
name = line[2:]
elif line.startswith("V:") and name:
out[name] = line[2:]
return out
def parse_dpkg(text):
out = {}
for block in text.split("\n\n"):
p = re.search(r"^Package: (\S+)", block, re.M)
v = re.search(r"^Version: (\S+)", block, re.M)
if p and v:
out[p.group(1)] = v.group(1)
return out
def parse_node_h(text):
parts = [re.search(r"#define NODE_%s_VERSION (\d+)" % k, text) for k in ("MAJOR", "MINOR", "PATCH")]
return ".".join(p.group(1) for p in parts) if all(parts) else None
def scan_layers(reg, layers, budget=DEFAULT_BUDGET):
"""Stream layers top-down. Returns (found, notes): found holds apk/dpkg/node_h/pydist; the first (topmost) wins."""
found, notes, spent = {}, [], 0
for layer in reversed(layers):
mt, size = layer.get("mediaType", ""), int(layer.get("size") or 0)
zstd = "zstd" in mt
if zstd and not shutil.which("zstd"):
notes.append("zstd layer %s: no zstd program here" % layer["digest"][:19])
continue
if spent + size > budget:
notes.append("byte budget reached before layer %s (%d MB)" % (layer["digest"][:19], size >> 20))
break
spent += size
resp = reg.blob(layer["digest"], stream=True)
proc = None
try:
if zstd: # Python 3.13 has no zstd: the zstd program decompresses the stream (a pipe, nothing on disk)
proc = subprocess.Popen(["zstd", "-dc"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
import threading
def feed(src=resp, dst=proc.stdin):
try:
for chunk in iter(lambda: src.read(1 << 20), b""):
dst.write(chunk)
except (OSError, ValueError):
pass
finally:
try:
dst.close()
except OSError:
pass
threading.Thread(target=feed, daemon=True).start()
fileobj, mode = proc.stdout, "r|"
else:
fileobj = resp
mode = "r|" if "tar" in mt and "gzip" not in mt and "gz" not in mt else "r|gz"
with tarfile.open(fileobj=fileobj, mode=mode) as tf:
for m in tf:
n = m.name.lstrip("./")
if n == "lib/apk/db/installed" and "apk" not in found and m.isfile():
found["apk"] = parse_apk(tf.extractfile(m).read().decode("utf-8", "replace"))
elif n == "var/lib/dpkg/status" and "dpkg" not in found and m.isfile():
found["dpkg"] = parse_dpkg(tf.extractfile(m).read().decode("utf-8", "replace"))
elif NODE_H.search(n) and "node_h" not in found and m.isfile():
found["node_h"] = parse_node_h(tf.extractfile(m).read().decode("utf-8", "replace"))
else:
pm = PYDIST.search(n)
if pm:
key = pm.group(1).lower()
found.setdefault("pydist", {}).setdefault(key, pm.group(2))
except (tarfile.TarError, OSError, EOFError, zlib.error) as e:
notes.append("layer %s unreadable: %s" % (layer["digest"][:19], type(e).__name__))
finally:
if proc:
proc.kill()
proc.wait()
try:
resp.close()
except Exception:
pass
return found, notes
def _head(url, headers):
req = urllib.request.Request(url, method="HEAD", headers=dict({"User-Agent": UA}, **headers))
try:
r = urllib.request.urlopen(req, timeout=60)
return r.status, r.headers
except urllib.error.HTTPError as e:
return e.code, e.headers
def resolve_digest(ref):
"""The digest the TAG points at now (the index's, for a multi-arch image) — a HEAD request, which Docker Hub does
not count against its anonymous pull limit (a GET of every manifest every night hit HTTP 429 on the first run,
2026-10-27). The cache is keyed by it: a changed tag is a new key. Raises Unreadable."""
r, path, tag = split_ref(ref)
reg = Registry(r, path)
st, h = _head("%s/v2/%s/manifests/%s" % (reg.base, path, tag), dict(reg.auth, Accept=ACCEPT))
if st != 200 or not h.get("Docker-Content-Digest"):
raise Unreadable("manifest %s:%s: HTTP %s" % (path, tag, st))
return h["Docker-Content-Digest"]
def read_image(ref, budget=DEFAULT_BUDGET, layers=True):
"""{'ref', 'digest', 'env': {...}, 'node', 'python', 'brotli', 'brotlicffi', 'apk', 'dpkg', 'unchecked': [...]}.
`node` comes from NODE_VERSION, else node_version.h, else the apk/dpkg `nodejs` package. `unchecked` lists every
reason a part could not be read — an empty list means everything this module looks for was looked for."""
out = {"ref": ref, "unchecked": []}
try:
r, path, tag = split_ref(ref)
reg = Registry(r, path)
m, digest = _platform_manifest(reg, tag)
out["digest"] = digest
cfg = json.loads(reg.blob(m["config"]["digest"]))
env = dict(e.split("=", 1) for e in ((cfg.get("config") or {}).get("Env") or []) if "=" in e)
out["env"] = {k: env[k] for k in ("NODE_VERSION", "PYTHON_VERSION") if k in env}
out["node"] = env.get("NODE_VERSION", "").lstrip("v") or None
out["python"] = env.get("PYTHON_VERSION") or None
if layers:
found, notes = scan_layers(reg, m.get("layers") or [], budget)
out["unchecked"] += notes
apk, dpkg = found.get("apk") or {}, found.get("dpkg") or {}
out["apk"] = {k: v for k, v in apk.items() if re.match(r"^(nodejs|nodejs-current|brotli|brotli-libs|python3)$", k)}
out["dpkg"] = {k: v for k, v in dpkg.items() if re.match(r"^(nodejs|libbrotli1|python3(\.\d+)?)$", k)}
if not out["node"]:
out["node"] = found.get("node_h") or (apk.get("nodejs") or apk.get("nodejs-current") or "").split("-r")[0] \
or (dpkg.get("nodejs") or "").split("-")[0].split("+")[0].split("~")[0] or None
py = found.get("pydist") or {}
out["brotli"], out["brotlicffi"] = py.get("brotli"), py.get("brotlicffi")
except Unreadable as e:
out["unchecked"].append(str(e))
except (urllib.error.URLError, OSError, ValueError, KeyError) as e:
out["unchecked"].append("%s: %s" % (type(e).__name__, str(e)[:120]))
return out
def main(argv):
budget = DEFAULT_BUDGET
refs, as_json = [], False
for a in argv[1:]:
if a.startswith("--budget-mb="):
budget = int(a.split("=", 1)[1]) * 1024 * 1024
elif a == "--json":
as_json = True
else:
refs.append(a)
res = [read_image(r, budget) for r in refs]
if as_json:
print(json.dumps(res, indent=1))
else:
for x in res:
print("%-55s node=%-9s brotli=%-7s brotlicffi=%-8s apk=%s dpkg=%s%s" % (
x["ref"], x.get("node"), x.get("brotli"), x.get("brotlicffi"), x.get("apk", {}), x.get("dpkg", {}),
(" UNCHECKED: " + "; ".join(x["unchecked"])) if x["unchecked"] else ""))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+134 -20
View File
@@ -21,6 +21,10 @@ WHAT IT READS (the spike that chose these: documentation/audits/security-watch-2
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):
@@ -36,7 +40,7 @@ state.json; the reasons in the workflow). A missing state file means: seed (reco
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=<file> [--catalog=<dir>] [--controller=<dir>] [--seed] [--no-mail]
python3 scripts/security_watch.py --state=<file> [--catalog=<dir>] [--controller=<dir>] [--seed] [--no-mail] [--no-runtime]
[--test-mail] [--today=YYYY-MM-DD] [--json=<results file>]
Exit: 0 the run finished (whatever it found) · 1 a mail could not be sent · 2 the list of pins is unreadable.
"""
@@ -54,6 +58,7 @@ 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"
@@ -219,6 +224,78 @@ def ghsa_affects(adv, ours, repo=""):
# ── 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
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
return hit
res = read(ref)
if digest and not res.get("unchecked"):
cache[digest] = {k: v for k, v in res.items() if k != "ref"}
cache[digest]["_seen"] = True
return res
return reader
class Sources:
def __init__(self, net, today, rotate):
self.net, self.today, self.rotate = net, today, rotate
@@ -348,11 +425,16 @@ class Sources:
if st != 200:
raise SourceError("ghcr.io token for %s: HTTP %s" % (path, st))
tok = json.loads(body).get("token", "")
st, _h, body = self.net.get("https://ghcr.io/v2/%s/tags/list?n=10000" % path,
{"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 []
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
@@ -365,7 +447,7 @@ def registry_of(image):
reg, path = first, image.split("/", 1)[1] if "/" in image else ""
else:
reg, path = "docker.io", image
if reg == "lscr.io":
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
@@ -496,6 +578,15 @@ def check_item(it, src, today):
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"])})
@@ -608,6 +699,13 @@ def plan(items, state, today, seed=False):
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)"),
@@ -617,15 +715,17 @@ def _adv_lines(it, a, reminder=False):
" 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 security-fix lane (B23) — capability map B23; for a box program, a controller release",
" 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 it["group"] != "app"]
box = [it for it, _ in allp if _grp(it) == 0]
names = []
for it, _ in sorted(allp, key=lambda p: (p[0]["group"] == "app", p[0]["image"])):
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)
@@ -634,9 +734,9 @@ def advisory_mail(new, remind, today):
", %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: (p[0]["group"] == "app", p[0]["image"])):
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: (p[0]["group"] == "app", p[0]["image"])):
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)}
@@ -657,8 +757,9 @@ 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 (("box", "Box programs and builds"), ("app", "Catalog apps")):
sel = [it for it in items if (it["group"] == "app") == (grp == "app")]
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"])
@@ -691,11 +792,14 @@ def send(mail, key, test=False):
# ── the run ──────────────────────────────────────────────────────────────────────────────────────────────────────────
def run(items, net, today, rotate):
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 it["group"] != "app"]
apps = [it for it in items if it["group"] == "app"]
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:
@@ -742,10 +846,17 @@ def main(argv):
return 2
token = os.environ.get("GH_ADVISORY_TOKEN", "")
net = Net(token)
print("security watch %s: %d pins (%d box/build, %d app); GitHub key %s" % (
today, len(items), sum(1 for i in items if i["group"] != "app"), sum(1 for i in items if i["group"] == "app"),
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"))
run(items, net, today, rotate=not token)
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()}
run(items, net, today, rotate=not token,
runtime=None if "no-runtime" in opts else cached_runtime_reader(rcache))
for line in table(items):
print(line)
counts = {}
@@ -759,6 +870,9 @@ def main(argv):
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.", ""]
+29 -4
View File
@@ -230,10 +230,35 @@ def judge(items):
return problems
DOOPLEX_LIST = os.path.join(FELHOM, "documentation", "security-watch", "dooplex-workloads.txt")
def dooplex_items(path=DOOPLEX_LIST, catalog_sources=None):
"""The DooPlex (homelab) workloads (R-1328) — a LIST from scripts/dooplex_inventory.py, group „dooplex": read and
mailed to the operator only; nothing on DooPlex is changed from here. An image the catalog also ships reuses the
catalog's upstream line; any other is a stated gap (the watch still reads its registry tags and its runtime)."""
if not os.path.isfile(path):
return []
items = []
for ln in _read(path).splitlines():
if not ln.strip() or ln.startswith("#"):
continue
ref, _, where = ln.partition("\t")
image, tag = split_ref(ref.strip())
items.append({"group": "dooplex", "owner": where.strip()[:80] or "DooPlex", "image": image, "tag": tag,
"where": "DooPlex (homelab)", "sources_text": (catalog_sources or {}).get(image) or
"gap:DooPlex workload — no notice source mapped; registry and runtime only"})
return items
def full_inventory(felhom_root=FELHOM, catalog=None, controller=None):
catalog = catalog or os.path.join(WORKSPACE, "app-catalog-felhom.eu")
controller = controller or os.path.join(WORKSPACE, "felhom-controller")
items = controller_items(controller) + hub_items(felhom_root) + catalog_items(catalog)
apps = catalog_items(catalog)
items = controller_items(controller) + hub_items(felhom_root) + apps
# the catalog's upstream lines, keyed by image, for the DooPlex images that are the same program
cat = {it["image"]: it["sources_text"] for it in apps if it.get("sources_text")}
items += dooplex_items(catalog_sources=cat)
problems = judge(items)
return items, problems
@@ -277,9 +302,9 @@ def main(argv):
for it in items:
print("%-6s %-22s %-48s %-24s %s" % (it["group"], it["owner"][:22], it["image"][:48], it["tag"][:24],
it.get("sources_text") or "-- MISSING --"))
print("%d pins (%d box/build, %d app); %d problems" % (
len(items), sum(1 for i in items if i["group"] != "app"),
sum(1 for i in items if i["group"] == "app"), len(problems)))
print("%d pins (%d box/build, %d app, %d DooPlex); %d problems" % (
len(items), sum(1 for i in items if i["group"] not in ("app", "dooplex")),
sum(1 for i in items if i["group"] == "app"), sum(1 for i in items if i["group"] == "dooplex"), len(problems)))
return 1 if problems else 0
print(__doc__)
return 2
+31
View File
@@ -0,0 +1,31 @@
{
"_comment": [
"The runtime table of the security watch (R-1327): runtime parts that ship INSIDE an app or box image and have",
"their own security notices, which the app's notices never repeat. One row per part. `fixed` maps a release LINE",
"(the leading numbers, as many as the row's `line_parts`) to the first fixed version on that line; a line below the",
"highest listed line that has no entry is END OF LIFE (no fix will come — the advisory says so); a line above it",
"is newer than the notice and not affected. `from` is the first affected version. `groups` limits a row to some pin",
"groups (the OS packages: our own box/build images only — R-1307's scope; for apps the image's own runtime rows",
"apply). Facts of 2026-10-27, CVE-2026-111497 (HTTP2Shell / Node2Shell), from the CVE record, the Node.js security",
"release of 2026-10-21 and PYSEC-2026-4322/4323 — read in the session, sources in the night report."
],
"rows": [
{"part": "node", "label": "Node.js", "id": "CVE-2026-111497", "severity": "critical", "line_parts": 1,
"from": "0", "fixed": {"22": "22.23.4", "24": "24.22.1", "26": "26.12.1"},
"summary": "Brotli decoder out-of-bounds write (HTTP2Shell): a crafted compressed body can run code in the process",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497", "published": "2026-10-21"},
{"part": "brotli", "label": "Python Brotli", "id": "CVE-2026-111497", "severity": "critical", "line_parts": 2,
"from": "1.0.3", "fixed": {"1.0": "1.0.10", "1.1": "1.1.1", "1.2": "1.2.1"},
"summary": "Brotli decoder out-of-bounds write (HTTP2Shell), PYSEC-2026-4322",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497", "published": "2026-10-21"},
{"part": "brotlicffi", "label": "Python brotlicffi", "id": "CVE-2026-111497", "severity": "critical", "line_parts": 2,
"from": "0", "fixed": {"1.2": "1.2.1.0"},
"summary": "Brotli decoder out-of-bounds write (HTTP2Shell), PYSEC-2026-4323",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497", "published": "2026-10-21"},
{"part": "apk:brotli-libs", "label": "Alpine brotli-libs", "id": "CVE-2026-111497", "severity": "critical",
"line_parts": 2, "from": "1.0.3", "fixed": {"1.0": "1.0.10", "1.1": "1.1.1", "1.2": "1.2.1"},
"groups": ["box", "build"],
"summary": "the Brotli library of the image's Alpine base (HTTP2Shell)",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497", "published": "2026-10-21"}
]
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""test_image_runtime.py — the image reader's parsers (R-1327) and the DooPlex list maker (R-1328). No network.
Pinned by consequence: an Alpine and a Debian package database read back the packages the runtime table matches; Node's
version header gives the version; an image reference splits the way the watch's registry_of does (lscr.io is Docker
Hub; a bare name is library/); a tar layer streamed top-down returns the TOPMOST copy of a file and the Python
`.dist-info` versions; the DooPlex list keeps one line per image:tag with every file that names it, and skips templated
and variable images. Red-proof: a scan that keeps the FIRST (bottom) copy instead of the topmost fails `topmost`.
"""
import io
import os
import sys
import tarfile
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import image_runtime as ir # noqa: E402
import dooplex_inventory as di # noqa: E402
fails, ran = [], 0
def check(name, cond, detail=""):
global ran
ran += 1
print(" %s %s%s" % ("ok" if cond else "XX", name, "" if cond else " %s" % (detail,)))
if not cond:
fails.append(name)
def tar_bytes(files):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, data in files.items():
ti = tarfile.TarInfo(name)
ti.size = len(data)
tf.addfile(ti, io.BytesIO(data))
return buf.getvalue()
class FakeReg:
def __init__(self, blobs):
self.blobs = blobs
def blob(self, digest, stream=False):
return io.BytesIO(self.blobs[digest])
def main():
apk = ir.parse_apk("P:musl\nV:1.2.5-r10\n\nP:nodejs\nV:24.18.1-r0\n\nP:brotli-libs\nV:1.2.0-r1\n")
check("apk database -> nodejs and brotli-libs", apk.get("nodejs") == "24.18.1-r0" and apk.get("brotli-libs") == "1.2.0-r1", apk)
dpkg = ir.parse_dpkg("Package: libbrotli1\nStatus: install ok installed\nVersion: 1.0.9-2+b6\n\nPackage: nodejs\nVersion: 20.20.0-1nodesource1\n")
check("dpkg database -> libbrotli1 and nodejs", dpkg == {"libbrotli1": "1.0.9-2+b6", "nodejs": "20.20.0-1nodesource1"}, dpkg)
h = "#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 22\n#define NODE_PATCH_VERSION 1\n"
check("node_version.h -> 24.22.1", ir.parse_node_h(h) == "24.22.1", ir.parse_node_h(h))
check("split_ref: lscr.io is Docker Hub, tag kept", ir.split_ref("lscr.io/linuxserver/qbittorrent:5.3.0") ==
("docker.io", "linuxserver/qbittorrent", "5.3.0"))
check("split_ref: a bare name is library/, no tag = latest", ir.split_ref("redis") == ("docker.io", "library/redis", "latest"))
check("split_ref: index.docker.io is Docker Hub", ir.split_ref("index.docker.io/plexinc/pms-docker:1.43") ==
("docker.io", "plexinc/pms-docker", "1.43"))
check("split_ref: ghcr.io with a port-free path", ir.split_ref("ghcr.io/home-assistant/home-assistant:2026.9.4") ==
("ghcr.io", "home-assistant/home-assistant", "2026.9.4"))
# two layers: the bottom one has an OLD apk db and brotli 1.1.0; the top one replaces the db and adds brotli 1.2.1
bottom = tar_bytes({"lib/apk/db/installed": b"P:nodejs\nV:24.18.1-r0\n",
"usr/lib/python3/site-packages/Brotli-1.1.0.dist-info/METADATA": b"x"})
top = tar_bytes({"lib/apk/db/installed": b"P:nodejs\nV:24.22.1-r0\n",
"usr/local/lib/python3.12/site-packages/brotli-1.2.1.dist-info/RECORD": b"x"})
layers = [{"digest": "sha256:bottom", "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": len(bottom)},
{"digest": "sha256:top", "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": len(top)}]
found, notes = ir.scan_layers(FakeReg({"sha256:bottom": bottom, "sha256:top": top}), layers)
check("topmost: the top layer's apk db wins", (found.get("apk") or {}).get("nodejs") == "24.22.1-r0", found.get("apk"))
check("topmost: the top layer's Brotli dist-info wins", (found.get("pydist") or {}).get("brotli") == "1.2.1", found.get("pydist"))
check("no note for readable gzip layers", notes == [], notes)
found, notes = ir.scan_layers(FakeReg({}), [{"digest": "sha256:z", "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
"size": 10}], budget=0)
check("a layer over the byte budget is a NOTE, never a silent pass", notes and ("budget" in notes[0] or "zstd" in notes[0]), notes)
d = tempfile.mkdtemp()
open(os.path.join(d, "a.yaml"), "w").write("spec:\n containers:\n - image: rommapp/romm:5.4.0\n name: x\n"
" - image: '{{ .Values.image }}'\n - image: $IMG\n")
open(os.path.join(d, "b.yml"), "w").write("image: rommapp/romm:5.4.0\nimage: busybox\n")
got = di.collect(d)
check("dooplex: one line per image:tag with every file", got.get("rommapp/romm:5.4.0") == {"a.yaml", "b.yml"}, got)
check("dooplex: a bare image becomes :latest; templated and variable images are skipped",
set(got) == {"rommapp/romm:5.4.0", "busybox:latest"}, set(got))
print()
if fails:
print("image_runtime tests FAILED: %s" % ", ".join(fails))
return 1
print("image_runtime tests OK — %d checks" % ran)
return 0
if __name__ == "__main__":
sys.exit(main())
+118
View File
@@ -14,6 +14,13 @@ What is pinned, each by its consequence (the mail or the class), not by its mech
would have mailed
W8 the inventory: an image with no upstream line refuses (the gate), a gap needs a reason, the grammar
W9 a GitHub Actions advisory is not listed; an empty fix with a range ending at X reads „after X", not „no fix"
W10 the RUNTIME inside an image (R-1327, HTTP2Shell CVE-2026-111497): Node 24.18.1 -> ADVISORY from „runtime" with
the fixed 24.22.1; 24.22.1 -> none (negative control); Python Brotli 1.2.0 -> ADVISORY, 1.2.1 -> none; Node 20 (no
fix on its line) -> ADVISORY; a layer read error -> NOT CHECKED; a registry with no route -> a note, not a
failure; the result is cached by digest (a second night reads nothing); main() wires the reader
W12 the DooPlex (homelab) group (R-1328): read from its list file; a DooPlex advisory names the homelab session, never
the catalog's lane; a DooPlex image in a private registry is NOT CHECKED with the reason; its own weekly block
W11 ghcr.io tag lists are PAGED (Link: rel="next"): every page is read, so a newer tag on page 2 is seen
Red-proofs (2026-10-26): W5 with the `k not in known` test removed (every night mails) -> W5 fails; W6 with the streak
threshold at 1 -> W6 fails; W2 with FEED_DAYS check removed -> W2's old-item case fails; W4 with SourceError swallowed
-> W4 fails. W9 (2026-10-27, R-1310) was seen failing before its two rules existed (2 of 4 checks red, the two
@@ -323,11 +330,122 @@ def t_ranges():
a is True and fixed == "no fixed version named yet", (a, fixed))
def t_runtime():
print("-- W10: the runtime inside an image")
reads = []
def reader_for(answers):
def read(ref):
reads.append(ref)
return dict(answers[ref], ref=ref)
return read
answers = {
"ex/node-old:1.0.0": {"digest": "sha256:a", "node": "24.18.1", "unchecked": []},
"ex/node-new:1.0.0": {"digest": "sha256:b", "node": "24.22.1", "unchecked": []},
"ex/py-old:1.0.0": {"digest": "sha256:c", "brotli": "1.2.0", "unchecked": []},
"ex/py-new:1.0.0": {"digest": "sha256:d", "brotli": "1.2.1", "unchecked": []},
"ex/node-eol:1.0.0": {"digest": "sha256:e", "node": "20.19.5", "unchecked": []},
"ex/broken:1.0.0": {"digest": "sha256:f", "unchecked": ["layer sha256:1234 unreadable: ReadError"]},
"ex/private:1.0.0": {"unchecked": ["registry gitea.dooplex.hu: no anonymous token route"]},
}
items = [item(r.split(":")[0], "1.0.0", "self", group="app") for r in answers]
for it in items:
it["sources"] = []
routes = {"https://hub.docker.com/v2/repositories/": TAGS("1.0.0")}
w.run(items, FakeNet(routes), TODAY, False, runtime=reader_for(answers))
by = {it["image"]: it for it in items}
a = by["ex/node-old"]["advisories"]
check("W10 Node 24.18.1 -> ADVISORY from runtime, CVE-2026-111497, fixed 24.22.1",
by["ex/node-old"]["class"] == "ADVISORY" and a and a[0]["source"] == "runtime" and
a[0]["id"] == "CVE-2026-111497" and "24.22.1" in a[0]["fixed"], (by["ex/node-old"]["class"], a))
check("W10 negative control: Node 24.22.1 -> no advisory", by["ex/node-new"]["class"] == "CURRENT",
(by["ex/node-new"]["class"], by["ex/node-new"]["advisories"]))
check("W10 Python Brotli 1.2.0 -> ADVISORY, fixed 1.2.1", by["ex/py-old"]["class"] == "ADVISORY" and
"1.2.1" in by["ex/py-old"]["advisories"][0]["fixed"], by["ex/py-old"]["advisories"])
check("W10 negative control: Brotli 1.2.1 -> no advisory", by["ex/py-new"]["class"] == "CURRENT", by["ex/py-new"]["class"])
check("W10 Node 20 (no fixed release on its line) -> ADVISORY naming the end of life",
by["ex/node-eol"]["class"] == "ADVISORY" and "end of life" in by["ex/node-eol"]["advisories"][0]["summary"],
by["ex/node-eol"]["advisories"])
check("W10 a layer read error -> NOT CHECKED with source runtime", by["ex/broken"]["class"] == "NOT CHECKED" and
by["ex/broken"]["not_checked"][0]["source"] == "runtime", (by["ex/broken"]["class"], by["ex/broken"]["not_checked"]))
check("W10 a registry with no route -> a note, not a failure", by["ex/private"]["class"] == "CURRENT" and
any("runtime" in n for n in by["ex/private"]["notes"]), (by["ex/private"]["class"], by["ex/private"]["notes"]))
# the cache: a second night with the same digests reads no layer
cache, resolved, full = {}, [], []
def resolve(ref):
resolved.append(ref)
return answers[ref].get("digest")
def read_full(ref):
full.append(ref)
return dict(answers[ref], ref=ref)
r = w.cached_runtime_reader(cache, resolve=resolve, read=read_full)
r("ex/node-old:1.0.0")
r("ex/node-old:1.0.0")
check("W10 the result is cached by digest: one full read for two nights", full == ["ex/node-old:1.0.0"] and
len(resolved) == 2 and "sha256:a" in cache, (full, resolved, list(cache)))
r("ex/broken:1.0.0")
check("W10 an UNCHECKED result is not cached (tomorrow tries again)", "sha256:f" not in cache, list(cache))
src = open(os.path.join(HERE, "security_watch.py"), encoding="utf-8").read()
check("W10 main() hands the reader to run() (off only with --no-runtime)",
"runtime=None if \"no-runtime\" in opts else cached_runtime_reader(rcache))" in src)
def t_ghcr_pages():
print("-- W11: ghcr.io tags are paged")
routes = {
"https://ghcr.io/token": (200, {}, {"token": "t"}),
"https://ghcr.io/v2/ex/app/tags/list?n=10000": (200, {"Link": '</v2/ex/app/tags/list?last=1.0.0&n=10000>; rel="next"'},
{"tags": ["0.9.0", "1.0.0"]}),
"https://ghcr.io/v2/ex/app/tags/list?last=1.0.0": (200, {}, {"tags": ["1.0.1", "1.1.0"]}),
}
src = w.Sources(FakeNet(routes), TODAY, False)
names = src.tags("ghcr.io/ex/app")
check("W11 both pages are read (1.1.0 is on page 2)", names == ["0.9.0", "1.0.0", "1.0.1", "1.1.0"], names)
def t_dooplex():
print("-- W12: the DooPlex group")
d = tempfile.mkdtemp()
try:
f = os.path.join(d, "dooplex-workloads.txt")
open(f, "w").write("# header\nrommapp/romm:5.4.0\tromm-system/romm.yaml\ngitea.dooplex.hu/admin/jarr:latest\tjarr/x.yaml\n")
items = inv.dooplex_items(f, catalog_sources={"rommapp/romm": "github:rommapp/romm"})
by = {i["image"]: i for i in items}
check("W12 the list file is read, group dooplex, the catalog's sources reused",
len(items) == 2 and by["rommapp/romm"]["group"] == "dooplex" and
by["rommapp/romm"]["sources_text"] == "github:rommapp/romm", items)
check("W12 an image the catalog does not ship is a stated gap (runtime + registry only)",
(by["gitea.dooplex.hu/admin/jarr"]["sources_text"] or "").startswith("gap:"), by["gitea.dooplex.hu/admin/jarr"])
finally:
shutil.rmtree(d)
it = {"image": "rommapp/romm", "tag": "5.4.0", "owner": "romm-system/romm.yaml", "where": "dooplex", "group": "dooplex"}
a = {"id": "CVE-2026-111497", "summary": "Node.js 24.18.1 inside the image", "fixed": "24.22.1", "severity": "critical",
"source": "runtime", "link": "x", "unsure": False}
m = w.advisory_mail([(it, a)], [], TODAY)
check("W12 a DooPlex advisory names the homelab session and not the lane", "homelab" in m["text"] and
"security-fix lane" not in m["text"].split("rommapp/romm")[1], m["text"][-400:])
priv = [item("gitea.dooplex.hu/admin/jarr", "latest", "gap:DooPlex workload", group="dooplex")]
priv[0]["sources"] = [("gap", "DooPlex workload")]
w.run(priv, FakeNet({}), TODAY, False, runtime=lambda ref: {"ref": ref, "unchecked": [
"registry gitea.dooplex.hu: no anonymous token route"]})
check("W12 a DooPlex image in a private registry -> NOT CHECKED with the reason", priv[0]["class"] == "NOT CHECKED" and
"gitea.dooplex.hu" in priv[0]["not_checked"][0]["why"], (priv[0]["class"], priv[0]["not_checked"]))
wk = w.weekly_mail([dict(it, behind="5.4.1", **{"class": "BEHIND"})], TODAY)
check("W12 the weekly mail has its own DooPlex block", "DooPlex (homelab)" in wk["text"], wk["text"][:300])
def main():
t_ranges()
t_classes()
t_memory()
t_inventory()
for t in (t_runtime, t_ghcr_pages, t_dooplex):
try:
t()
except Exception as e: # noqa: BLE001 — a missing feature is a red check, not a crash
check("%s ran" % t.__name__, False, "%s: %s" % (type(e).__name__, e))
print()
if fails:
for f in fails: