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.
1134 lines
58 KiB
Python
Executable File
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())
|