diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index ab67c4ed..dbd93e72 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2026-10-28 — the watch remembers a notice per GROUP: a catalog pin and a DooPlex pin of one image are two entries + +- **`security_watch.py`:** the memory key is `dooplex:|` for a DooPlex pin (`mem_image`), `|` as + before for the rest. Before, the catalog's RomM 5.4.1 and DooPlex's RomM 5.4.0 shared one key, `plan()` kept the later + item, and a new notice on both was mailed in ONE group's block only (the homelab session or the catalog work, never + both). A memory written before this is copied once to the DooPlex key, so the first night after the change mails + nothing again. Found while classifying the 20 runtime apps (R-1332); fixed in the session, no row. +- Tests: `test_security_watch.py` W13 (5 checks; 4 red on the image-only key) — 64 checks. + ## 2026-10-28 — the security watch reads zstd layers in CI by itself (R-1327) - **`image_runtime.py`:** zstd layers are decoded by `zstandard` 0.25.0 — an importable one, else the wheel pinned by diff --git a/scripts/security_watch.py b/scripts/security_watch.py index f710d3b1..4bbb8c73 100644 --- a/scripts/security_watch.py +++ b/scripts/security_watch.py @@ -630,8 +630,13 @@ def source_name(kind, arg): # ── memory and mails (pure: the tests drive this directly) ────────────────────────────────────────────────────────── +def mem_image(it): + """The image as the memory names it: a DooPlex pin of an image the catalog also ships is its own entry (R-1333).""" + return ("dooplex:" if it.get("group") == "dooplex" else "") + it["image"] + + def adv_key(it, a): - return "%s|%s" % (it["image"], a["id"]) + return "%s|%s" % (mem_image(it), a["id"]) def label(it): @@ -649,9 +654,17 @@ def plan(items, state, today, seed=False): open_now, checked_images = {}, set() for it in items: if not it.get("not_checked"): - checked_images.add(it["image"]) + checked_images.add(mem_image(it)) for a in it.get("advisories", []): open_now[adv_key(it, a)] = (it, a) + # A memory written before R-1333 keyed a DooPlex notice by the image alone: copy it to the DooPlex key, so the first + # night after the change mails nothing again; the shared key stays only while a catalog pin still has the notice. + for k in [k for k in open_now if k.startswith("dooplex:") and k not in known]: + shared = k[len("dooplex:"):] + if shared in known: + known[k] = dict(known[shared]) + if shared not in open_now: + del known[shared] # 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. diff --git a/scripts/test_security_watch.py b/scripts/test_security_watch.py index 5d97eb39..9d770c5d 100644 --- a/scripts/test_security_watch.py +++ b/scripts/test_security_watch.py @@ -20,11 +20,14 @@ What is pinned, each by its consequence (the mail or the class), not by its mech 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 + W13 one image in the catalog AND on DooPlex (R-1333): a new notice is mailed for both pins; the memory keeps one + entry per group; an old shared key is copied, so nothing is mailed twice; a clean DooPlex pin forgets only its own 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 controls green). +W13 (2026-10-28, R-1333) was seen failing on the image-only key (4 of 5 checks red; the clean-DooPlex-pin check green). """ import datetime import json @@ -440,10 +443,38 @@ def t_dooplex(): check("W12 the weekly mail has its own DooPlex block", "DooPlex (homelab)" in wk["text"], wk["text"][:300]) +def t_two_groups(): + print("-- W13: one image in the catalog AND on DooPlex (R-1333)") + cat = dict(adv_item("rommapp/romm", "CVE-2026-111497", group="app"), tag="5.4.1") + dpx = dict(adv_item("rommapp/romm", "CVE-2026-111497", group="dooplex"), tag="5.4.0") + m1, st, _ = w.plan([cat, dpx], {"weekly_last": TODAY.isoformat()}, TODAY) + adv = [m for m in m1 if m["kind"] == "advisory"] + check("W13 a new notice on both pins is mailed for BOTH (the catalog's 5.4.1 and DooPlex's 5.4.0)", + len(adv) == 1 and "5.4.1" in adv[0]["text"] and "5.4.0" in adv[0]["text"] and "homelab" in adv[0]["text"], + adv[0]["text"][-500:] if adv else kinds(m1)) + check("W13 the memory keeps one entry per group", sorted(st["advisories"]) == + ["dooplex:rommapp/romm|CVE-2026-111497", "rommapp/romm|CVE-2026-111497"], sorted(st["advisories"])) + # a memory written before R-1333 holds ONE shared key: the first night after the change mails nothing again + old = {"weekly_last": TODAY.isoformat(), "advisories": {"rommapp/romm|CVE-2026-111497": { + "first": "2026-10-27", "last_mailed": "2026-10-27", "program": "rommapp/romm 5.4.0 (x)", "summary": "s"}}} + m2, st2, _ = w.plan([cat, dpx], old, TODAY) + check("W13 the old shared key is copied to the DooPlex key: no mail again", kinds(m2, "advisory") == [] and + st2["advisories"].get("dooplex:rommapp/romm|CVE-2026-111497", {}).get("first") == "2026-10-27", (kinds(m2), st2["advisories"])) + m3, st3, _ = w.plan([dpx], dict(old), TODAY) + check("W13 a shared key whose catalog pin is gone moves to the DooPlex key (no stale twin, no mail)", + sorted(st3["advisories"]) == ["dooplex:rommapp/romm|CVE-2026-111497"] and kinds(m3, "advisory") == [], + (sorted(st3["advisories"]), kinds(m3))) + dclean = dict(clean("rommapp/romm"), group="dooplex") + _, st4, _ = w.plan([cat, dclean], st, TODAY) + check("W13 DooPlex's pin checked clean forgets only DooPlex's entry", sorted(st4["advisories"]) == + ["rommapp/romm|CVE-2026-111497"], sorted(st4["advisories"])) + + def main(): t_ranges() t_classes() t_memory() + t_two_groups() t_inventory() for t in (t_runtime, t_ghcr_pages, t_dooplex): try: