From b41eeaed7554204fed7d70c55d82d9567aec5966 Mon Sep 17 00:00:00 2001 From: notplants <@notplants> Date: Mon, 2 Nov 2026 19:08:54 +0000 Subject: [PATCH] fix: fail closed on incomplete weekly image and CVE surveys --- .claude/skills/cve-check/SKILL.md | 19 ++++++++--- .claude/skills/recipe-report/SKILL.md | 14 +++++--- .claude/skills/upgrade-all/SKILL.md | 31 ++++++++++++++---- cc-ci-plan/advisory-scan.py | 36 ++++++++++++-------- cc-ci-plan/audit-sources.py | 18 ++++++---- cc-ci-plan/recipe-report.py | 3 +- cc-ci-plan/resolve-images.py | 47 +++++++++++++++++++-------- cc-ci-plan/test-advisory-scan.py | 22 +++++++++++-- cc-ci-plan/test-resolve-images.py | 38 ++++++++++++++++++++++ 9 files changed, 174 insertions(+), 54 deletions(-) create mode 100644 cc-ci-plan/test-resolve-images.py diff --git a/.claude/skills/cve-check/SKILL.md b/.claude/skills/cve-check/SKILL.md index 8527039..c6d13e5 100644 --- a/.claude/skills/cve-check/SKILL.md +++ b/.claude/skills/cve-check/SKILL.md @@ -95,8 +95,9 @@ For each recipe produce **one window per image**: `current pinned tag → newest the sidecars (redis, postgres, nginx …), not just the app — a sidecar bump is where discourse's only CRITICAL came from, and an image with no window is not counted at all. -- **No upgrade available** → the recipe is `UPTODATE`; its CVE count is **`0`**, not `?`. There is - nothing an upgrade could fix. Record it and move on. +- **No upgrade available** → the recipe is `UPTODATE`; its **CVEs fixed by an available upgrade** + count is `0`. This does not assert that the running image has no known CVEs. If advisory sources + failed or are blind, use `?` and say why. - **No output at all is NOT "no upgrade".** An abra call that times out, FATAs, or prints nothing leaves the recipe **unverified** — treat it as a distinct outcome, never fold it into up-to-date. Re-run it, and if it still yields nothing, resolve the versions by direct registry check (box item 4). @@ -111,9 +112,17 @@ python3 cc-ci-plan/audit-sources.py --security-sources A recipe whose sources yield **no CVE data at all** cannot produce a meaningful `0` — nothing was measured, the same way a missing registry file cannot. Render those as **`?`**, not `0`. -**The fleet is currently at zero such recipes.** The last two — `mattermost-lts` (empty advisory -feed, client-side-rendered bulletins) and `mumble` (nothing published anywhere) — were fixed by -declaring an NVD CPE in their registry: +Do not assume that the fleet has zero blind recipes. Run the audit every sweep and inspect source +statuses; an HTTP error or rate limit is not evidence of no CVEs. `rustdesk-server` is currently +blind because its upstream GitHub repo publishes no security advisories and no other usable source +has been verified; mark it `?` until that changes. + +Check `test -s /srv/cc-ci/.github-token` before a fleet scan. Without that read-only token, +GitHub's anonymous API limit can be exhausted partway through the fleet; HTTP 403 results are +failed sources, so the affected CVE counts remain unknown. + +Two older blind spots — `mattermost-lts` and `mumble` — were addressed by declaring NVD CPEs in +their registries: ``` - nvd-cpe: mattermost-team-edition = cpe:2.3:a:mattermost:mattermost_server:*:*:*:*:*:*:*:* ``` diff --git a/.claude/skills/recipe-report/SKILL.md b/.claude/skills/recipe-report/SKILL.md index 3f5ddd1..5247cce 100644 --- a/.claude/skills/recipe-report/SKILL.md +++ b/.claude/skills/recipe-report/SKILL.md @@ -30,7 +30,9 @@ keeps every weekly edition looking the same regardless of which model writes the 2. **Survey.** `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py survey > /tmp/survey.json`. This gives you: the raw `/upgrade-all` summary; for every recipe mirror its open PRs + the `cc-ci/testme` verdict; and each recipe's **per-recipe upgrade notes** (`upgrade_notes_md` — the breaking-change / - **CVE / security** analysis the upgrade did). Read it all. + **CVE / security** analysis the upgrade did). Read it all. Also run + `python3 /srv/cc-ci/cc-ci-plan/audit-sources.py --security-sources`; a recipe with no verified + security source or a failed source must be called out, not presented as a clean CVE scan. 3. **Review & classify.** The page order is: **short lead → the full wire table (priority-sorted) → Addendum → Security Bulletin → per-recipe "What changed".** Same newspaper style; this order. @@ -46,9 +48,13 @@ keeps every weekly edition looking the same regardless of which model writes the lists **failed sources**, the count is **not** authoritative: render the cve cell as `?` (unknown), never `none` — a blank that reads as "clean" is exactly how two CVSS-9.8 gitea RCEs were reported as "none" on 2026-08-07. - - **`?` must stay RARE — it means "we tried and could not tell", not "we didn't look".** Use it - ONLY when a scan ran and reported genuinely failed sources, **or when the scan block says - COUNT UNKNOWN**. In that case the scan's `0` means *not determined*: publish `?` and say so in + - The table's **CVEs fixed** column counts known CVEs addressed by the available recipe upgrade. + A zero for an up-to-date recipe means there is no upgrade window to credit; it does not + certify that the running images are free of vulnerabilities. If an upgrade exists but its + advisory scan is absent, a source failed, or an image window was unmatched, use `?`. + - **`?` means the count is not verified.** Use it when an upgrade has no scan, a source failed, + an image window was unmatched, or the scan block says COUNT UNKNOWN. In that case publish + `?` and say why in the notes; publishing `0` would assert a clean bill of health nothing supports. Note a **version-scheme change is no longer a reason for `?`** — the scan resolves semver→calver jumps (discourse 3.5.3 → 2026.7.1) by falling back to advisory publish dates and reports a real number. diff --git a/.claude/skills/upgrade-all/SKILL.md b/.claude/skills/upgrade-all/SKILL.md index 2a5bb1e..278a635 100644 --- a/.claude/skills/upgrade-all/SKILL.md +++ b/.claude/skills/upgrade-all/SKILL.md @@ -143,12 +143,31 @@ ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; R=; \ script -qec "abra recipe upgrade $R -m -n" /dev/null; \ git -C ~/.abra/recipes/$R stash pop >/dev/null 2>&1 || true' ``` -Build `RECIPES_TO_UPGRADE` = recipes with **≥1 available upgrade** (after the stash, so the untracked -overlay doesn't count as dirty). Others go to `SKIPPED_UPFRONT` with a reason (`dirty-worktree` only for -real TRACKED edits, `up-to-date`, `not-fetchable`). **A `tag and digest … not supported` parse FATA is -NOT `not-fetchable`** — per box item 4, catch it and resolve availability via the direct upstream-tag -check; only `not-fetchable` when the mirror genuinely can't be fetched (network/auth), never for a -digest-pinned image. +**Mandatory registry cross-check for EVERY recipe and EVERY image, even if abra says current:** after +reconciling the mirror and refreshing the recipe checkout, run +`python3 /srv/cc-ci/cc-ci-plan/resolve-images.py --recipe-dir /root/.abra/recipes` +on cc-ci. Read the JSON `images`, `upgrades_available`, `unresolved`, and `all_resolved` fields. +The candidate list is the **union** of abra's upgrades and the resolver's upgrades. CryptPad's +`version-YYYY.M.D` app tag is a known counterexample: abra only reports a parse warning, while the +resolver finds the newer image. A warning is never evidence that an image is current. Inspect both +`newest_same_shape` and `newest_within_major`; a newer major is still a candidate for planning, +though it must not be applied without a compatibility check. + +Build `RECIPES_TO_UPGRADE` from every recipe with at least one candidate. Only label a recipe +`up-to-date` if abra completed successfully **and** the resolver checked every relevant image +(`all_resolved: true`) **and** neither found a candidate. If an image is unresolved, templated, +floating, or lacks comparable tags, investigate it directly and record the evidence. Otherwise +record `unverified` with the image and reason; never fold it into `up-to-date`. `dirty-worktree` +applies only to real tracked edits. A tag+digest parse failure is not `not-fetchable`. + +Run `python3 /srv/cc-ci/cc-ci-plan/audit-sources.py --security-sources` once for the full inventory +and include any `NO VERIFIED CVE SOURCE` recipes or failed advisory sources in the weekly summary. +Do not describe their CVE count as clean. This audit is a coverage check, not a substitute for +`advisory-scan.py` on each actual upgrade window. +Before the audit, check `test -s /srv/cc-ci/.github-token`. If it is missing, record that GitHub +advisory checks are subject to the anonymous 60-request/hour limit; any resulting HTTP 403 is a +failed source and must become `?` in the report. Never replace it with `0` or silently retry from +model memory. The file is a read-only GitHub token location; never print its contents. **Reconcile every candidate's mirror during the survey** (even the up-to-date ones), so merged-upstream PRs are closed and every mirror `main` tracks true upstream — fleet-wide, not just where there's a new diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 3a818d8..5187aba 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -231,6 +231,8 @@ def registry_urls(recipe: str, registry_dir: str) -> tuple[list[str], str | None # recipe render '?' for no real reason — that is what put immich and n8n in the unknown # column on 2026-08-07 (https://docs.n8n.io/release-notes/` ← note the backtick). u = u.rstrip("`'\"*.,;:>)") + if "…" in u or "..." in u: + continue # prose placeholder, not a fetchable security source if u and u not in urls: urls.append(u) return urls, path @@ -826,6 +828,7 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, windows[src] = (v_from, v_to) window_key[src] = k for key, wf, wt in (images or []): + matched = False for src in gh_sources: if src in windows: continue @@ -837,6 +840,9 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, if k in src.lower() or name in k: windows[src] = (wf, wt) window_key[src] = key + matched = True + if not matched: + report.setdefault("unmatched_image_windows", []).append(key) report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()} def _classify_window(src, wf, wt): @@ -1020,24 +1026,22 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, # A scan with NO usable source has not measured anything, so it must not report a number — # least of all 0, which asserts safety. mumble had no cc-ci-plan/upstream/mumble.md at all and # still produced "0 identified", which was then published as a clean 0 in a CVE report. - usable_sources = [ - s for s in report["sources"] - if s["status"] == "ok" or s["status"].startswith("no-advisories-published") - ] + usable_sources = [s for s in report["sources"] if s["status"] == "ok"] no_sources = not usable_sources if no_sources: report["no_usable_sources"] = True - report["count_known"] = not unresolved_any and not no_sources - report["cve_count_fixed"] = (len(fixed_set) - if (not unresolved_any and not no_sources) else None) - report["cve_count_total_seen"] = len(report["cves"]) - # Only GENUINE failures make a count unreliable. "no-advisories-published" (404: the repo has - # no advisory feed) and "skipped: template URL" are benign and must not degrade the verdict. report["sources_failed"] = [ s["source"] for s in report["sources"] if not (s["status"] == "ok" or s["status"].startswith(("no-advisories-published", "skipped:"))) ] + coverage_failed = bool(report.get("unmatched_image_windows")) or bool(v_from and not primary) + report["count_known"] = not unresolved_any and not no_sources and not coverage_failed and not report["sources_failed"] + report["cve_count_fixed"] = (len(fixed_set) + if report["count_known"] else None) + report["cve_count_total_seen"] = len(report["cves"]) + # Only GENUINE failures make a count unreliable. "no-advisories-published" (404: the repo has + # no advisory feed) and "skipped: template URL" are benign and must not degrade the verdict. report["sources_benign"] = [ s["source"] for s in report["sources"] @@ -1052,16 +1056,20 @@ def markdown(rep: dict) -> str: f"{rep.get('from') or '?'} → {rep.get('to') or '?'}"] if not rep.get("count_known", True): L.append("\n**CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count.**") + if rep.get("fixed_by_this_upgrade"): + L.append(f"\nAt least {len(rep['fixed_by_this_upgrade'])} fixed CVE(s) were identified: " + + ", ".join(rep["fixed_by_this_upgrade"][:15])) if rep.get("no_usable_sources"): L.append("\n⚠ This is NOT zero. **No usable source was checked at all** — the registry " "file `cc-ci-plan/upstream/.md` is missing or every source failed, so " "nothing was measured. Render this recipe's cve cell as `?`, never `0`, and add " "the registry file.") else: - L.append("\n⚠ This is NOT zero. A version-scheme change (e.g. semver → calver) makes " - "numeric ordering meaningless across this jump, so no advisory could be " - "classified. Render this recipe's cve cell as `?`, never `0`. Read the vendor's " - "release notes for the jump and count by hand.") + L.append("\n⚠ This is NOT zero. At least one version window or advisory source could " + "not be checked completely. Render this recipe's cve cell as `?`, never `0`.") + if rep.get("unmatched_image_windows"): + L.append("\n⚠ image windows with no matching advisory source: " + + ", ".join(rep["unmatched_image_windows"])) if rep["unclassified"]: L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes " f"other images in this recipe: " + ", ".join(rep["unclassified"][:12])) diff --git a/cc-ci-plan/audit-sources.py b/cc-ci-plan/audit-sources.py index b0db1b3..e1a99e1 100755 --- a/cc-ci-plan/audit-sources.py +++ b/cc-ci-plan/audit-sources.py @@ -234,6 +234,8 @@ def _registry_urls(recipe: str): urls = [] for u in re.findall(r"https?://[^\s)|\]]+", text): u = u.rstrip("`'\"*.,;:>)") + if "…" in u or "..." in u: + continue if u and u not in urls: urls.append(u) return urls, path @@ -272,21 +274,23 @@ def main() -> int: blind_recipes, noisy = [], 0 for r in recipes: rows = security_source_audit(r) - feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0] - logs = [x for x in rows if x["kind"] == "changelog"] + feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0 + and x["status"] == "ok"] + logs = [x for x in rows if x["kind"] == "changelog" and x["status"] == "ok"] unattr = [x for x in rows if x["kind"] == "unattributable"] + failed = [x for x in rows if x["status"].startswith("error:")] noisy += len(unattr) usable = len(feeds) + len(logs) if usable == 0: blind_recipes.append(r) - print(f"!! {r}: NO USABLE CVE SOURCE — {len(unattr)} unparseable page(s), " - f"0 advisory feeds, 0 attributable changelogs") + print(f"!! {r}: NO VERIFIED CVE SOURCE — {len(unattr)} unparseable page(s), " + f"0 populated advisory feeds, 0 attributable changelogs, {len(failed)} failed source(s)") for x in rows: - print(f" {x['kind']:15} {x['source'][:64]} ({x['cves']} CVEs)") + print(f" {x['kind']:15} {x['source'][:64]} ({x['cves']} CVEs; {x['status']})") else: print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)" - + (f", {len(unattr)} unparseable page(s) (redundant — covered by a feed)" - if unattr else "")) + + (f", {len(unattr)} unparseable page(s)" if unattr else "") + + (f", {len(failed)} failed source(s) — counts incomplete" if failed else "")) for x in logs: print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})") print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source" diff --git a/cc-ci-plan/recipe-report.py b/cc-ci-plan/recipe-report.py index f114c04..beb0563 100755 --- a/cc-ci-plan/recipe-report.py +++ b/cc-ci-plan/recipe-report.py @@ -217,7 +217,8 @@ def _table(rows, repo_url=None): return "" # TESTS = the CI/test verdict (GREEN/FAILED/STALE/…); STATUS = the PR's LIVE state, fetched # client-side (open vs ✓ for any not-open state) by the inline script in _page(). - head = ("RecipeChangeTESTSCVEs" + head = ("RecipeChangeTESTS" + 'CVEs fixed' "CIPRSTATUSNotes") trs = [] for r in rows: diff --git a/cc-ci-plan/resolve-images.py b/cc-ci-plan/resolve-images.py index 8721763..b64d9ad 100644 --- a/cc-ci-plan/resolve-images.py +++ b/cc-ci-plan/resolve-images.py @@ -163,7 +163,8 @@ def parse_ref(ref: str) -> dict: return {"registry": host, "repo": repo, "tag": tag, "digest": digest} -HUB_RECENT_PAGES = int(os.environ.get("RESOLVE_IMAGES_HUB_PAGES", "10")) +HUB_RECENT_PAGES = int(os.environ.get("RESOLVE_IMAGES_HUB_PAGES", "40")) +_HUB_TRUNCATED: set[str] = set() def _hub_tag_exists(repo: str, tag: str) -> bool: @@ -177,10 +178,10 @@ def _hub_tag_exists(repo: str, tag: str) -> bool: def _hub_tags(repo: str) -> list[str]: """Recently-pushed tags, newest first. - Popular Docker Hub repos carry many thousands of tags, so a full enumeration is impractical — - but it is also unnecessary: a tag NEWER than the one we run must have been pushed AFTER it, so - ordering by last_updated and reading a bounded recent window is sufficient to find any upgrade. - (ghcr offers no ordering, which is why that path needs a different strategy.) + Popular Docker Hub repos carry many thousands of tags, so enumerate a bounded recent window. + A rebuilt old tag can displace a newer version from that window, so callers MUST treat a + truncated listing with no detected upgrade as unverified, not current. (ghcr offers no recency + ordering, which is why that path needs a different strategy.) """ tags, url = [], (f"https://hub.docker.com/v2/repositories/{repo}/tags" f"?page_size=100&ordering=last_updated") @@ -191,6 +192,8 @@ def _hub_tags(repo: str) -> list[str]: url = d.get("next") if not url: break + if url: + _HUB_TRUNCATED.add(repo) return tags @@ -236,15 +239,26 @@ def _oci_tags(host: str, repo: str) -> list[str]: hdrs = _oci_bearer(host, repo) tags, url = [], f"https://{host}/v2/{repo}/tags/list?n=1000" for _ in range(MAX_TAG_PAGES): - req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs}) - with urllib.request.urlopen(req, timeout=TIMEOUT) as r: - tags += (json.load(r) or {}).get("tags") or [] - link = r.headers.get("Link", "") or "" + for attempt in range(RETRIES): + req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs}) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + tags += (json.load(r) or {}).get("tags") or [] + link = r.headers.get("Link", "") or "" + break + except urllib.error.HTTPError as e: + if e.code not in (429, 503) or attempt == RETRIES - 1: + raise + retry_after = e.headers.get("Retry-After") or "" + delay = min(int(retry_after) if retry_after.isdigit() else 2 ** (attempt + 1), 60) + time.sleep(delay) m = re.search(r'<([^>]+)>;\s*rel="next"', link) if not m: break nxt = m.group(1) url = f"https://{host}{nxt}" if nxt.startswith("/") else nxt + else: + raise RuntimeError(f"tag listing truncated after {MAX_TAG_PAGES} pages") return tags @@ -367,8 +381,9 @@ def resolve(ref: str) -> dict: same = [t for t in set(tags) if shape(t) == want and vkey(t)] out["candidates"] = len(same) if not same: - # Not a failure: digest-only pins and `latest`/`stable` have no comparable siblings. - out["status"] = "no comparable tags (shape has no numeric siblings)" + # A floating or unusual tag cannot prove that an image is current. Keep it visible to + # callers rather than letting an empty candidate set become a false UPTODATE verdict. + out["status"] = "unverifiable: no comparable numeric tags" return out newest = max(same, key=vkey) out["newest_same_shape"] = newest @@ -377,6 +392,10 @@ def resolve(ref: str) -> dict: if within: out["newest_within_major"] = max(within, key=vkey) out["upgrade_available"] = bool(cur and vkey(newest) > cur) + if (info["registry"] in ("docker.io", "registry-1.docker.io") + and info["repo"] in _HUB_TRUNCATED and not out["upgrade_available"]): + out["status"] = (f"unverifiable: Docker Hub listing truncated after " + f"{HUB_RECENT_PAGES * 100} recent tags") return out @@ -436,16 +455,16 @@ def main() -> int: "recipe": a.recipe, "images": results, "upgrades_available": [r["ref"] for r in results if r["upgrade_available"]], - "unresolved": [r["ref"] for r in results if r["status"].startswith("error")], + "unresolved": [r["ref"] for r in results if r["status"].startswith(("error", "skipped", "unverifiable"))], # The whole point: distinguish "checked, current" from "could not check". - "all_resolved": not any(r["status"].startswith("error") for r in results), + "all_resolved": bool(results) and all(r["status"] == "ok" for r in results), } if not a.table: print(json.dumps(report, indent=2)) return 0 print(f"{a.recipe} — {len(results)} images") for r in results: - flag = "UPGRADE" if r["upgrade_available"] else ("ERROR" if r["status"].startswith("error") else "current") + flag = "UPGRADE" if r["upgrade_available"] else ("UNKNOWN" if r["status"] != "ok" else "current") print(f" [{flag:7}] {r['repo']}:{r['tag']}" + (" (digest-pinned)" if r["digest"] else "")) print(f" shape={r['shape']} candidates={r['candidates']}" f" newest_same_shape={r['newest_same_shape']}" diff --git a/cc-ci-plan/test-advisory-scan.py b/cc-ci-plan/test-advisory-scan.py index 3147e83..df527e0 100755 --- a/cc-ci-plan/test-advisory-scan.py +++ b/cc-ci-plan/test-advisory-scan.py @@ -22,6 +22,7 @@ import io import json import os import pathlib +import tempfile import sys import unittest import unittest.mock @@ -153,6 +154,12 @@ class TestRegistryUrls(unittest.TestCase): p.write_text(text) return A.registry_urls("r", tmp) + def test_prose_ellipsis_is_not_a_security_source(self): + with tempfile.TemporaryDirectory() as directory: + urls, _ = self._write(directory, + "https://github.com/cryptpad/cryptpad and example `https://…` URLs") + self.assertEqual(urls, ["https://github.com/cryptpad/cryptpad"]) + def test_strips_trailing_markdown_punctuation(self): # Production defect: a captured backtick 404'd the fetch and rendered n8n/immich as '?'. import tempfile @@ -290,12 +297,14 @@ class TestPerImageWindows(unittest.TestCase): self.assertEqual(rep["windows"]["github-advisories:discourse/discourse"], {"from": "3.5.3", "to": "3.5.4"}) - def test_unmatched_image_name_is_silently_ignored(self): - # Documents CURRENT behaviour: a typo'd name costs coverage without warning. + def test_unmatched_image_name_makes_count_unknown(self): + # A typo'd sidecar name must not silently turn its CVEs into a clean zero. rep = run_scan([self.APP], v_from="3.5.3", v_to="3.5.4", images=[("nosuchimage", "1", "2")], urls=self.URLS[:1]) self.assertEqual(list(rep["windows"]), ["github-advisories:discourse/discourse"]) - self.assertTrue(rep["count_known"]) + self.assertFalse(rep["count_known"]) + self.assertEqual(rep["unmatched_image_windows"], ["nosuchimage"]) + self.assertIsNone(rep["cve_count_fixed"]) class TestSchemeChangeDateFallback(unittest.TestCase): @@ -377,6 +386,13 @@ class TestCountGuarantees(unittest.TestCase): v_from="1.0", v_to="1.1") self.assertIn("github-advisories:app/app", rep["sources_failed"]) self.assertIn("FAILED", A.markdown(rep)) + self.assertFalse(rep["count_known"]) + + def test_only_404_feed_is_not_evidence_of_clean_scan(self): + rep = run_scan([gh("app/app", [], status="no-advisories-published")], + v_from="1.0", v_to="1.1") + self.assertTrue(rep["no_usable_sources"]) + self.assertFalse(rep["count_known"]) def test_no_window_given_classifies_nothing(self): rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")])]) diff --git a/cc-ci-plan/test-resolve-images.py b/cc-ci-plan/test-resolve-images.py new file mode 100644 index 0000000..65f87c3 --- /dev/null +++ b/cc-ci-plan/test-resolve-images.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Offline regressions for the weekly image survey's false-current cases.""" + +import importlib.util +import pathlib +import unittest +from unittest import mock + +HERE = pathlib.Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("resolve_images", HERE / "resolve-images.py") +resolver = importlib.util.module_from_spec(spec) +spec.loader.exec_module(resolver) + + +class TestResolverCoverage(unittest.TestCase): + def test_cryptpad_calver_tag_is_found_independently_of_abra(self): + with mock.patch.object(resolver, "list_tags", return_value=( + ["version-2026.5.1", "version-2026.10.0", "opendesk-2026.08.04"], None)): + row = resolver.resolve("cryptpad/cryptpad:version-2026.5.1") + self.assertTrue(row["upgrade_available"]) + self.assertEqual(row["newest_same_shape"], "version-2026.10.0") + + def test_floating_and_templated_tags_cannot_prove_current(self): + with mock.patch.object(resolver, "list_tags", return_value=(["alpine", "1.2.3"], None)): + floating = resolver.resolve("atmoz/sftp:alpine") + templated = resolver.resolve("ghost:${IMAGE_VERSION}-alpine") + self.assertTrue(floating["status"].startswith("unverifiable:")) + self.assertTrue(templated["status"].startswith("skipped:")) + + def test_truncated_registry_listing_cannot_prove_current(self): + with mock.patch.object(resolver, "list_tags", return_value=(["1.2.3"], None)): + with mock.patch.object(resolver, "_HUB_TRUNCATED", {"library/example"}): + row = resolver.resolve("example:1.2.3") + self.assertTrue(row["status"].startswith("unverifiable:")) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0