Files
cc-ci-orchestrator/cc-ci-plan/advisory-scan.py
T
autonomic-bot db37f1618b advisory-scan: attribute vendor-changelog CVEs to the release that fixed them
nginx publishes NO GitHub security advisories. Every nginx CVE we can see comes
from nginx.org/en/CHANGES, and the scan scraped ids out of it without attributing
them to a release - so they had no patched version, could never be classified, and
every nginx bump in the fleet reported 0 CVEs. nginx is a sidecar in most recipes,
so this was a fleet-wide blind spot.

Measured on the two PRs that prompted the question:
  lasuite-docs#7  nginx 1.31.1 -> 1.31.3   0 -> 6 CVEs
  lasuite-drive#6 nginx 1.31.2 -> 1.31.3   0 -> 3 CVEs
matching a hand count of the changelog exactly (three fixed in 1.31.2, three in
1.31.3; the narrower window correctly counts only the latter).

How: when a vendor page is organised by release, each CVE is attributed to the
nearest preceding release heading ('Changes with nginx 1.31.3', '## v1.31.3'),
and that becomes its fixed-in version. The CVE is tied to a window by the image
name appearing in the page URL (window 'nginx' <-> nginx.org/...). A changelog
lists the project's whole history, so only releases the window actually crosses
count - asserted by a test that the 2013 entries stay out.

76 tests. discourse 140 / gitea 2 / mailu 2 / keycloak 12 unchanged.
2026-08-11 19:49:43 +00:00

1134 lines
58 KiB
Python
Executable File

#!/usr/bin/env python3
"""Deterministic per-recipe CVE/advisory scan — an ADDITIVE pre-step for /recipe-upgrade.
WHY THIS EXISTS (2026-08-10): gitea 1.27.1 fixed two CVSS-9.8 RCEs (CVE-2026-60004,
CVE-2026-59774). Our weekly report showed gitea's CVE count as "1", then "none". The upgrade
subagent had scanned the GitHub *release notes*, which mention neither; the two CVEs were announced
only in the vendor's blog security section. The report generator then derived security content from
those notes plus model knowledge — and the model's training predates the CVEs. Nothing in the
pipeline ever queried an advisory source, so a critical CVE that is newer than the model and absent
from the changelog was invisible by construction.
WHAT IT DOES NOT DO: it does not replace or alter any existing security analysis. It is a strictly
ADDITIONAL evidence source whose findings are unioned into the CVE count.
SOURCES (measured against the gitea case before being chosen):
1. GitHub Security Advisories API — repos/<owner>/<repo>/security-advisories. PRIMARY: carries
CVE id, GHSA id, severity AND vulnerable/patched version ranges, so "fixed by THIS upgrade" is
computable rather than guessed. Found both gitea CVEs. Derived from the source-repo URLs the
per-recipe registry already records — no new per-recipe config needed.
2. Vendor release/security pages — every URL in cc-ci-plan/upstream/<recipe>.md, fetched and
regex-scanned for CVE ids. This is what would have caught gitea: the vendor blog names both,
while the GitHub releases page names neither. Add vendor security/announcement URLs to the
registry to widen this.
3. OSV.dev — supplementary, best-effort, only when the recipe declares an ecosystem/package
mapping below. NOTE: for gitea, OSV returned only Go *dependency* advisories and 404'd on both
application CVEs; NVD's API had them neither by CPE, CVE id, nor keyword. Advisory databases
lag the vendor — which is exactly why (1) and (2) lead.
Every source reports its own status, so "checked, none found" is never confused with "not checked".
Usage:
advisory-scan.py <recipe> [--from <version>] [--to <version>] [--json] [--registry DIR]
--from/--to are the app versions being upgraded between (e.g. 1.26.2 -> 1.27.1). When given, each
advisory is classified fixed-by-this-upgrade / still-open / older. Without them everything known
is listed unclassified. Exits 0 even when sources fail (informational; failures are reported).
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import re
import sys
import urllib.error
import urllib.request
REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", "/srv/cc-ci/cc-ci-plan/upstream")
UA = "cc-ci-advisory-scan (+https://git.autonomic.zone/recipe-maintainers/cc-ci)"
TIMEOUT = int(os.environ.get("ADVISORY_SCAN_TIMEOUT", "45"))
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
# Leading-version-component jump that means the scheme changed (semver → calver).
SCHEME_JUMP = 100
# A patched_versions field that names no usable version. GitHub carries these verbatim from the
# vendor: redis publishes "TBD" for 11 advisories and "6.2.X, 7.2.X, 7.4.X" for another, and their
# vulnerable_version_range is open-ended ("All", ">= 7.0.0"), so the fix version is NOT recoverable.
# Such an advisory must be reported as INDETERMINATE, never silently counted as "not fixed" — that
# would assert an upgrade did not fix something we simply cannot judge.
PLACEHOLDER_RE = re.compile(r"\bTBD\b|\bunknown\b|\bnone\b|\d+\.[Xx]\b|\?", re.I)
# Optional OSV mappings: recipe -> (ecosystem, package). Supplementary only (see module docstring).
OSV_PACKAGES: dict[str, tuple[str, str]] = {
"gitea": ("Go", "code.gitea.io/gitea"),
"n8n": ("npm", "n8n"),
}
def _github_token() -> str | None:
"""Read-only GitHub token, for the API rate limit ONLY (60/hr anonymous → 5000/hr with a token).
Env `GITHUB_TOKEN` wins; otherwise the file at `GITHUB_TOKEN_FILE` (default
/srv/cc-ci/.github-token, chmod 600, never in git). Reading PUBLIC security advisories needs NO
scopes at all — create a classic PAT with every box unticked, or a fine-grained token limited to
"Public repositories: read". Do NOT grant repo/write scopes: this tool only ever GETs advisories.
A missing token is not an error — the scan simply runs anonymously and will report sources as
failed once the 60/hr limit bites, which is visible rather than silent.
"""
tok = os.environ.get("GITHUB_TOKEN")
if tok:
return tok.strip()
path = os.environ.get("GITHUB_TOKEN_FILE", "/srv/cc-ci/.github-token")
try:
with open(path) as f:
return f.read().strip() or None
except OSError:
return None
def _gh_paginate(url: str, hdrs: dict, max_pages: int = 20):
"""Yield every row from a GitHub list endpoint, following Link rel=\"next\" cursors."""
seen_keys = set()
for _ in range(max_pages):
req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
rows = json.load(r)
link = r.headers.get("Link", "") or ""
fresh = 0
for a in rows:
k = a.get("ghsa_id") or json.dumps(a, sort_keys=True)[:120]
if k not in seen_keys:
seen_keys.add(k); fresh += 1
yield a
nxt = None
for part in link.split(","):
if 'rel="next"' in part:
nxt = part.split(";")[0].strip().strip("<>")
if not nxt or fresh == 0:
return
url = nxt
def _tag_date(owner: str, repo: str, version: str | None) -> str | None:
"""Publish date of a release tag, for DATE-BASED classification (see classify_by_date).
Version strings cannot be ordered across a scheme change (semver → calver), but tag dates
always can. Tries the common tag spellings; returns an ISO timestamp or None."""
if not version:
return None
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
for tag in (f"v{version}", version):
try:
ref = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}", hdrs))
obj = ref.get("object", {})
sha, typ = obj.get("sha"), obj.get("type")
if typ == "tag":
t = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/tags/{sha}", hdrs))
if t.get("tagger", {}).get("date"):
return t["tagger"]["date"]
sha = t.get("object", {}).get("sha")
c = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}", hdrs))
return c["commit"]["committer"]["date"]
except Exception: # noqa: BLE001 — try the next spelling
continue
return None
def _fetch(url: str, headers: dict | None = None) -> str:
h = {"User-Agent": UA, "Accept-Encoding": "gzip"}
h.update(headers or {})
with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=TIMEOUT) as r:
raw = r.read()
if r.headers.get("Content-Encoding") == "gzip":
raw = gzip.decompress(raw)
return raw.decode(errors="replace")
def _vkey(v: str | None) -> tuple:
"""Loose version ordering key: leading integers of each dot-part ('1.27.1-rootless' -> (1,27,1))."""
if not v:
return ()
v = v.strip().lstrip("vV").split("+")[0]
out = []
for part in re.split(r"[.\-_]", v):
m = re.match(r"^\d+", part)
if m:
out.append(int(m.group()))
elif out:
break
return tuple(out)
def _superseded_on_target_line(kt: tuple, cands: list[tuple]) -> bool:
"""Does a patched version on the TARGET's own release line sit ABOVE the target?
Projects maintain several branches at once and backport per branch, so "some patched version is
inside the numeric window" is not the same as "the version we land on has the fix". ClickHouse
fixed CVE-2023-48704 in 23.9.6.20 AND 23.10.5.20; an upgrade landing on 23.10.4.25 crosses the
23.9 fix numerically but is still BELOW its own line's fix, so it does NOT have it. When the
advisory names a fix on the target's own line and the target is older than it, that is proof of
absence and outranks any other candidate."""
if len(kt) < 2:
return False
line = kt[:2]
return any(c[:2] == line and c > kt for c in cands if len(c) >= 2)
def _within(kf: tuple, kt: tuple, c: tuple) -> bool:
"""Is patched-version `c` inside the window (kf, kt] — exclusive lower, inclusive upper?
Compares ZERO-PADDED to equal length, so "18" == "18.0" == "18.0.0" the way semver means it.
Without the padding, plain tuple order says (18,) < (18,0), i.e. a CVE patched in 18.0 falls
OUTSIDE a window ending at 18 — and bare major tags are the norm for sidecars (postgres:18,
redis:8-alpine), so that silently dropped real fixes. Padding also keeps the upper bound
conservative: a fix in 18.5 is still outside a window ending at "18", because nothing proves
which 18.x a floating tag resolved to.
"""
n = max(len(kf), len(kt), len(c))
pad = lambda t: t + (0,) * (n - len(t))
return pad(kf) < pad(c) <= pad(kt)
def registry_urls(recipe: str, registry_dir: str) -> tuple[list[str], str | None]:
path = os.path.join(registry_dir, f"{recipe}.md")
try:
with open(path) as f:
text = f.read()
except OSError:
return [], None
urls = []
for u in re.findall(r"https?://[^\s)|\]]+", text):
# The registry is MARKDOWN: urls appear inside `backticks`, 'quotes', **bold**, and at the
# end of sentences. Trailing punctuation captured into the url makes the fetch 404 and the
# 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 u and u not in urls:
urls.append(u)
return urls, path
def github_advisories(urls: list[str]) -> list[dict]:
"""Query GitHub Security Advisories for every github.com/<owner>/<repo> in the registry."""
seen, results = set(), []
for u in urls:
m = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", u)
if not m:
continue
owner, repo = m.group(1), m.group(2).removesuffix(".git")
if (owner, repo) in seen:
continue
seen.add((owner, repo))
api = f"https://api.github.com/repos/{owner}/{repo}/security-advisories?per_page=100"
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
entry = {"source": f"github-advisories:{owner}/{repo}", "status": "ok", "advisories": []}
try:
# PAGINATE. This endpoint caps at 100 per response and IGNORES ?page= — it returns the
# same rows again, which silently truncates busy projects (discourse has 286; a hand
# count on 2026-08-10 found 123 CVEs in one upgrade window that a single page missed).
# Follow the Link rel="next" cursor to exhaustion instead.
for a in _gh_paginate(api, hdrs):
# An advisory carries ONE ENTRY PER PATCHED RELEASE LINE. n8n patches three
# (1.123.32, 2.17.4, 2.18.1); reading only vulnerabilities[0] silently dropped the
# line our deployment is actually on, so CVE-2026-42231/42232 classified as
# out-of-window. Keep them ALL and let the classifier match any of them.
vulns = a.get("vulnerabilities") or []
entry["advisories"].append(
{
"cve": a.get("cve_id"),
"ghsa": a.get("ghsa_id"),
"severity": a.get("severity"),
"summary": (a.get("summary") or "")[:200],
"vulnerable_range": "; ".join(
filter(None, (v.get("vulnerable_version_range") for v in vulns))
) or None,
"patched": "; ".join(
filter(None, (v.get("patched_versions") for v in vulns))
) or None,
"url": a.get("html_url"),
"published_at": a.get("published_at"),
# The list response ALREADY carries the prose. Keep it: the adjudication
# pass needs it, and re-fetching per advisory costs a request each.
"description": (a.get("description") or "")[:4000],
"cvss": ((a.get("cvss") or {}).get("vector_string")),
}
)
except urllib.error.HTTPError as e:
# 404 = this repo simply publishes no security advisories (e.g. sidecar images like
# pgautoupgrade). That is a BENIGN ABSENCE, not a failed check — conflating the two
# would push nearly every recipe to "unknown" and make the ? signal meaningless again.
entry["status"] = "no-advisories-published" if e.code == 404 else f"error: HTTP {e.code}"
except Exception as e: # noqa: BLE001 — a genuinely dead source must be REPORTED, never silent
entry["status"] = f"error: {type(e).__name__}: {e}"
results.append(entry)
return results
# Release headings in a vendor changelog. nginx's CHANGES uses "Changes with nginx 1.31.3", most
# markdown changelogs use "## 1.31.3" / "## v1.31.3".
_HEADING_RE = re.compile(
r"^\s*(?:#{1,4}\s*)?(?:Changes with\s+\S+\s+|Version\s+|Release\s+)?v?(\d+\.\d+(?:\.\d+)*)\s*$"
r"|^\s*Changes with\s+\S+\s+(\d+\.\d+(?:\.\d+)*)", re.I)
def _changelog_versions(text: str) -> dict:
"""{cve: version} for a changelog that is ORGANISED BY RELEASE.
Why this exists: nginx publishes NO GitHub security advisories. Every nginx CVE we can see comes
from nginx.org/en/CHANGES, and scraping ids out of it without attributing them to a release
leaves them with no patched version — so they can never be classified, and an nginx bump reports
0 CVEs forever. nginx 1.31.1 -> 1.31.3 in fact fixes SIX (three in .2, three in .3), and nginx is
a sidecar in most of the fleet, so that was a fleet-wide blind spot.
Attributes each CVE to the nearest PRECEDING release heading — the release that fixed it.
"""
plain = re.sub(r"<[^>]+>", " ", text)
out, cur = {}, None
for line in plain.splitlines():
m = _HEADING_RE.match(line)
if m:
cur = m.group(1) or m.group(2)
continue
if cur:
for cve in CVE_RE.findall(line):
out.setdefault(cve, cur)
return out
def vendor_pages(urls: list[str]) -> list[dict]:
"""Fetch each registry URL and regex out CVE ids, with a little surrounding context."""
out = []
for u in urls:
if u.startswith("https://api.github.com"):
continue
if re.search(r"[<>{}]|\bVERSION\b|\bvX\.Y\.Z\b", u):
# Registry entries sometimes carry TEMPLATE urls for humans
# (…/changelog/v<VERSION>/). They are documentation, not fetchable — skipping them is
# correct; counting them as failures would wrongly mark the recipe's count unreliable.
out.append({"source": u, "status": "skipped: template URL (not fetchable)", "cves": [], "context": {}})
continue
entry = {"source": u, "status": "ok", "cves": [], "context": {}, "fixed_in": {}}
try:
text = _fetch(u)
plain = re.sub(r"<[^>]+>", " ", text)
for cve in sorted(set(CVE_RE.findall(plain))):
entry["cves"].append(cve)
i = plain.find(cve)
entry["context"][cve] = re.sub(r"\s+", " ", plain[max(0, i - 160) : i + 200]).strip()
entry["fixed_in"] = _changelog_versions(text)
except Exception as e: # noqa: BLE001
entry["status"] = f"error: {type(e).__name__}: {e}"
out.append(entry)
return out
def osv(recipe: str, version: str | None) -> dict | None:
pkg = OSV_PACKAGES.get(recipe)
if not pkg or not version:
return None
eco, name = pkg
entry = {"source": f"osv:{eco}/{name}@{version}", "status": "ok", "cves": []}
try:
body = json.dumps({"package": {"name": name, "ecosystem": eco}, "version": version}).encode()
req = urllib.request.Request(
"https://api.osv.dev/v1/query", data=body,
headers={"Content-Type": "application/json", "User-Agent": UA}, method="POST",
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
data = json.load(r)
ids = set()
for v in data.get("vulns", []):
for a in [v.get("id")] + (v.get("aliases") or []):
if a and a.startswith("CVE"):
ids.add(a)
entry["cves"] = sorted(ids)
except Exception as e: # noqa: BLE001
entry["status"] = f"error: {type(e).__name__}: {e}"
return entry
_RELEASE_CACHE: dict[str, list[tuple[str, str]]] = {}
def _releases(owner: str, repo: str, max_pages: int = 4) -> list[tuple[str, str]]:
"""[(tag, body)] for a repo's GitHub releases, cached per repo for the process."""
key = f"{owner}/{repo}"
if key in _RELEASE_CACHE:
return _RELEASE_CACHE[key]
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
out: list[tuple[str, str]] = []
try:
for rel in _gh_paginate(
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=100", hdrs, max_pages
):
out.append((rel.get("tag_name") or "",
f"{rel.get('name') or ''}\n{rel.get('body') or ''}"))
except Exception: # noqa: BLE001 — best effort; absence just leaves advisories undetermined
pass
_RELEASE_CACHE[key] = out
return out
def _source_repo(source: str) -> tuple[str, str] | None:
"""(owner, repo) for a source, whether it is an advisory feed or a vendor page on GitHub.
A CVE that appears ONLY on a vendor page still deserves the release-note method when that page
lives on GitHub — mailu announces its Roundcube CVEs on github.com/Mailu/Mailu/releases and
nowhere structured, so requiring an advisory feed sent a deterministic case to pass 2."""
if source.startswith("github-advisories:"):
owner, _, repo = source.split(":", 1)[1].partition("/")
return (owner, repo) if owner and repo else None
m = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", source)
return (m.group(1), m.group(2).removesuffix(".git")) if m else None
def release_fix_versions(source: str, cve: str) -> list[str]:
"""Release tags whose notes NAME this CVE — a deterministic fix version when the advisory has none.
Vendors routinely publish an advisory with `patched_versions: "TBD"` and then name the CVE in the
release notes of every branch that got the fix. redis does exactly this: all 12 of its advisories
that discourse's redis bump crosses carry TBD, yet each is named in concrete releases
(CVE-2025-32023 → 6.2.19, 7.2.10, 7.4.5, 8.0.3, 8.2.0). Ignoring that evidence undercounted
discourse by 12 CVEs, so this is checked BEFORE giving up on an advisory.
"""
ref = _source_repo(source)
if not ref:
return []
return [tag for tag, body in _releases(*ref) if cve in body]
def advisory_text(ghsa: str, source: str | None = None) -> dict:
"""Full text of one advisory, for the ADJUDICATION pass (see adjudication_block).
The structured `patched_versions` field is often "TBD" while the prose description and the
linked references DO state where the fix landed. That prose is not machine-parseable in general
— which is the point: it is collected here for a MODEL to judge, not for a regex."""
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
out = {"ghsa": ghsa, "status": "ok"}
# Repo-scoped FIRST. Many repository advisories are never mirrored into the global GitHub
# Advisory Database, so /advisories/<ghsa> 404s for them (all 12 redis ones, for instance)
# while /repos/<owner>/<repo>/security-advisories/<ghsa> returns the full record.
cands = []
if source and source.startswith("github-advisories:"):
cands.append(f"https://api.github.com/repos/{source.split(':',1)[1]}/security-advisories/{ghsa}")
cands.append(f"https://api.github.com/advisories/{ghsa}")
try:
a, last = None, None
for u in cands:
try:
a = json.loads(_fetch(u, hdrs)); break
except Exception as ex: # noqa: BLE001 — try the next endpoint
last = ex
if a is None:
raise last or RuntimeError("no advisory endpoint responded")
out.update({
"summary": a.get("summary"),
"description": (a.get("description") or "")[:4000],
"severity": a.get("severity"),
"published_at": a.get("published_at"),
"references": [r for r in (a.get("references") or [])][:12] or (
[a.get("html_url")] if a.get("html_url") else []),
"cvss": (a.get("cvss") or {}).get("vector_string"),
"vulnerabilities": [
{"package": (v.get("package") or {}).get("name"),
"vulnerable_version_range": v.get("vulnerable_version_range"),
"first_patched_version": v.get("first_patched_version")}
for v in (a.get("vulnerabilities") or [])
],
})
except Exception as e: # noqa: BLE001
out["status"] = f"error: {type(e).__name__}: {e}"
return out
MAX_ADJUDICATE = int(os.environ.get("ADVISORY_SCAN_MAX_ADJUDICATE", "25"))
# Compact review rows for advisories pass 1 DID decide. Pass 2 sees these too, so a wrong
# deterministic verdict can be caught rather than inherited.
MAX_REVIEW_ROWS = int(os.environ.get("ADVISORY_SCAN_MAX_REVIEW", "400"))
def needs_judgement(rep: dict) -> list[str]:
"""CVEs the deterministic pass could not decide — the input set for the adjudication pass.
Two kinds, both real gaps rather than noise:
1. INDETERMINATE — from an image WITH a window, but no fix version is knowable (advisory says
`TBD`/`7.4.X`, range is open-ended, and no release note names it).
2. VENDOR-PAGE-ONLY — a CVE seen only on a vendor security page, with no structured advisory
behind it at all. gitea's two CVSS-9.8 RCEs are this shape. They carry no version data, so
no arithmetic can place them, but the page's prose usually states the fixed release.
"""
windows = rep.get("windows") or {}
out = list(rep.get("indeterminate") or [])
for cve in rep.get("unclassified") or []:
srcs = rep["cves"][cve]["sources"]
if not any(s.startswith("github-advisories:") for s in srcs) and cve not in out:
out.append(cve)
return sorted(out)
def evidence_bundle(rep: dict, cve: str) -> dict:
"""EVERY deterministic signal held about one CVE, gathered for a model to weigh.
Pass 1 collects; pass 2 judges. Nothing here interprets — it assembles what was measured, so the
judgement is made against evidence rather than recollection (the exact failure that let two
CVSS-9.8 gitea RCEs be published as "none": the report leaned on model knowledge that predated
them, and no source had been queried at all).
"""
e = rep["cves"][cve]
src = e["sources"][0]
windows = rep.get("windows") or {}
win = next((windows[s] for s in e["sources"] if s in windows), None)
ev = {
"cve": cve,
"severity": e.get("severity"),
"cvss": e.get("cvss"),
"sources": e["sources"],
"window": win,
"why_undecided": ("no fix version published and no release note names it"
if cve in (rep.get("indeterminate") or [])
else "seen only on a vendor page — no structured advisory, no version data"),
"patched_as_published": e.get("patched"),
"vulnerable_range_as_published": e.get("vulnerable_range"),
"summary": e.get("context"),
"description": e.get("description"),
"advisory_url": e.get("url"),
# Release tags NAMING this CVE, whether or not they fall in the window — the model may
# reason about branch lines the arithmetic deliberately would not.
"releases_naming_it": release_fix_versions(src, cve) if src.startswith("github-advisories:") else [],
"references": [],
}
if e.get("ghsa"):
t = advisory_text(e["ghsa"], src)
if t.get("status") == "ok":
ev["references"] = t.get("references") or []
ev["description"] = t.get("description") or ev["description"]
ev["summary"] = t.get("summary") or ev["summary"]
ev["affected"] = t.get("vulnerabilities") or []
else:
ev["detail_fetch"] = t.get("status")
return ev
def adjudication_block(rep: dict) -> str:
"""SECOND PASS: present the collected evidence and ask for a judgement on each open case.
This block decides nothing. The deterministic count stands as a FLOOR; a verdict here may only
ADD to it, matching the rule that this scan raises a count on evidence but never lowers one.
"""
todo = needs_judgement(rep)
if not todo:
return ""
shown, dropped = todo[:MAX_ADJUDICATE], max(0, len(todo) - MAX_ADJUDICATE)
L = ["", "---", "",
f"## Adjudication pass — {len(todo)} advisory/advisories need judgement", "",
"Pass 1 collected the evidence below deterministically and could NOT decide these cases. "
"Weigh the evidence and decide each one.", "",
"**For each: did the version move in its window fix it?** Answer **FIXED** / **NOT-FIXED** / "
"**STILL-UNKNOWN**, each with a one-line reason **citing the evidence shown** — a fixed "
"release named in the text, a branch line, an affected range. Add every FIXED to the "
"recipe's CVE count; the deterministic number is a floor, not a total. If the evidence does "
"not settle it, say STILL-UNKNOWN: do NOT infer from memory of the project, and never "
"record an undecided CVE as unaffected.", ""]
for src, win in (rep.get("windows") or {}).items():
L.append(f"- window: `{src.split(':',1)[-1]}` {win['from']}{win['to']}")
if dropped:
L += ["", f"⚠ Showing the first {MAX_ADJUDICATE} of {len(todo)}; **{dropped} not shown** "
f"(raise ADVISORY_SCAN_MAX_ADJUDICATE). The unshown remain undetermined — do not "
f"treat them as absent."]
L.append("")
for cve in shown:
ev = evidence_bundle(rep, cve)
L.append(f"### {cve}{ev['severity'] or '?'}")
L.append(f"- undecided because: {ev['why_undecided']}")
L.append(f"- source: `{ev['sources'][0]}`"
+ (f" · window {ev['window']['from']}{ev['window']['to']}" if ev["window"] else
" · **no version window** for this image"))
L.append(f"- patched_versions as published: `{ev['patched_as_published']}`")
L.append(f"- vulnerable_range as published: `{ev['vulnerable_range_as_published']}`")
if ev["releases_naming_it"]:
L.append(f"- **releases naming this CVE**: {', '.join(ev['releases_naming_it'][:14])}")
for v in ev.get("affected") or []:
L.append(f"- affects `{v.get('package')}` {v.get('vulnerable_version_range')} — "
f"first_patched_version: {v.get('first_patched_version')}")
if ev["references"]:
L.append(f"- references: {', '.join(r.strip() for r in ev['references'][:6])}")
if ev.get("detail_fetch"):
L.append(f"- ⚠ detail fetch failed: {ev['detail_fetch']} (evidence below is from pass 1)")
if ev["summary"]:
L += ["", f"> {ev['summary']}"]
if ev["description"]:
L += ["", "```", (ev["description"] or "").strip()[:2000], "```"]
L.append("")
# ── everything pass 1 DID decide, with the evidence behind each verdict ──────────────────────
# Pass 2 must see the whole picture, not only the leftovers: a deterministic verdict can still
# be wrong (a mis-parsed range, a release note that names a CVE without fixing it), and only a
# reader with the evidence in front of it can catch that.
decided = []
for cve in rep.get("fixed_by_this_upgrade") or []:
e = rep["cves"][cve]
decided.append((cve, "COUNTED", e))
for cve, e in sorted(rep.get("cves", {}).items()):
if e.get("classification") == "outside-window":
decided.append((cve, "excluded (outside window)", e))
if decided:
shown_rows, dropped_rows = decided[:MAX_REVIEW_ROWS], max(0, len(decided) - MAX_REVIEW_ROWS)
L += ["---", "",
f"## Pass 1 decisions — {len(decided)} already judged deterministically", "",
"Review these too. If any verdict looks wrong given its evidence, say so and explain; "
"a correction here changes the count. Silence means you agree.", ""]
if dropped_rows:
L.append(f"⚠ Showing {MAX_REVIEW_ROWS} of {len(decided)}; **{dropped_rows} not shown** "
f"(raise ADVISORY_SCAN_MAX_REVIEW).")
L.append("")
L += ["| CVE | verdict | severity | patched as published | releases naming it | source |",
"|---|---|---|---|---|---|"]
for cve, verdict, e in shown_rows:
rel = e.get("fix_versions_from_release_notes") or []
L.append(f"| {cve} | {verdict} | {e.get('severity') or '?'} | "
f"{(e.get('patched') or '—')[:60]} | {', '.join(rel[:5]) or '—'} | "
f"{e['sources'][0].split(':',1)[-1]} |")
L.append("")
return "\n".join(L)
def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
images: list[tuple[str, str, str]] | None = None) -> dict:
urls, reg_path = registry_urls(recipe, registry_dir)
report: dict = {
"recipe": recipe,
"from": v_from,
"to": v_to,
"registry": reg_path,
"registry_urls": len(urls),
"sources": [],
"cves": {},
}
if reg_path is None:
report["sources"].append(
{"source": f"registry:{recipe}.md", "status": "error: registry file not found"}
)
def record(cve: str, src: str, **extra):
e = report["cves"].setdefault(cve, {"sources": [], "severity": None, "ghsa": None,
"vulnerable_range": None, "patched": None,
"context": None, "published_at": None,
"description": None, "url": None, "cvss": None,
"changelog_fixed_in": None})
if src not in e["sources"]:
e["sources"].append(src)
for k, v in extra.items():
if v and not e.get(k):
e[k] = v
for entry in github_advisories(urls):
report["sources"].append({"source": entry["source"], "status": entry["status"],
"found": len(entry.get("advisories", []))})
for a in entry.get("advisories", []):
if a.get("cve"):
record(a["cve"], entry["source"], severity=a.get("severity"), ghsa=a.get("ghsa"),
vulnerable_range=a.get("vulnerable_range"), patched=a.get("patched"),
context=a.get("summary"), published_at=a.get("published_at"),
description=a.get("description"), url=a.get("url"), cvss=a.get("cvss"))
for entry in vendor_pages(urls):
report["sources"].append({"source": entry["source"], "status": entry["status"],
"found": len(entry.get("cves", []))})
for cve in entry.get("cves", []):
record(cve, entry["source"], context=entry["context"].get(cve),
changelog_fixed_in=(entry.get("fixed_in") or {}).get(cve))
for version in filter(None, (v_from, v_to)):
o = osv(recipe, version)
if o:
report["sources"].append({"source": o["source"], "status": o["status"],
"found": len(o.get("cves", []))})
for cve in o.get("cves", []):
record(cve, o["source"])
# Classify against the upgrade window when we know it: an advisory is "fixed by this upgrade"
# when its patched version is newer than `from` and no newer than `to`.
#
# TWO HARD-WON CONSTRAINTS (2026-08-10, discourse reported a false 133):
# a) The window belongs to ONE image. Advisories from OTHER repos in the registry (redis,
# postgres, nginx sidecars) must NOT be judged by it — redis CVE-2021-21309, patched in
# redis 6.0.11, scored as "fixed" because 6.0.11 sits numerically inside discourse's
# 3.5.3 → 2026.7.1 window. Only the PRIMARY app repo is classified; every other source is
# reported as unclassified so a human/agent still sees it but it never inflates the count.
# b) A version-SCHEME change (semver → calver, 3.5.3 → 2026.7.1) makes numeric ordering
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier. When the
# leading component jumps by more than SCHEME_JUMP we refuse to classify and say so,
# rather than emitting a confident wrong number.
# ── Classification ────────────────────────────────────────────────────────────────────────
# A recipe upgrades SEVERAL images (app + redis/postgres/nginx sidecars), each with its OWN
# version window. Judging every advisory by the app's window is how discourse once reported a
# false 133 (34 of them redis CVEs, incl. one patched in redis 6.0.11 in 2021). So each source
# is classified against ITS OWN window, and the count is the union across windows.
#
# --from/--to → the PRIMARY app repo (first github source in the registry)
# --image NAME=FROM:TO → any other source whose name contains NAME (repeatable),
# e.g. --image redis=7.4:8.10
#
# A source with no window is not classified: its advisories are listed as unclassified so they
# stay visible without inflating the count.
gh_sources = [x["source"] for x in report["sources"] if x["source"].startswith("github-advisories:")]
primary = gh_sources[0] if (gh_sources and (v_from or v_to)) else None
report["primary_source"] = primary
windows = {} # source name -> (from, to)
window_key = {} # source name -> the image name it covers
if primary:
windows[primary] = (v_from, v_to)
window_key[primary] = primary.split("/")[-1]
for key, wf, wt in (images or []):
for src in gh_sources:
if src in windows:
continue
# Match BOTH ways: an image name is often longer than its source repo
# (`clickhouse/clickhouse-server` vs source `ClickHouse/ClickHouse`) and sometimes
# shorter (`redis` vs `redis/redis`). One-directional matching silently dropped the
# clickhouse window when the key was derived from a compose file.
k, name = key.lower(), src.split("/")[-1].lower()
if k in src.lower() or name in k:
windows[src] = (wf, wt)
window_key[src] = key
report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()}
def _classify_window(src, wf, wt):
"""Return (fixed, method, date_window|None, unresolved, indeterminate) for one source.
`indeterminate` = advisories from this source that the method COULD NOT JUDGE (no usable
patched version, or no publish date). They are neither counted nor dismissed."""
kf, kt = _vkey(wf), _vkey(wt)
# A version-SCHEME change (semver 3.5.3 → calver 2026.7.1) makes numeric ordering
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier.
scheme = bool(kf and kt and abs(kt[0] - kf[0]) >= SCHEME_JUMP)
if not scheme:
got, undecidable = set(), set()
for cve, e in report["cves"].items():
if src not in e["sources"]:
continue
patched = e.get("patched") or ""
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", patched)]
if kf and kt and _superseded_on_target_line(kt, cands):
# The target's own line got the fix LATER than the target: not fixed here.
e.setdefault("classification", "outside-window")
continue
if kf and kt and any(_within(kf, kt, c) for c in cands):
got.add(cve)
elif not patched or PLACEHOLDER_RE.search(patched):
# No fix version published ("TBD") or only a placeholder ("7.4.X" — which could
# be 7.4.1, inside the window). We cannot say either way, so say so.
undecidable.add(cve)
return got, "patched version ranges", None, False, undecidable
# DATE FALLBACK: release DATES always order, even across a scheme change. Resolve both
# versions to git tag dates and count advisories PUBLISHED in that window — the method a
# hand count used to establish discourse 3.5.3 (2025-12-30) → 2026.7.1 (2026-07-31) = 123.
owner, _, repo = src.split(":", 1)[1].partition("/")
d_from, d_to = _tag_date(owner, repo, wf), _tag_date(owner, repo, wt)
if d_from and d_to and d_from < d_to:
got = {cve for cve, e in report["cves"].items()
if src in e["sources"] and e.get("published_at")
and d_from < e["published_at"] <= d_to}
undecidable = {cve for cve, e in report["cves"].items()
if src in e["sources"] and not e.get("published_at")}
return got, "advisory publish date (version scheme changed)", (d_from, d_to), False, undecidable
return set(), "unresolved", None, True, set()
fixed_set, methods, date_windows, unresolved_any = set(), {}, {}, False
indeterminate: set = set()
for src, (wf, wt) in windows.items():
got, method, dw, unresolved, undecidable = _classify_window(src, wf, wt)
indeterminate |= undecidable
methods[src] = method
if dw:
date_windows[src] = {"from": dw[0], "to": dw[1]}
if unresolved:
unresolved_any = True
for cve in got:
report["cves"][cve]["classification"] = f"fixed-by-this-upgrade ({method}) via {src}"
fixed_set.add(cve)
# A CVE seen only in a vendor CHANGELOG has no advisory feed behind it, but the changelog says
# which release fixed it (see _changelog_versions). Tie it to a window by the image name
# appearing in the page URL — nginx's window is `nginx`, and its changelog is nginx.org/... .
# Without this, projects that publish no GitHub advisories (nginx being the big one) can never
# contribute a CVE, and every nginx bump in the fleet silently reports 0.
from_changelog = {}
for cve, e in report["cves"].items():
if cve in fixed_set or not e.get("changelog_fixed_in"):
continue
for src, (wf, wt) in windows.items():
key = (window_key.get(src) or "").lower()
if not key:
continue
if not any(key in s_.lower() for s_ in e["sources"] if s_.startswith("http")):
continue
kf, kt = _vkey(wf), _vkey(wt)
cand = _vkey(e["changelog_fixed_in"])
if kf and kt and cand and _within(kf, kt, cand):
e["classification"] = (f"fixed-by-this-upgrade (named under {e['changelog_fixed_in']} "
f"in the vendor changelog) via {src}")
fixed_set.add(cve)
from_changelog[cve] = e["changelog_fixed_in"]
break
if from_changelog:
report["resolved_by_changelog"] = from_changelog
unknown = []
for cve, e in report["cves"].items():
if cve in fixed_set:
continue
if not any(src in e["sources"] for src in windows):
e["classification"] = "unclassified: no versions given for this image"
unknown.append(cve)
else:
e.setdefault("classification", "outside-window")
if e["classification"] == "outside-window":
pass
else:
unknown.append(cve)
report["classified_by"] = methods
if date_windows:
report["date_window"] = date_windows
# THIRD METHOD: before declaring an advisory undecidable, look for the CVE id in the project's
# own release notes. A tag that names it, inside the window, IS the fix version the advisory
# failed to publish. Deterministic and citable — not a judgement call.
# A window is keyed by advisory-feed source; map it to its repo so a vendor page on the SAME
# repo can be judged by the same window.
win_by_repo = {}
for wsrc, wv in windows.items():
ref = _source_repo(wsrc)
if ref:
win_by_repo[ref] = wv
resolved_by_release, already_fixed = {}, set()
candidates = set(indeterminate) | {
c for c in unknown
if not any(s in windows for s in report["cves"][c]["sources"])
and any(_source_repo(s) in win_by_repo for s in report["cves"][c]["sources"])
}
for cve in sorted(candidates - fixed_set):
e = report["cves"][cve]
for src in e["sources"]:
ref = _source_repo(src)
if src not in windows and ref not in win_by_repo:
continue
wf, wt = windows[src] if src in windows else win_by_repo[ref]
kf, kt = _vkey(wf), _vkey(wt)
if not (kf and kt):
continue
naming = release_fix_versions(src, cve)
hits = [t for t in naming if _within(kf, kt, _vkey(t))]
if hits:
fixed_set.add(cve)
resolved_by_release[cve] = sorted(hits)
e["classification"] = (f"fixed-by-this-upgrade (named in release notes "
f"{', '.join(sorted(hits))}) via {src}")
e["fix_versions_from_release_notes"] = sorted(hits)
break
# Naming releases exist but ALL predate the version we were already on: the fix shipped
# before this upgrade, so the upgrade did not deliver it. That is a DECISION, not an
# unknown — mailu's redis 8.8.0 → 8.10.0 crosses 12 advisories all fixed by 8.6.3 or
# earlier, and reporting them as "could not judge" overstates the uncertainty.
if naming and all(_vkey(t) and not _within(kf, kt, _vkey(t)) for t in naming) \
and max(_vkey(t) for t in naming if _vkey(t)) <= kf:
e["classification"] = ("outside-window: fixed in "
f"{', '.join(sorted(naming))}, all at or before {wf}")
e["fix_versions_from_release_notes"] = sorted(naming)
already_fixed.add(cve)
break
if resolved_by_release:
report["resolved_by_release_notes"] = resolved_by_release
indeterminate -= fixed_set | already_fixed
if already_fixed:
report["already_fixed_before_upgrade"] = sorted(already_fixed)
for cve in indeterminate:
report["cves"][cve]["classification"] = "indeterminate: no fix version published"
unknown = [c for c in unknown if c not in fixed_set]
report["fixed_by_this_upgrade"] = sorted(fixed_set)
report["indeterminate"] = sorted(indeterminate)
report["cve_count_indeterminate"] = len(indeterminate)
report["unclassified"] = sorted(unknown)
# NEVER report 0 for something we could not determine — a 0 asserts safety. If ANY requested
# window could not be ordered at all, the total is UNKNOWN rather than a partial 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
# 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")
]
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:")))
]
report["sources_benign"] = [
s["source"]
for s in report["sources"]
if s["status"].startswith(("no-advisories-published", "skipped:"))
]
return report
def markdown(rep: dict) -> str:
"""Human/agent-readable block for pasting into the per-recipe upgrade log."""
L = [f"### Advisory scan (deterministic pre-step) — {rep['recipe']} "
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("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.")
if rep["unclassified"]:
L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes "
f"other images in this recipe: " + ", ".join(rep["unclassified"][:12]))
if rep["sources_failed"]:
L.append(f"\n⚠ sources that FAILED: {', '.join(rep['sources_failed'])}")
L.append(f"\n_Sources checked: {len(rep['sources'])} ({rep['registry_urls']} registry URLs + "
f"advisory APIs). This scan is ADDITIVE — it does not replace the release-note "
f"reading in the upgrade step._")
return "\n".join(L)
ind = rep.get("cve_count_indeterminate") or 0
if rep["fixed_by_this_upgrade"]:
floor = " (at least — see undetermined below)" if ind else ""
L.append(f"\n**CVEs fixed by this upgrade: {rep['cve_count_fixed']}**{floor}\n")
cb = rep.get("classified_by") or {}
if isinstance(cb, dict) and cb:
for src, method in cb.items():
dw = (rep.get("date_window") or {}).get(src)
win = (rep.get("windows") or {}).get(src, {})
span = f"{win.get('from')}{win.get('to')}"
extra = (f" (dates {dw['from'][:10]}{dw['to'][:10]})" if dw else "")
L.append(f"_{src.split(':',1)[-1]}: {span} — counted by {method}{extra}._")
L.append("")
L.append("| CVE | severity | fixed in | advisory | source |")
L.append("|---|---|---|---|---|")
for cve in rep["fixed_by_this_upgrade"]:
e = rep["cves"][cve]
L.append(f"| {cve} | {e.get('severity') or '?'} | {e.get('patched') or '?'} | "
f"{e.get('ghsa') or '-'} | {e['sources'][0]} |")
else:
L.append("\n**CVEs fixed by this upgrade: 0 identified by the deterministic scan.**")
if ind:
L.append(f"\n⚠ **{ind} advisory/advisories could NOT be judged** — the vendor published no fix "
f"version (GitHub carries `TBD` or a placeholder like `7.4.X`) and the vulnerable "
f"range is open-ended, so neither method can tell whether this upgrade fixed them. "
f"They are NOT included in the count above and must NOT be read as unaffected: "
+ ", ".join(f"{c} ({rep['cves'][c].get('severity') or '?'})"
for c in rep["indeterminate"][:15])
+ (" …" if len(rep["indeterminate"]) > 15 else "")
+ "\n\nRe-run with `--adjudicate` for the collected evidence on each, to judge.")
if rep["unclassified"]:
L.append(f"\nSeen but not version-classified ({len(rep['unclassified'])}) — includes advisories "
f"from OTHER images in this recipe (sidecars), which this window cannot judge: "
+ ", ".join(rep["unclassified"][:12]))
if rep["sources_failed"]:
L.append(f"\n⚠ sources that FAILED (treat counts as incomplete): {', '.join(rep['sources_failed'])}")
L.append(f"\n_Sources checked: {len(rep['sources'])} "
f"({rep['registry_urls']} registry URLs + advisory APIs). This scan is ADDITIVE — it does "
f"not replace the release-note reading in the upgrade step._")
return "\n".join(L)
def _gitea_auth(url: str) -> dict:
"""Basic auth for the private mirror, from /srv/cc-ci/.testenv.
Sent as a HEADER, never embedded in the URL: in-URL credentials leak into shell history, process
lists and error messages, and urllib mis-parses a password containing a colon."""
host = re.sub(r"^https?://", "", url).split("/")[0]
env = {}
try:
for ln in open(os.environ.get("CCCI_TESTENV", "/srv/cc-ci/.testenv")):
if "=" in ln and not ln.strip().startswith("#"):
k, v = ln.strip().split("=", 1)
env[k] = v.strip().strip("\"'")
except OSError:
return {}
if host != env.get("GITEA_URL", "git.autonomic.zone"):
return {}
u, pw = env.get("GITEA_USERNAME"), env.get("GITEA_PASSWORD")
if not (u and pw):
return {}
import base64 as _b64
return {"Authorization": "Basic " + _b64.b64encode(f"{u}:{pw}".encode()).decode()}
def _compose_images(url: str) -> dict[str, tuple[str, str]]:
"""{service: (image-repo, tag)} for a compose file.
Keyed by SERVICE, not by image repo, because an upgrade may change the repo itself: plausible
moved `plausible/analytics` -> `ghcr.io/plausible/community-edition`. Keyed by repo that reads
as one image vanishing and an unrelated one appearing, and the app's version window is lost —
which is exactly the upgrade most worth scanning."""
txt = _fetch(url, _gitea_auth(url))
out, svc = {}, None
in_services = False
for line in txt.splitlines():
if re.match(r"^services:\s*$", line):
in_services = True
continue
if in_services and re.match(r"^\S", line):
in_services = False
if not in_services:
continue
m = re.match(r"^ (\S+):\s*$", line)
if m:
svc = m.group(1)
continue
m = re.match(r"^\s+image:\s*[\"']?([^\"'\s]+)", line)
if m and svc:
ref = m.group(1).split("@", 1)[0]
if "${" in ref or "$(" in ref:
continue
repo, _, tag = ref.rpartition(":")
if repo and tag:
out[svc] = (repo, tag)
return out
def _default_branch_compose(url: str) -> str | None:
"""Same repo as `url`, but its DEFAULT branch — resolved from the API, never assumed.
Several coopcloud recipes keep a stale `main` beside the real default `master` (gitea's `main`
is 1.24.2-rootless while `master` has 1.27.1-rootless), so guessing the branch produces a
confidently wrong baseline."""
m = re.match(r"(https?://[^/]+)/([^/]+)/([^/]+)/(?:raw|src)/branch/[^/]+/(.*)$", url)
if not m:
return None
host, owner, repo, path = m.groups()
try:
meta = json.loads(_fetch(f"{host}/api/v1/repos/{owner}/{repo}", _gitea_auth(host)))
br = meta.get("default_branch")
except Exception: # noqa: BLE001
return None
return f"{host}/{owner}/{repo}/raw/branch/{br}/{path}" if br else None
def windows_from_compose(to_url: str, from_url: str | None = None) -> tuple[list, str | None]:
"""Derive the scan's version windows by DIFFING two compose files.
This is the deterministic alternative to a human (or a model) deciding which `--image` args a
given upgrade needs. Point it at a PR's compose and it reads the windows straight off the diff:
every image whose tag changed becomes a window, every image that did not change is correctly
left out, and nothing depends on anyone remembering that the recipe also bumped its redis.
Returns (windows, note) where windows is [(image-name, from, to)].
"""
if from_url is None:
from_url = _default_branch_compose(to_url)
if not from_url:
raise SystemExit("could not resolve a baseline compose; pass --compose-from explicitly")
new, old = _compose_images(to_url), _compose_images(from_url)
app, others = None, []
for svc, (repo, tag) in sorted(new.items()):
if svc not in old:
continue
prev_repo, prev_tag = old[svc]
if prev_tag == tag and prev_repo == repo:
continue
# The `app` service is the recipe's primary image by coop-cloud convention; its window drives
# --from/--to so the scan's primary advisory source is judged against it. Everything else is
# a sidecar window keyed by its image name.
if svc == "app":
app = (repo.split("/")[-1], prev_tag, tag)
else:
others.append((repo.split("/")[-1], prev_tag, tag))
wins = ([app] if app else []) + others
return wins, f"baseline {from_url}"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("recipe")
ap.add_argument("--from", dest="v_from", default=None)
ap.add_argument("--to", dest="v_to", default=None)
ap.add_argument("--json", action="store_true", help="emit raw JSON instead of markdown")
ap.add_argument("--adjudicate", action="store_true",
help="SECOND PASS: for advisories the deterministic pass could not judge (no "
"fix version published), fetch their full text + references and append a "
"block for the agent to judge. Additive: it never changes the count above.")
ap.add_argument("--registry", default=REGISTRY_DIR)
ap.add_argument("--compose-to", default=None, metavar="URL",
help="derive the windows by DIFFING this compose against its baseline, instead "
"of passing --from/--to/--image by hand. Point it at a PR's compose.yml "
"(e.g. .../raw/branch/<pr-branch>/compose.yml).")
ap.add_argument("--compose-from", default=None, metavar="URL",
help="baseline compose for --compose-to. Default: the same repo's DEFAULT "
"branch, resolved from the API (never assumed to be `main`).")
ap.add_argument("--image", action="append", default=[], metavar="NAME=FROM:TO",
help="a sidecar image and the versions it moved between, e.g. "
"--image redis=7.4:8.10 (repeatable). NAME matches a source repo name; "
"its advisories are then counted against ITS OWN versions instead of "
"being left unclassified.")
a = ap.parse_args()
images = []
if a.compose_to:
wins, note = windows_from_compose(a.compose_to, a.compose_from)
if not wins:
print(f"### Advisory scan — {a.recipe}\n\n**No image versions changed between the two "
f"compose files, so this upgrade fixes no CVEs by definition.**\n\n_{note}_")
return 0
print(f"_derived from compose diff ({note}):_", file=sys.stderr)
for n_, f_, t_ in wins:
print(f"_ {n_}: {f_}{t_}_", file=sys.stderr)
# The `app` service (first entry when present) drives --from/--to; the rest are --image
# windows. Passing every window as --image too is harmless: each is matched by name against
# the advisory sources, and an unmatched name is simply ignored.
a.v_from, a.v_to = a.v_from or wins[0][1], a.v_to or wins[0][2]
images = list(wins[1:])
for spec in a.image:
name, _, rng = spec.partition('=')
vf, _, vt = rng.partition(':')
if name and vf and vt:
images.append((name, vf, vt))
else:
print(f'ignoring malformed --image {spec!r} (expected NAME=FROM:TO)', file=sys.stderr)
rep = scan(a.recipe, a.v_from, a.v_to, a.registry, images)
if a.adjudicate:
rep["adjudication"] = [evidence_bundle(rep, c)
for c in needs_judgement(rep)[:MAX_ADJUDICATE]]
if a.json:
print(json.dumps(rep, indent=2))
else:
print(markdown(rep))
if a.adjudicate:
print(adjudication_block(rep))
return 0
if __name__ == "__main__":
sys.exit(main())