Improve recipe advisory coverage and report non-CVE fixes
This commit is contained in:
+152
-16
@@ -40,6 +40,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -166,6 +167,43 @@ def _vkey(v: str | None) -> tuple:
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _nvd_affects(version: str | None, ranges: list[dict]) -> bool | None:
|
||||
"""Whether NVD marks a concrete image version affected; None means tag lacks precision."""
|
||||
current = _vkey(version)
|
||||
if not current:
|
||||
return None
|
||||
uncertain = False
|
||||
for row in ranges:
|
||||
exact = _vkey(row.get("version")) if row.get("version") not in (None, "*") else ()
|
||||
bounds = [(name, _vkey(row.get(name))) for name in (
|
||||
"versionStartIncluding", "versionStartExcluding",
|
||||
"versionEndIncluding", "versionEndExcluding") if row.get(name)]
|
||||
versions = ([exact] if exact else []) + [value for _, value in bounds]
|
||||
if any(not value for value in versions):
|
||||
uncertain = True
|
||||
continue
|
||||
if any(len(value) > len(current) and value[:len(current)] == current for value in versions):
|
||||
uncertain = True # e.g. postgres:13-alpine does not say which 13.x patch is running
|
||||
continue
|
||||
def compare(other):
|
||||
length = max(len(current), len(other))
|
||||
return (current + (0,) * (length - len(current))) > (other + (0,) * (length - len(other)))
|
||||
if exact and current != exact:
|
||||
continue
|
||||
def outside(name, value):
|
||||
if name == "versionStartExcluding":
|
||||
return not compare(value)
|
||||
if name == "versionStartIncluding":
|
||||
return not compare(value) and current != value
|
||||
if name == "versionEndIncluding":
|
||||
return compare(value)
|
||||
return compare(value) or current == value # versionEndExcluding
|
||||
if any(outside(name, value) for name, value in bounds):
|
||||
continue
|
||||
return True
|
||||
return None if uncertain else False
|
||||
|
||||
|
||||
def _already_fixed_on_from_line(kf: tuple, cands: list[tuple]) -> bool:
|
||||
"""Was it ALREADY fixed on the line we are upgrading FROM?
|
||||
|
||||
@@ -233,6 +271,14 @@ def registry_urls(recipe: str, registry_dir: str) -> tuple[list[str], str | None
|
||||
u = u.rstrip("`'\"*.,;:>)")
|
||||
if "…" in u or "..." in u:
|
||||
continue # prose placeholder, not a fetchable security source
|
||||
host = urllib.parse.urlsplit(u).hostname
|
||||
if not host or host == "localhost" or "." not in host:
|
||||
continue # a container/loopback URL in operational notes, not a public advisory source
|
||||
try:
|
||||
if not ipaddress.ip_address(host).is_global:
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
if u and u not in urls:
|
||||
urls.append(u)
|
||||
return urls, path
|
||||
@@ -371,15 +417,57 @@ def vendor_pages(urls: list[str]) -> list[dict]:
|
||||
|
||||
NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
NVD_CPE_RE = re.compile(r"^\s*[-*]?\s*nvd-cpe:\s*(\S+)\s*=\s*(cpe:2\.3:[^\s`]+)", re.M | re.I)
|
||||
_NVD_CACHE: dict[str, dict] = {}
|
||||
COMMON_IMAGE_CPES = {
|
||||
"postgres": "cpe:2.3:a:postgresql:postgresql:*:*:*:*:*:*:*:*",
|
||||
"mariadb": "cpe:2.3:a:mariadb:mariadb:*:*:*:*:*:*:*:*",
|
||||
"mysql": "cpe:2.3:a:oracle:mysql:*:*:*:*:*:*:*:*",
|
||||
}
|
||||
|
||||
|
||||
def registry_cpes(recipe: str, registry_dir: str) -> list[tuple[str, str]]:
|
||||
"""[(image-key, cpe)] declared in the recipe's registry as `nvd-cpe: <key> = <cpe>`."""
|
||||
"""Explicit CPEs plus common database CPEs for images listed in the registry table."""
|
||||
path = os.path.join(registry_dir, f"{recipe}.md")
|
||||
try:
|
||||
return [(m.group(1), m.group(2)) for m in NVD_CPE_RE.finditer(open(path).read())]
|
||||
text = open(path).read()
|
||||
except OSError:
|
||||
return []
|
||||
out = [(m.group(1), m.group(2)) for m in NVD_CPE_RE.finditer(text)]
|
||||
explicit = {key for key, _ in out}
|
||||
for line in text.splitlines():
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip().strip("`") for cell in line.strip().strip("|").split("|")]
|
||||
if len(cells) < 2:
|
||||
continue
|
||||
image = cells[1].split("/")[-1].lower()
|
||||
if image in COMMON_IMAGE_CPES and image not in explicit:
|
||||
out.append((image, COMMON_IMAGE_CPES[image]))
|
||||
explicit.add(image)
|
||||
return out
|
||||
|
||||
|
||||
def registry_image_sources(recipe: str, registry_dir: str) -> dict[str, set[str]]:
|
||||
"""Map image repo basenames to GitHub advisory sources from the registry table."""
|
||||
path = os.path.join(registry_dir, f"{recipe}.md")
|
||||
try:
|
||||
lines = open(path).read().splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
out: dict[str, set[str]] = {}
|
||||
for line in lines:
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip().strip("`") for cell in line.strip().strip("|").split("|")]
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
image = cells[1].split("/")[-1].lower()
|
||||
match = re.search(r"https?://github\.com/([^/\s|]+)/([^/\s|)]+)", cells[2])
|
||||
if not match or image in ("image", "---", ""):
|
||||
continue
|
||||
source = f"github-advisories:{match.group(1)}/{match.group(2).removesuffix('.git')}"
|
||||
out.setdefault(image, set()).add(source)
|
||||
return out
|
||||
|
||||
|
||||
def nvd_advisories(cpe: str, key: str) -> dict:
|
||||
@@ -397,6 +485,8 @@ def nvd_advisories(cpe: str, key: str) -> dict:
|
||||
NVD LAGS the vendor (it had neither gitea CVSS-9.8 RCE at publication), so this is a fallback,
|
||||
never a replacement for 2a/2b. Unauthenticated calls are rate-limited to ~5/30s, hence the retry.
|
||||
"""
|
||||
if cpe in _NVD_CACHE:
|
||||
return {**_NVD_CACHE[cpe], "source": f"nvd:{key}"}
|
||||
entry = {"source": f"nvd:{key}", "status": "ok", "advisories": []}
|
||||
url = f"{NVD_API}?resultsPerPage=2000&virtualMatchString={urllib.parse.quote(cpe)}"
|
||||
data = None
|
||||
@@ -409,15 +499,31 @@ def nvd_advisories(cpe: str, key: str) -> dict:
|
||||
entry["status"] = f"error: {type(e).__name__}"
|
||||
return entry
|
||||
time.sleep(8)
|
||||
for v in (data or {}).get("vulnerabilities", []):
|
||||
vulnerabilities = (data or {}).get("vulnerabilities", [])
|
||||
if (data or {}).get("totalResults", len(vulnerabilities)) > len(vulnerabilities):
|
||||
entry["status"] = "error: NVD result set truncated"
|
||||
return entry
|
||||
for v in vulnerabilities:
|
||||
c = v.get("cve") or {}
|
||||
cid = c.get("id")
|
||||
if not cid:
|
||||
if not cid or c.get("vulnStatus") == "Rejected":
|
||||
continue
|
||||
fixed, affected_max = set(), set()
|
||||
fixed, affected_max, affected_ranges = set(), set(), []
|
||||
cpe_product = ":".join(cpe.split(":")[:5]) + ":"
|
||||
cpe_edition = cpe.split(":")[9] if len(cpe.split(":")) > 9 else "*"
|
||||
for cfg in c.get("configurations", []):
|
||||
for node in cfg.get("nodes", []):
|
||||
for m in node.get("cpeMatch", []):
|
||||
criteria = m.get("criteria") or ""
|
||||
if not m.get("vulnerable") or not criteria.startswith(cpe_product):
|
||||
continue
|
||||
edition = criteria.split(":")[9] if len(criteria.split(":")) > 9 else "*"
|
||||
if cpe_edition != "*" and edition not in (cpe_edition, "*"):
|
||||
continue
|
||||
affected_ranges.append({k: m.get(k) for k in (
|
||||
"versionStartIncluding", "versionStartExcluding",
|
||||
"versionEndIncluding", "versionEndExcluding") if m.get(k)})
|
||||
affected_ranges[-1]["version"] = criteria.split(":")[5]
|
||||
if m.get("versionEndExcluding"):
|
||||
fixed.add(m["versionEndExcluding"])
|
||||
elif m.get("versionEndIncluding"):
|
||||
@@ -435,9 +541,11 @@ def nvd_advisories(cpe: str, key: str) -> dict:
|
||||
"vulnerable_range": None,
|
||||
"patched": "; ".join(sorted(fixed)) or None,
|
||||
"affected_max": "; ".join(sorted(affected_max)) or None,
|
||||
"affected_ranges": affected_ranges or None,
|
||||
"url": f"https://nvd.nist.gov/vuln/detail/{cid}",
|
||||
"published_at": c.get("published"), "description": None, "cvss": None,
|
||||
})
|
||||
_NVD_CACHE[cpe] = entry
|
||||
return entry
|
||||
|
||||
|
||||
@@ -739,7 +847,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
"vulnerable_range": None, "patched": None,
|
||||
"context": None, "published_at": None,
|
||||
"description": None, "url": None, "cvss": None,
|
||||
"changelog_fixed_in": None, "affected_max": None})
|
||||
"changelog_fixed_in": None, "affected_max": None,
|
||||
"affected_ranges": None})
|
||||
if src not in e["sources"]:
|
||||
e["sources"].append(src)
|
||||
for k, v in extra.items():
|
||||
@@ -750,8 +859,9 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
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"),
|
||||
advisory_id = a.get("cve") or a.get("ghsa")
|
||||
if advisory_id:
|
||||
record(advisory_id, 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"))
|
||||
@@ -763,6 +873,7 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
for a in entry.get("advisories", []):
|
||||
record(a["cve"], entry["source"], severity=a.get("severity"),
|
||||
patched=a.get("patched"), affected_max=a.get("affected_max"),
|
||||
affected_ranges=a.get("affected_ranges"),
|
||||
context=a.get("summary"), published_at=a.get("published_at"),
|
||||
url=a.get("url"))
|
||||
|
||||
@@ -827,17 +938,20 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
if pname in k or k in pname:
|
||||
windows[src] = (v_from, v_to)
|
||||
window_key[src] = k
|
||||
image_sources = registry_image_sources(recipe, registry_dir)
|
||||
for key, wf, wt in (images or []):
|
||||
matched = False
|
||||
explicit = image_sources.get(key.lower(), set())
|
||||
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:
|
||||
if src in explicit or k in src.lower() or name in k:
|
||||
if src in windows:
|
||||
matched |= windows[src] == (wf, wt)
|
||||
continue # two images in one monorepo share a source and version window
|
||||
windows[src] = (wf, wt)
|
||||
window_key[src] = key
|
||||
matched = True
|
||||
@@ -850,6 +964,21 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
|
||||
`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."""
|
||||
if src.startswith("nvd:"):
|
||||
got, undecidable = set(), set()
|
||||
for advisory, evidence in report["cves"].items():
|
||||
if src not in evidence["sources"]:
|
||||
continue
|
||||
if not evidence.get("affected_ranges"):
|
||||
undecidable.add(advisory)
|
||||
continue
|
||||
old = _nvd_affects(wf, evidence["affected_ranges"])
|
||||
new = _nvd_affects(wt, evidence["affected_ranges"])
|
||||
if old is True and new is False:
|
||||
got.add(advisory)
|
||||
elif old is None or (old is True and new is None):
|
||||
undecidable.add(advisory)
|
||||
return got, "NVD affected-version ranges", None, False, undecidable
|
||||
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.
|
||||
@@ -1018,6 +1147,7 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
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["fixed_non_cve_advisories"] = sorted(x for x in fixed_set if not x.startswith("CVE-"))
|
||||
report["indeterminate"] = sorted(indeterminate)
|
||||
report["cve_count_indeterminate"] = len(indeterminate)
|
||||
report["unclassified"] = sorted(unknown)
|
||||
@@ -1037,9 +1167,9 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
]
|
||||
coverage_failed = bool(report.get("unmatched_image_windows")) or bool(v_from and not primary)
|
||||
report["count_known"] = not unresolved_any and not no_sources and not coverage_failed and not report["sources_failed"]
|
||||
report["cve_count_fixed"] = (len(fixed_set)
|
||||
report["cve_count_fixed"] = (sum(x.startswith("CVE-") for x in fixed_set)
|
||||
if report["count_known"] else None)
|
||||
report["cve_count_total_seen"] = len(report["cves"])
|
||||
report["cve_count_total_seen"] = sum(x.startswith("CVE-") for x in report["cves"])
|
||||
# Only GENUINE failures make a count unreliable. "no-advisories-published" (404: the repo has
|
||||
# no advisory feed) and "skipped: template URL" are benign and must not degrade the verdict.
|
||||
report["sources_benign"] = [
|
||||
@@ -1057,7 +1187,7 @@ def markdown(rep: dict) -> str:
|
||||
if not rep.get("count_known", True):
|
||||
L.append("\n**CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count.**")
|
||||
if rep.get("fixed_by_this_upgrade"):
|
||||
L.append(f"\nAt least {len(rep['fixed_by_this_upgrade'])} fixed CVE(s) were identified: "
|
||||
L.append(f"\nAt least {len(rep['fixed_by_this_upgrade'])} fixed advisory/advisories were identified: "
|
||||
+ ", ".join(rep["fixed_by_this_upgrade"][:15]))
|
||||
if rep.get("no_usable_sources"):
|
||||
L.append("\n⚠ This is NOT zero. **No usable source was checked at all** — the registry "
|
||||
@@ -1080,7 +1210,8 @@ def markdown(rep: dict) -> str:
|
||||
f"reading in the upgrade step._")
|
||||
return "\n".join(L)
|
||||
ind = rep.get("cve_count_indeterminate") or 0
|
||||
if rep["fixed_by_this_upgrade"]:
|
||||
fixed_cves = [x for x in rep["fixed_by_this_upgrade"] if x.startswith("CVE-")]
|
||||
if fixed_cves:
|
||||
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 {}
|
||||
@@ -1094,12 +1225,17 @@ def markdown(rep: dict) -> str:
|
||||
L.append("")
|
||||
L.append("| CVE | severity | fixed in | advisory | source |")
|
||||
L.append("|---|---|---|---|---|")
|
||||
for cve in rep["fixed_by_this_upgrade"]:
|
||||
for cve in fixed_cves:
|
||||
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 rep.get("fixed_non_cve_advisories"):
|
||||
L.append("\n**Security advisories fixed without CVE IDs: "
|
||||
+ str(len(rep["fixed_non_cve_advisories"])) + "** — "
|
||||
+ ", ".join(rep["fixed_non_cve_advisories"][:15])
|
||||
+ ". In the report CVEs column, use YES when the CVE count is zero.")
|
||||
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 "
|
||||
|
||||
Reference in New Issue
Block a user