Compare commits

..
9 changed files with 236 additions and 59 deletions
+14 -5
View File
@@ -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 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. 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 - **No upgrade available** → the recipe is `UPTODATE`; its **CVEs fixed by an available upgrade**
nothing an upgrade could fix. Record it and move on. 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 - **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. 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). 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 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`. 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 Do not assume that the fleet has zero blind recipes. Run the audit every sweep and inspect source
feed, client-side-rendered bulletins) and `mumble` (nothing published anywhere) — were fixed by statuses; an HTTP error or rate limit is not evidence of no CVEs. `rustdesk-server` is currently
declaring an NVD CPE in their registry: 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:*:*:*:*:*:*:*:* - nvd-cpe: mattermost-team-edition = cpe:2.3:a:mattermost:mattermost_server:*:*:*:*:*:*:*:*
``` ```
+10 -4
View File
@@ -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 <DATE> > /tmp/survey.json`. This 2. **Survey.** `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py survey <DATE> > /tmp/survey.json`. This
gives you: the raw `/upgrade-all` summary; for every recipe mirror its open PRs + the `cc-ci/testme` 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 / 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) → 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. 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 `?` 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 (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. 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 - The table's **CVEs** column counts known CVEs addressed by the available recipe upgrade.
ONLY when a scan ran and reported genuinely failed sources, **or when the scan block says A zero for an up-to-date recipe means there is no upgrade window to credit; it does not
COUNT UNKNOWN**. In that case the scan's `0` means *not determined*: publish `?` and say so in 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 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 **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. (discourse 3.5.3 → 2026.7.1) by falling back to advisory publish dates and reports a real number.
+25 -6
View File
@@ -143,12 +143,31 @@ ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; R=<r>; \
script -qec "abra recipe upgrade $R -m -n" /dev/null; \ script -qec "abra recipe upgrade $R -m -n" /dev/null; \
git -C ~/.abra/recipes/$R stash pop >/dev/null 2>&1 || true' 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 **Mandatory registry cross-check for EVERY recipe and EVERY image, even if abra says current:** after
overlay doesn't count as dirty). Others go to `SKIPPED_UPFRONT` with a reason (`dirty-worktree` only for reconciling the mirror and refreshing the recipe checkout, run
real TRACKED edits, `up-to-date`, `not-fetchable`). **A `tag and digest … not supported` parse FATA is `python3 /srv/cc-ci/cc-ci-plan/resolve-images.py <recipe> --recipe-dir /root/.abra/recipes`
NOT `not-fetchable`** — per box item 4, catch it and resolve availability via the direct upstream-tag on cc-ci. Read the JSON `images`, `upgrades_available`, `unresolved`, and `all_resolved` fields.
check; only `not-fetchable` when the mirror genuinely can't be fetched (network/auth), never for a The candidate list is the **union** of abra's upgrades and the resolver's upgrades. CryptPad's
digest-pinned image. `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 **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 PRs are closed and every mirror `main` tracks true upstream — fleet-wide, not just where there's a new
+22 -14
View File
@@ -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 # 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). # column on 2026-08-07 (https://docs.n8n.io/release-notes/` ← note the backtick).
u = u.rstrip("`'\"*.,;:>)") u = u.rstrip("`'\"*.,;:>)")
if "…" in u or "..." in u:
continue # prose placeholder, not a fetchable security source
if u and u not in urls: if u and u not in urls:
urls.append(u) urls.append(u)
return urls, path 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) windows[src] = (v_from, v_to)
window_key[src] = k window_key[src] = k
for key, wf, wt in (images or []): for key, wf, wt in (images or []):
matched = False
for src in gh_sources: for src in gh_sources:
if src in windows: if src in windows:
continue 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: if k in src.lower() or name in k:
windows[src] = (wf, wt) windows[src] = (wf, wt)
window_key[src] = key 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()} report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()}
def _classify_window(src, wf, wt): 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 — # 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 # 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. # still produced "0 identified", which was then published as a clean 0 in a CVE report.
usable_sources = [ usable_sources = [s for s in report["sources"] if s["status"] == "ok"]
s for s in report["sources"]
if s["status"] == "ok" or s["status"].startswith("no-advisories-published")
]
no_sources = not usable_sources no_sources = not usable_sources
if no_sources: if no_sources:
report["no_usable_sources"] = True 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"] = [ report["sources_failed"] = [
s["source"] s["source"]
for s in report["sources"] for s in report["sources"]
if not (s["status"] == "ok" or s["status"].startswith(("no-advisories-published", "skipped:"))) 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"] = [ report["sources_benign"] = [
s["source"] s["source"]
for s in report["sources"] for s in report["sources"]
@@ -1052,16 +1056,20 @@ def markdown(rep: dict) -> str:
f"{rep.get('from') or '?'} → {rep.get('to') or '?'}"] f"{rep.get('from') or '?'} → {rep.get('to') or '?'}"]
if not rep.get("count_known", True): if not rep.get("count_known", True):
L.append("\n**CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count.**") 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"): if rep.get("no_usable_sources"):
L.append("\n⚠ This is NOT zero. **No usable source was checked at all** — the registry " L.append("\n⚠ This is NOT zero. **No usable source was checked at all** — the registry "
"file `cc-ci-plan/upstream/<recipe>.md` is missing or every source failed, so " "file `cc-ci-plan/upstream/<recipe>.md` is missing or every source failed, so "
"nothing was measured. Render this recipe's cve cell as `?`, never `0`, and add " "nothing was measured. Render this recipe's cve cell as `?`, never `0`, and add "
"the registry file.") "the registry file.")
else: else:
L.append("\n⚠ This is NOT zero. A version-scheme change (e.g. semver → calver) makes " L.append("\n⚠ This is NOT zero. At least one version window or advisory source could "
"numeric ordering meaningless across this jump, so no advisory could be " "not be checked completely. Render this recipe's cve cell as `?`, never `0`.")
"classified. Render this recipe's cve cell as `?`, never `0`. Read the vendor's " if rep.get("unmatched_image_windows"):
"release notes for the jump and count by hand.") L.append("\n⚠ image windows with no matching advisory source: "
+ ", ".join(rep["unmatched_image_windows"]))
if rep["unclassified"]: if rep["unclassified"]:
L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes " L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes "
f"other images in this recipe: " + ", ".join(rep["unclassified"][:12])) f"other images in this recipe: " + ", ".join(rep["unclassified"][:12]))
+11 -7
View File
@@ -234,6 +234,8 @@ def _registry_urls(recipe: str):
urls = [] urls = []
for u in re.findall(r"https?://[^\s)|\]]+", text): for u in re.findall(r"https?://[^\s)|\]]+", text):
u = u.rstrip("`'\"*.,;:>)") u = u.rstrip("`'\"*.,;:>)")
if "…" in u or "..." in u:
continue
if u and u not in urls: if u and u not in urls:
urls.append(u) urls.append(u)
return urls, path return urls, path
@@ -272,21 +274,23 @@ def main() -> int:
blind_recipes, noisy = [], 0 blind_recipes, noisy = [], 0
for r in recipes: for r in recipes:
rows = security_source_audit(r) rows = security_source_audit(r)
feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0] 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"] 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"] unattr = [x for x in rows if x["kind"] == "unattributable"]
failed = [x for x in rows if x["status"].startswith("error:")]
noisy += len(unattr) noisy += len(unattr)
usable = len(feeds) + len(logs) usable = len(feeds) + len(logs)
if usable == 0: if usable == 0:
blind_recipes.append(r) blind_recipes.append(r)
print(f"!! {r}: NO USABLE CVE SOURCE — {len(unattr)} unparseable page(s), " print(f"!! {r}: NO VERIFIED CVE SOURCE — {len(unattr)} unparseable page(s), "
f"0 advisory feeds, 0 attributable changelogs") f"0 populated advisory feeds, 0 attributable changelogs, {len(failed)} failed source(s)")
for x in rows: 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: else:
print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)" print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)"
+ (f", {len(unattr)} unparseable page(s) (redundant — covered by a feed)" + (f", {len(unattr)} unparseable page(s)" if unattr else "")
if unattr else "")) + (f", {len(failed)} failed source(s) — counts incomplete" if failed else ""))
for x in logs: for x in logs:
print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})") print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})")
print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source" print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source"
+70 -10
View File
@@ -163,7 +163,8 @@ def parse_ref(ref: str) -> dict:
return {"registry": host, "repo": repo, "tag": tag, "digest": digest} 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: 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]: def _hub_tags(repo: str) -> list[str]:
"""Recently-pushed tags, newest first. """Recently-pushed tags, newest first.
Popular Docker Hub repos carry many thousands of tags, so a full enumeration is impractical — Popular Docker Hub repos carry many thousands of tags, so enumerate a bounded recent window.
but it is also unnecessary: a tag NEWER than the one we run must have been pushed AFTER it, so A rebuilt old tag can displace a newer version from that window, so callers MUST treat a
ordering by last_updated and reading a bounded recent window is sufficient to find any upgrade. truncated listing with no detected upgrade as unverified, not current. (ghcr offers no recency
(ghcr offers no ordering, which is why that path needs a different strategy.) ordering, which is why that path needs a different strategy.)
""" """
tags, url = [], (f"https://hub.docker.com/v2/repositories/{repo}/tags" tags, url = [], (f"https://hub.docker.com/v2/repositories/{repo}/tags"
f"?page_size=100&ordering=last_updated") f"?page_size=100&ordering=last_updated")
@@ -191,6 +192,8 @@ def _hub_tags(repo: str) -> list[str]:
url = d.get("next") url = d.get("next")
if not url: if not url:
break break
if url:
_HUB_TRUNCATED.add(repo)
return tags return tags
@@ -236,15 +239,26 @@ def _oci_tags(host: str, repo: str) -> list[str]:
hdrs = _oci_bearer(host, repo) hdrs = _oci_bearer(host, repo)
tags, url = [], f"https://{host}/v2/{repo}/tags/list?n=1000" tags, url = [], f"https://{host}/v2/{repo}/tags/list?n=1000"
for _ in range(MAX_TAG_PAGES): for _ in range(MAX_TAG_PAGES):
for attempt in range(RETRIES):
req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs}) req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r: with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
tags += (json.load(r) or {}).get("tags") or [] tags += (json.load(r) or {}).get("tags") or []
link = r.headers.get("Link", "") 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) m = re.search(r'<([^>]+)>;\s*rel="next"', link)
if not m: if not m:
break break
nxt = m.group(1) nxt = m.group(1)
url = f"https://{host}{nxt}" if nxt.startswith("/") else nxt url = f"https://{host}{nxt}" if nxt.startswith("/") else nxt
else:
raise RuntimeError(f"tag listing truncated after {MAX_TAG_PAGES} pages")
return tags return tags
@@ -299,6 +313,39 @@ def _release_fallback_repos(registry: str, repo: str) -> list[tuple[str, str]]:
return cands return cands
def _oci_tag_exists(host: str, repo: str, tag: str) -> bool:
"""Verify a release candidate is actually published as an image manifest."""
hdrs = {"User-Agent": UA,
"Accept": "application/vnd.oci.image.index.v1+json, "
"application/vnd.docker.distribution.manifest.list.v2+json, "
"application/vnd.oci.image.manifest.v1+json",
**_oci_bearer(host, repo)}
url = f"https://{host}/v2/{repo}/manifests/{urllib.parse.quote(tag, safe='')}"
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=hdrs, method="HEAD"),
timeout=TIMEOUT) as response:
return response.status == 200
except urllib.error.HTTPError as e:
if e.code == 404:
return False
raise
def _release_manifest_fallback(registry: str, repo: str, current: str) -> tuple[list[str], str] | None:
"""For huge GHCR registries, use release tags only after checking actual manifests."""
for owner, name in _release_fallback_repos(registry, repo):
releases = github_release_tags(owner, name)
if current not in releases:
continue # incomplete release window; it cannot establish a current baseline
candidates = sorted((tag for tag in set(releases)
if shape(tag) == shape(current) and vkey(tag) >= vkey(current)),
key=vkey, reverse=True)
for tag in candidates:
if _oci_tag_exists(registry, repo, tag):
return [current, tag], f"github-releases+registry-manifest:{owner}/{name}"
return None
_TAG_CACHE: dict[tuple[str, str], tuple[list[str], str | None]] = {} _TAG_CACHE: dict[tuple[str, str], tuple[list[str], str | None]] = {}
@@ -335,8 +382,16 @@ def resolve(ref: str) -> dict:
"upgrade_available": False} "upgrade_available": False}
tags, err = list_tags(info["registry"], info["repo"]) tags, err = list_tags(info["registry"], info["repo"])
if err: if err:
try:
fallback = (_release_manifest_fallback(info["registry"], info["repo"], info["tag"])
if "tag listing truncated" in err else None)
except Exception as e: # noqa: BLE001 — a failed fallback must stay unresolved
out["status"] = f"error: registry {err}; release fallback {type(e).__name__}: {e}"
return out
if not fallback:
out["status"] = f"error: {err}" out["status"] = f"error: {err}"
return out return out
tags, out["source"] = fallback
out["tags_seen"] = len(set(tags)) out["tags_seen"] = len(set(tags))
# INTEGRITY CHECK: the tag we are currently running MUST appear in the listing. If it does not, # INTEGRITY CHECK: the tag we are currently running MUST appear in the listing. If it does not,
# the listing is incomplete and any "newest" derived from it is a guess — ghcr paginates to tens # the listing is incomplete and any "newest" derived from it is a guess — ghcr paginates to tens
@@ -367,8 +422,9 @@ def resolve(ref: str) -> dict:
same = [t for t in set(tags) if shape(t) == want and vkey(t)] same = [t for t in set(tags) if shape(t) == want and vkey(t)]
out["candidates"] = len(same) out["candidates"] = len(same)
if not same: if not same:
# Not a failure: digest-only pins and `latest`/`stable` have no comparable siblings. # A floating or unusual tag cannot prove that an image is current. Keep it visible to
out["status"] = "no comparable tags (shape has no numeric siblings)" # callers rather than letting an empty candidate set become a false UPTODATE verdict.
out["status"] = "unverifiable: no comparable numeric tags"
return out return out
newest = max(same, key=vkey) newest = max(same, key=vkey)
out["newest_same_shape"] = newest out["newest_same_shape"] = newest
@@ -377,6 +433,10 @@ def resolve(ref: str) -> dict:
if within: if within:
out["newest_within_major"] = max(within, key=vkey) out["newest_within_major"] = max(within, key=vkey)
out["upgrade_available"] = bool(cur and vkey(newest) > cur) 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 return out
@@ -436,16 +496,16 @@ def main() -> int:
"recipe": a.recipe, "recipe": a.recipe,
"images": results, "images": results,
"upgrades_available": [r["ref"] for r in results if r["upgrade_available"]], "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". # 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: if not a.table:
print(json.dumps(report, indent=2)) print(json.dumps(report, indent=2))
return 0 return 0
print(f"{a.recipe} — {len(results)} images") print(f"{a.recipe} — {len(results)} images")
for r in results: 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" [{flag:7}] {r['repo']}:{r['tag']}" + (" (digest-pinned)" if r["digest"] else ""))
print(f" shape={r['shape']} candidates={r['candidates']}" print(f" shape={r['shape']} candidates={r['candidates']}"
f" newest_same_shape={r['newest_same_shape']}" f" newest_same_shape={r['newest_same_shape']}"
+19 -3
View File
@@ -22,6 +22,7 @@ import io
import json import json
import os import os
import pathlib import pathlib
import tempfile
import sys import sys
import unittest import unittest
import unittest.mock import unittest.mock
@@ -153,6 +154,12 @@ class TestRegistryUrls(unittest.TestCase):
p.write_text(text) p.write_text(text)
return A.registry_urls("r", tmp) 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): def test_strips_trailing_markdown_punctuation(self):
# Production defect: a captured backtick 404'd the fetch and rendered n8n/immich as '?'. # Production defect: a captured backtick 404'd the fetch and rendered n8n/immich as '?'.
import tempfile import tempfile
@@ -290,12 +297,14 @@ class TestPerImageWindows(unittest.TestCase):
self.assertEqual(rep["windows"]["github-advisories:discourse/discourse"], self.assertEqual(rep["windows"]["github-advisories:discourse/discourse"],
{"from": "3.5.3", "to": "3.5.4"}) {"from": "3.5.3", "to": "3.5.4"})
def test_unmatched_image_name_is_silently_ignored(self): def test_unmatched_image_name_makes_count_unknown(self):
# Documents CURRENT behaviour: a typo'd name costs coverage without warning. # 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", rep = run_scan([self.APP], v_from="3.5.3", v_to="3.5.4",
images=[("nosuchimage", "1", "2")], urls=self.URLS[:1]) images=[("nosuchimage", "1", "2")], urls=self.URLS[:1])
self.assertEqual(list(rep["windows"]), ["github-advisories:discourse/discourse"]) 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): class TestSchemeChangeDateFallback(unittest.TestCase):
@@ -377,6 +386,13 @@ class TestCountGuarantees(unittest.TestCase):
v_from="1.0", v_to="1.1") v_from="1.0", v_to="1.1")
self.assertIn("github-advisories:app/app", rep["sources_failed"]) self.assertIn("github-advisories:app/app", rep["sources_failed"])
self.assertIn("FAILED", A.markdown(rep)) 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): def test_no_window_given_classifies_nothing(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")])]) rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")])])
+55
View File
@@ -0,0 +1,55 @@
#!/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:"))
def test_huge_ghcr_listing_uses_only_published_release_manifests(self):
ref = "ghcr.io/immich-app/immich-machine-learning:v3.3.5"
with mock.patch.object(resolver, "list_tags", return_value=([], "RuntimeError: tag listing truncated")), \
mock.patch.object(resolver, "github_release_tags",
return_value=["v3.3.6", "v3.3.5"]), \
mock.patch.object(resolver, "_oci_tag_exists", side_effect=lambda h, r, t: t == "v3.3.5"):
row = resolver.resolve(ref)
self.assertEqual(row["status"], "ok")
self.assertFalse(row["upgrade_available"]) # release exists; image does not yet
self.assertIn("registry-manifest", row["source"])
with mock.patch.object(resolver, "list_tags", return_value=([], "RuntimeError: tag listing truncated")), \
mock.patch.object(resolver, "github_release_tags",
return_value=["v3.3.6", "v3.3.5"]), \
mock.patch.object(resolver, "_oci_tag_exists", return_value=True):
upgraded = resolver.resolve(ref)
self.assertTrue(upgraded["upgrade_available"])
if __name__ == "__main__":
unittest.main()
Generated
+4 -4
View File
@@ -10,11 +10,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1793642332, "lastModified": 1793644559,
"narHash": "sha256-OJhw1h9CvIjbQ1y+sApq01cQwoaMe9gf+bGVXAnj2Ak=", "narHash": "sha256-u6xPHneKwIrd1taLVs1X12dUs46RzTzkZ9fJ8d/ZvYI=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "a47c1caa8cd44512a1b988ee5296ff9129672a2a", "rev": "d569e6528f2eba4309db4794b091d6a1ecc029bf",
"revCount": 1585, "revCount": 1589,
"type": "git", "type": "git",
"url": "https://git.autonomic.zone/recipe-maintainers/cc-ci.git" "url": "https://git.autonomic.zone/recipe-maintainers/cc-ci.git"
}, },