advisory-scan: fix version classification (multi-line patches + range expressions)

Exposed by asking whether the scan catches the n8n CVEs (CVE-2026-42231/42232). It
did not — the advisories were fetched correctly but both misclassified as
out-of-window. Two bugs:

1. Only vulnerabilities[0] was read. An advisory carries ONE ENTRY PER PATCHED
   RELEASE LINE: n8n patches three (1.123.32, 2.17.4, 2.18.1), so whichever line
   the deployment is actually on was silently dropped. gitea passed only because it
   patches a single line. Now all entries are kept.
2. patched_versions is a RANGE EXPRESSION ('>= 2.18.1'), not a bare version. Naive
   parsing produced (18,1) instead of (2,18,1), so no comparison could ever match.
   Version tokens are now extracted with a regex and the advisory counts as
   fixed-by-this-upgrade if ANY patched line falls in (from, to].

Verified: n8n 2.17.0 -> 2.18.1 now reports 12 CVEs including both criticals
(CVE-2026-42231 GHSA-q5f4-99jv-pgg5, CVE-2026-42232); gitea 1.27.0 -> 1.27.1 still
reports exactly 2. Note our deployed n8n (2.27.2+) is already past all three patched
lines, so these were never outstanding for us — the bug was in detection, not
exposure.
This commit is contained in:
autonomic-bot
2026-08-10 18:50:07 +00:00
parent 3307bdb0fe
commit 3e59924450
+16 -4
View File
@@ -138,7 +138,11 @@ def github_advisories(urls: list[str]) -> list[dict]:
hdrs["Authorization"] = f"Bearer {tok}"
entry = {"source": f"github-advisories:{owner}/{repo}", "status": "ok", "advisories": []}
try:
for a in json.loads(_fetch(api, hdrs)): # noqa: PERF401 — explicit for readability
for a in json.loads(_fetch(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(
{
@@ -146,8 +150,12 @@ def github_advisories(urls: list[str]) -> list[dict]:
"ghsa": a.get("ghsa_id"),
"severity": a.get("severity"),
"summary": (a.get("summary") or "")[:200],
"vulnerable_range": vulns[0].get("vulnerable_version_range") if vulns else None,
"patched": vulns[0].get("patched_versions") if vulns else None,
"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"),
}
)
@@ -266,7 +274,11 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str) -
kf, kt = _vkey(v_from), _vkey(v_to)
fixed, unknown = [], []
for cve, e in report["cves"].items():
kp = _vkey((e.get("patched") or "").split(",")[0].strip() or None)
# `patched_versions` is a RANGE EXPRESSION (">= 2.18.1"), not a bare version, and there may
# be several (one per patched release line, joined with ";"). Pull every version-looking
# token and treat the advisory as fixed-by-this-upgrade if ANY of them lands in (from, to].
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", e.get("patched") or "")]
kp = next((c for c in cands if kf and kt and kf < c <= kt), None) or (cands[0] if cands else ())
if kf and kt and kp and kf < kp <= kt:
e["classification"] = "fixed-by-this-upgrade"
fixed.append(cve)