From b5f8543a9b8f6a29378ec244f7118f82fa7f384d Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Mon, 10 Aug 2026 22:01:28 +0000 Subject: [PATCH] advisory-scan: count sidecar CVEs via per-image windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recipe upgrades several images, each through its own version range. The scan previously classified only the app repo, so sidecar bumps contributed nothing — the alternative to the earlier bug where sidecars were judged by the APP's window and produced a false 133. Now: --window KEY=FROM:TO (repeatable) gives any other source its own range; each window is classified independently (one may use patched-version ranges while another falls back to advisory dates) and the count is the UNION. An image with no window is still not counted — the scan will not guess a range it was not given. If ANY requested window cannot be ordered, the total is UNKNOWN rather than a partial number. /recipe-upgrade now instructs passing a --window per bumped sidecar. Verified on discourse app 3.5.3->2026.7.1 + redis 7.4->8.10: 128 = 123 (app, by publish date) + 5 (redis, by version range). The redis five are genuine for that bump (patched 7.4.1 / 7.4.6 / 8.2.3) and include CVE-2025-49844, CRITICAL — previously invisible. Regressions clean: gitea still 2, discourse without the sidecar window still 123. --- .claude/skills/recipe-upgrade/SKILL.md | 9 +- cc-ci-plan/advisory-scan.SPEC.md | 25 ++-- cc-ci-plan/advisory-scan.py | 180 +++++++++++++++---------- 3 files changed, 133 insertions(+), 81 deletions(-) diff --git a/.claude/skills/recipe-upgrade/SKILL.md b/.claude/skills/recipe-upgrade/SKILL.md index 4cd658b..5399985 100644 --- a/.claude/skills/recipe-upgrade/SKILL.md +++ b/.claude/skills/recipe-upgrade/SKILL.md @@ -163,9 +163,16 @@ Run the deterministic scanner for the exact upgrade window and **paste its markd into the per-recipe log**: ``` -python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py --from --to +python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py --from --to \ + [--window =: ...] ``` +**Pass a `--window` for EVERY sidecar you bumped** (redis, postgres, nginx …), not just the app — +each image is judged by its own version range, and an image without a window is not counted at all. +e.g. discourse bumping app 3.5.3→2026.7.1 *and* redis 7.4→8.10: +`--from 3.5.3 --to 2026.7.1 --window redis=7.4:8.10` → 128 CVEs (123 app + 5 redis), where the redis +five include a **critical** (CVE-2025-49844) that is invisible if the sidecar is left out. + It queries, per recipe: the **GitHub Security Advisories API** for every source repo in `cc-ci-plan/upstream/.md` (CVE + GHSA + severity + vulnerable/patched ranges, so "fixed by THIS upgrade" is computed, not guessed), every **vendor release/security URL** in that diff --git a/cc-ci-plan/advisory-scan.SPEC.md b/cc-ci-plan/advisory-scan.SPEC.md index 7b665d9..ff91e6b 100644 --- a/cc-ci-plan/advisory-scan.SPEC.md +++ b/cc-ci-plan/advisory-scan.SPEC.md @@ -26,6 +26,7 @@ advisory-scan.py [--from ] [--to ] [--json] [--regist |---|---| | `` | Recipe name; selects `cc-ci-plan/upstream/.md` (the per-recipe URL registry) | | `--from` / `--to` | The **primary app image's** version window being upgraded across | +| `--window KEY=FROM:TO` | A **sidecar's own** window (repeatable). `KEY` matches a source repo name, e.g. `--window redis=7.4:8.10`. Without it that image's advisories stay unclassified. | | `--registry` | Registry dir; also `CCCI_UPSTREAM_REGISTRY` | | `GITHUB_TOKEN` / `GITHUB_TOKEN_FILE` | Read-only token; **rate limit only** (60/hr anonymous → 5000/hr). Default file `/srv/cc-ci/.github-token`, mode 600. Public advisories need **no scopes**. | @@ -100,12 +101,17 @@ published_at, context}`. A CVE seen by several sources keeps them all. Two invariants govern this step, both learned from a wrong answer in production. -> **A. The window belongs to ONE image.** Only advisories from the **primary** source (the first -> `github-advisories:` source, i.e. the app repo the registry lists first) are classified. Sidecar -> advisories are recorded as unclassified so they stay visible without inflating the count. +> **A. Every image is judged by its OWN window.** The app repo uses `--from/--to`; each sidecar uses +> its own `--window KEY=FROM:TO`. An image with no window is **not** classified — its advisories are +> listed as unclassified so they stay visible without inflating the count. The reported count is the +> **union across windows**, and each window is classified independently (so one may use version +> ranges while another falls back to dates). > *Why:* discourse once reported **133**, of which **34 were redis CVEs** — including > `CVE-2021-21309`, patched in redis 6.0.11 in 2021 — counted purely because 6.0.11 sits numerically -> inside discourse's `3.5.3 → 2026.7.1` range. +> inside discourse's `3.5.3 → 2026.7.1` range. The fix is not to ignore sidecars but to give each one +> the window it actually moved through: with `--window redis=7.4:8.10`, discourse scores +> **128 = 123 (app, by date) + 5 (redis, by version range)** — and the redis five include +> `CVE-2025-49844`, **critical**, which was invisible while sidecars went uncounted. > > **B. Never emit a number you cannot justify.** If neither method below can order the window, the > count is `null` / `UNKNOWN`, never `0`. A `0` in a security column asserts safety. @@ -147,10 +153,11 @@ Markdown (default) for pasting into the per-recipe upgrade log, or `--json`. | Field | Meaning | |---|---| -| `cve_count_fixed` | Count, or **`null`** when undeterminable | +| `cve_count_fixed` | Union across all windows, or **`null`** if ANY requested window could not be ordered (a partial number would understate) | | `count_known` | Distinguishes "counted zero" from "could not count" | -| `classified_by` | `patched version ranges` or `advisory publish date (version scheme changed)` | -| `date_window` | The resolved date window, when 4b was used | +| `windows` | Every source classified, with its from/to | +| `classified_by` | **Per source**: `patched version ranges` or `advisory publish date (version scheme changed)` | +| `date_window` | **Per source**, when 4b was used | | `fixed_by_this_upgrade[]` | CVE ids, with severity / GHSA / fixed-in per id | | `unclassified[]` | Seen but not attributable to this window (incl. other images) | | `sources[]` | Every source with its own status | @@ -175,8 +182,8 @@ in this tool or stale registry data. ## Known limits -1. **One window per scan.** Sidecar bumps (redis, postgres) are not counted; they appear as - unclassified. Per-image windows would be the natural extension. +1. **Windows must be supplied.** An image with no `--window` is not counted — the scan will not + guess a version range it was not told. `/recipe-upgrade` passes one per image it bumped. 2. **Date-based counts are temporal**, not exact — they assume publish-at-fix-time. 3. **Registry-bound.** Unlisted vendor security pages are invisible; the scan cannot know what it was never pointed at. diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 019f9a0..0a519c7 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -51,6 +51,8 @@ REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", "/srv/cc-ci/cc-ci-plan/u 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 # Optional OSV mappings: recipe -> (ecosystem, package). Supplementary only (see module docstring). OSV_PACKAGES: dict[str, tuple[str, str]] = { @@ -281,7 +283,8 @@ def osv(recipe: str, version: str | None) -> dict | None: return entry -def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str) -> dict: +def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, + extra_windows: list[tuple[str, str, str]] | None = None) -> dict: urls, reg_path = registry_urls(recipe, registry_dir) report: dict = { "recipe": recipe, @@ -343,74 +346,92 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str) - # 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. - kf, kt = _vkey(v_from), _vkey(v_to) - SCHEME_JUMP = 100 - scheme_change = bool(kf and kt and abs(kt[0] - kf[0]) >= SCHEME_JUMP) - primary = None - if v_from or v_to: - # the app repo = first github source in the registry (registry lists the app service first) - primary = next((s["source"] for s in report["sources"] - if s["source"].startswith("github-advisories:")), None) + # ── 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) + # --window K=F:T → any other source whose name contains K (repeatable), e.g. 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 - report["scheme_change"] = scheme_change - fixed, unknown = [], [] - for cve, e in report["cves"].items(): - # `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 ()) - from_primary = primary is not None and primary in e["sources"] - if scheme_change: - e["classification"] = "unclassified: version-scheme change, cannot order reliably" - unknown.append(cve) - elif not from_primary: - e["classification"] = "unclassified: different image than the given version window" - unknown.append(cve) - elif kf and kt and kp and kf < kp <= kt: - e["classification"] = "fixed-by-this-upgrade" - fixed.append(cve) - else: - e["classification"] = "unclassified" if not (kf and kt and kp) else "outside-window" - if e["classification"] == "unclassified": - unknown.append(cve) - # NEVER report 0 for something we could not determine. When classification was refused, the - # count is UNKNOWN (null) — a 0 would be read as "no CVEs", which is an assertion this scan - # cannot make. Consumers must distinguish "counted 0" from "could not count". - # DATE-BASED FALLBACK (phase datewin). Version strings cannot be ordered across a scheme change, - # but RELEASE DATES always can. Resolve both versions to their tag dates on the primary repo and - # count advisories PUBLISHED inside that window — the method a hand count used on 2026-08-10 to - # establish discourse 3.5.3 (2025-12-30) → 2026.7.1 (2026-07-31) = 123 CVEs, where version - # comparison had produced first a false 133 and then a refusal. Only used when the version path - # refuses; a successful version classification is always preferred (it is exact, not temporal). - date_window = None - if scheme_change and primary and primary.startswith("github-advisories:"): - owner_repo = primary.split(":", 1)[1] - owner, _, repo = owner_repo.partition("/") - d_from, d_to = _tag_date(owner, repo, v_from), _tag_date(owner, repo, v_to) - if d_from and d_to and d_from < d_to: - date_window = (d_from, d_to) - fixed, unknown = [], [] - for cve, e in report["cves"].items(): - pub = e.get("published_at") - if primary in e["sources"] and pub and d_from < pub <= d_to: - e["classification"] = "fixed-by-this-upgrade (by advisory publish date)" - fixed.append(cve) - else: - e["classification"] = ("outside-window (by date)" if primary in e["sources"] - else "unclassified: different image than the given window") - if primary not in e["sources"]: - unknown.append(cve) - scheme_change = False # resolved by date; a real count is available - report["classified_by"] = "advisory publish date (version scheme changed)" - report["date_window"] = {"from": d_from, "to": d_to} - if not date_window and not scheme_change: - report["classified_by"] = "patched version ranges" - report["fixed_by_this_upgrade"] = sorted(fixed) + windows = {} # source name -> (from, to) + if primary: + windows[primary] = (v_from, v_to) + for key, wf, wt in (extra_windows or []): + for src in gh_sources: + if key.lower() in src.lower() and src not in windows: + windows[src] = (wf, wt) + report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()} + + def _classify_window(src, wf, wt): + """Return (set_of_fixed_cves, method, date_window|None, unresolved:boolean) for one source.""" + 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 = set() + for cve, e in report["cves"].items(): + if src not in e["sources"]: + continue + cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", e.get("patched") or "")] + if kf and kt and any(kf < c <= kt for c in cands): + got.add(cve) + return got, "patched version ranges", None, False + # 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} + return got, "advisory publish date (version scheme changed)", (d_from, d_to), False + return set(), "unresolved", None, True + + fixed_set, methods, date_windows, unresolved_any = set(), {}, {}, False + for src, (wf, wt) in windows.items(): + got, method, dw, unresolved = _classify_window(src, wf, wt) + 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) + + 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 version window 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 + report["fixed_by_this_upgrade"] = sorted(fixed_set) report["unclassified"] = sorted(unknown) - report["count_known"] = not scheme_change - report["cve_count_fixed"] = len(fixed) if not scheme_change else None + # 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. + report["count_known"] = not unresolved_any + report["cve_count_fixed"] = len(fixed_set) if not unresolved_any 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. @@ -448,10 +469,15 @@ def markdown(rep: dict) -> str: return "\n".join(L) if rep["fixed_by_this_upgrade"]: L.append(f"\n**CVEs fixed by this upgrade: {rep['cve_count_fixed']}**\n") - if rep.get("date_window"): - L.append(f"_Counted by advisory PUBLISH DATE ({rep['date_window']['from'][:10]} → " - f"{rep['date_window']['to'][:10]}) because the version scheme changed across this " - f"jump; version strings cannot be ordered across it._\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"]: @@ -479,8 +505,20 @@ def main() -> int: 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("--registry", default=REGISTRY_DIR) + ap.add_argument("--window", action="append", default=[], metavar="KEY=FROM:TO", + help="extra image window, e.g. --window redis=7.4:8.10 (repeatable). " + "KEY matches a source repo name; its advisories are then counted " + "against ITS OWN bump instead of being left unclassified.") a = ap.parse_args() - rep = scan(a.recipe, a.v_from, a.v_to, a.registry) + wins = [] + for w in a.window: + key, _, rng = w.partition('=') + wf, _, wt = rng.partition(':') + if key and wf and wt: + wins.append((key, wf, wt)) + else: + print(f'ignoring malformed --window {w!r} (expected KEY=FROM:TO)', file=sys.stderr) + rep = scan(a.recipe, a.v_from, a.v_to, a.registry, wins) print(json.dumps(rep, indent=2) if a.json else markdown(rep)) return 0