From 6ee23a3b8f9067d8f7efb7e11a65ca44d1a547a4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 26 Oct 2026 23:34:29 +0100 Subject: [PATCH] =?UTF-8?q?R-1303:=20watch=20=E2=80=94=20abandoned=20templ?= =?UTF-8?q?ates=20are=20GAP,=20a=20seed's=20unread=20pins=20stay=20pending?= =?UTF-8?q?=20(no=20storm=20after=20a=20seed);=2038=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/CHANGELOG.md | 3 +++ scripts/security_watch.py | 30 ++++++++++++++++++++++++++++- scripts/security_watch_inventory.py | 8 +++++--- scripts/test_security_watch.py | 17 ++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 3c7597d5..677a2492 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -13,6 +13,9 @@ item as a recorded answer), one mail per advisory, the 7-day reminder, a source down one night vs two, the weekly, the seed, the inventory gate. Red-proved four ways (dedupe, streak, the 30-day window, a swallowed source error). - `hub/Dockerfile`: a `# upstream:` comment above each `FROM`. +- After the first CI dry run: a `lifecycle: abandoned` template (plant-it) is GAP and never looked up; a pin a seed + could not read (no-key rotation, a source down) stays PENDING and its first real read is recorded silently — no + storm in the nights after a seed, while a pin added later still mails (38 checks; red-proved with an empty pending set). ## 2026-10-26 (evening) — CI: heavy suites by path, no second full gate run, a nightly run (R-1297 A); a decoy hole closed diff --git a/scripts/security_watch.py b/scripts/security_watch.py index cf9740c8..f3428e45 100644 --- a/scripts/security_watch.py +++ b/scripts/security_watch.py @@ -385,6 +385,13 @@ def newest_in_channel(tag, names): 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 = [], [], [] @@ -394,6 +401,7 @@ def check_item(it, src, today): 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: @@ -455,6 +463,7 @@ def check_item(it, src, today): "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) @@ -525,6 +534,24 @@ def plan(items, state, today, seed=False): 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: @@ -627,7 +654,8 @@ def weekly_mail(items, today): 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"), "")) + 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"})) diff --git a/scripts/security_watch_inventory.py b/scripts/security_watch_inventory.py index f54545f9..e692747a 100644 --- a/scripts/security_watch_inventory.py +++ b/scripts/security_watch_inventory.py @@ -135,8 +135,10 @@ def catalog_items(root): compose = os.path.join(tdir, app, "docker-compose.yml") if not os.path.isfile(compose): continue - ups = felhom_upstream_block(_read(os.path.join(tdir, app, ".felhom.yml"))) \ - if os.path.isfile(os.path.join(tdir, app, ".felhom.yml")) else {} + meta = _read(os.path.join(tdir, app, ".felhom.yml")) if os.path.isfile(os.path.join(tdir, app, ".felhom.yml")) else "" + ups = felhom_upstream_block(meta) + # `lifecycle: abandoned` — still running on boxes that had it, never updated again (plant-it): listed, not looked up. + abandoned = bool(re.search(r"^lifecycle:\s*[\"']?abandoned", meta, re.M)) seen = set() for line in _read(compose).splitlines(): m = IMAGE_RX.match(line) @@ -146,7 +148,7 @@ def catalog_items(root): if (image, tag) in seen: continue seen.add((image, tag)) - items.append({"group": "app", "owner": app, "image": image, "tag": tag, + items.append({"group": "app", "owner": app, "image": image, "tag": tag, "abandoned": abandoned, "where": "templates/%s/.felhom.yml upstream: %s" % (app, image), "sources_text": ups.get(image)}) return items diff --git a/scripts/test_security_watch.py b/scripts/test_security_watch.py index 760dbc51..ae3b7b4c 100644 --- a/scripts/test_security_watch.py +++ b/scripts/test_security_watch.py @@ -182,6 +182,12 @@ def t_classes(): fb["class"] == "BEHIND" and fb["behind"] == "1.5.7-stable", (fb["class"], fb.get("behind"), fb["advisories"])) check("W3 redis 7-alpine -> FLOATING", red["class"] == "FLOATING", red["class"]) check("W3 postgres gap -> GAP (BEHIND still computed: no newer 16-…)", pg["class"] == "GAP", pg["class"]) + ab = item("msdeluise/plant-it", "0.10.0", "github:MDeLuise/plant-it", group="app") + ab["abandoned"] = True + seen_before = len(FakeNet({}).seen) + run_items([ab], {}) + check("W3 an abandoned template -> GAP, nothing looked up (no route needed)", ab["class"] == "GAP" and + not ab["not_checked"] and seen_before == 0, (ab["class"], ab.get("not_checked"))) check("W4 a source that fails -> NOT CHECKED, with the reason", down["class"] == "NOT CHECKED" and "timed out" in down["not_checked"][0]["why"], down.get("not_checked")) lines = w.table(items) @@ -259,6 +265,17 @@ def t_memory(): sm, sst, would = w.plan([adv_item("traefik", "GHSA-1"), down("b")], {}, d(1), seed=True) check("W7 a seed mails nothing, records the advisory, and says what it would have mailed", sm == [] and "traefik|GHSA-1" in sst["advisories"] and kinds(would) == ["advisory"], (kinds(sm), kinds(would))) + unread = clean("immich") + unread["adv_unread"] = True + _, sst, _ = w.plan([adv_item("traefik", "GHSA-1"), unread], {}, d(1), seed=True) + check("W7 a pin the seed could not read is PENDING", sst["seed_pending"] == ["immich"], sst.get("seed_pending")) + first = adv_item("immich", "GHSA-im1", group="app") + newapp = adv_item("newapp", "GHSA-na1", group="app") + pm, sst, _ = w.plan([adv_item("traefik", "GHSA-1"), first, newapp], sst, d(2)) + check("W7 its first real read is recorded SILENTLY; a pin added later still mails", + "immich|GHSA-im1" in sst["advisories"] and sst["seed_pending"] == [] and + [m for m in pm if m["kind"] == "advisory" and "GHSA-im1" in m["text"]] == [] and + any(m["kind"] == "advisory" and "GHSA-na1" in m["text"] for m in pm), (kinds(pm), sst.get("seed_pending"))) def t_inventory():