#!/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:TAGWHERE` 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:]))