fix: fail closed on incomplete weekly image and CVE surveys
This commit is contained in:
@@ -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']}"
|
||||
|
||||
Reference in New Issue
Block a user