Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b41eeaed75 | ||
|
|
e5cfa67e6e | ||
|
|
a1357e6866 | ||
|
|
f59eb1480f |
@@ -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:*:*:*:*:*:*:*:*
|
||||
```
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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; \
|
||||
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> --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
|
||||
|
||||
+22
-14
@@ -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/<recipe>.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]))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ("<tr><th>Recipe</th><th>Change</th><th>TESTS</th><th>CVEs</th>"
|
||||
head = ("<tr><th>Recipe</th><th>Change</th><th>TESTS</th>"
|
||||
'<th title="Known CVEs fixed by the available recipe upgrade; zero does not certify the current image as vulnerability-free">CVEs fixed</th>'
|
||||
"<th>CI</th><th>PR</th><th>STATUS</th><th>Notes</th></tr>")
|
||||
trs = []
|
||||
for r in rows:
|
||||
|
||||
@@ -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):
|
||||
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']}"
|
||||
|
||||
@@ -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")])])
|
||||
|
||||
@@ -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()
|
||||
Generated
+4
-4
@@ -10,11 +10,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1793642332,
|
||||
"narHash": "sha256-OJhw1h9CvIjbQ1y+sApq01cQwoaMe9gf+bGVXAnj2Ak=",
|
||||
"lastModified": 1793644559,
|
||||
"narHash": "sha256-u6xPHneKwIrd1taLVs1X12dUs46RzTzkZ9fJ8d/ZvYI=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "a47c1caa8cd44512a1b988ee5296ff9129672a2a",
|
||||
"revCount": 1585,
|
||||
"rev": "d569e6528f2eba4309db4794b091d6a1ecc029bf",
|
||||
"revCount": 1589,
|
||||
"type": "git",
|
||||
"url": "https://git.autonomic.zone/recipe-maintainers/cc-ci.git"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user