diff --git a/cc-ci-plan/advisory-scan.SPEC.md b/cc-ci-plan/advisory-scan.SPEC.md index 4cb0fc7..d11fb6a 100644 --- a/cc-ci-plan/advisory-scan.SPEC.md +++ b/cc-ci-plan/advisory-scan.SPEC.md @@ -39,6 +39,8 @@ keeps landing in pass 2, the fix is a new deterministic method in pass 1. §4c i ``` advisory-scan.py [--from ] [--to ] [--image =:]... [--adjudicate] [--json] [--registry DIR] + +advisory-scan.py --compose-to [--compose-from ] # windows derived, not typed ``` | Input | Meaning | @@ -46,6 +48,8 @@ advisory-scan.py [--from ] [--to ] | `` | 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 | | `--image NAME=FROM:TO` | A **sidecar image and the versions it moved between** (repeatable, all in ONE call). `NAME` is substring-matched against source repo names. Malformed values warn on stderr and are skipped. Without it that image's advisories stay unclassified. | +| `--compose-to URL` | **Derive every window by diffing this compose against its baseline**, instead of typing `--from/--to/--image`. Point it at a PR's `compose.yml`. | +| `--compose-from URL` | Baseline for the above. Default: the same repo's **default branch, resolved from the API** — never assumed to be `main`. | | `--adjudicate` | Run pass 2: append the evidence dossier for judgement | | `--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**. | @@ -140,6 +144,33 @@ Two invariants govern this step, both learned from a wrong answer in production. > `null` / `UNKNOWN`, never `0`. A `0` in a security column asserts safety. Equally, an advisory that > cannot be judged is **indeterminate** (§4d) — never silently counted as "not fixed". +### 3b. Deriving the windows from a compose diff (`--compose-to`) + +Typing `--from/--to/--image` by hand means someone has to remember that the recipe also bumped its +redis. That is how sidecar CVEs went uncounted for months. This mode reads the windows off the diff: + +1. Fetch both compose files (baseline = the repo's **default branch from the API**, since several + recipes keep a stale `main` beside a live `master`). +2. Parse `{service: (image-repo, tag)}` — keyed by **service, not 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, losing the app window entirely). +3. Every service whose tag or repo changed becomes a window. The `app` service drives `--from/--to` + (coop-cloud convention: it is the recipe's primary image); the rest become `--image` windows. + Unchanged images produce no window — inventing one would be a false count. +4. The derived windows are printed to stderr before the scan, so the inputs are auditable. + +Image names are matched against advisory sources **both ways** — an image name is often longer than +its source repo (`clickhouse/clickhouse-server` vs `ClickHouse/ClickHouse`) and sometimes shorter +(`redis` vs `redis/redis`). + +Verified on plausible PR #5: from the compose URL alone it derives `v2.0.0 → v3.2.1` plus +`clickhouse-server 23.4.2.11-alpine → 24.12-alpine`, and reports **6** — identical to the +hand-specified args. + +`--from/--to/--image` remain available for finer-grained checks (scanning a window that is not a +literal compose diff, e.g. "what would the compatibility-safe target fix?"). + ### 4a. By patched version (preferred — exact) `patched_versions` is a **range expression** (`">= 2.18.1"`), possibly several joined by `;`. Extract diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 1a3b0e6..2fc0301 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -660,7 +660,14 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, windows[primary] = (v_from, v_to) for key, wf, wt in (images or []): for src in gh_sources: - if key.lower() in src.lower() and src not in windows: + 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) report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()} @@ -893,6 +900,113 @@ def markdown(rep: dict) -> str: 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") @@ -904,6 +1018,13 @@ def main() -> int: "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//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; " @@ -911,6 +1032,20 @@ def main() -> int: "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(':') diff --git a/cc-ci-plan/test-advisory-scan.py b/cc-ci-plan/test-advisory-scan.py index 80be3ea..777d376 100755 --- a/cc-ci-plan/test-advisory-scan.py +++ b/cc-ci-plan/test-advisory-scan.py @@ -537,6 +537,80 @@ class TestReleaseLineSemantics(unittest.TestCase): self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2025-49844"]) +class TestComposeDerivedWindows(unittest.TestCase): + """Windows read off a compose diff, so nobody has to remember which --image args an upgrade needs.""" + + OLD = """ +services: + app: + image: "plausible/analytics:v2.0.0" + db: + image: pgautoupgrade/pgautoupgrade:18-alpine + plausible_events_db: + image: clickhouse/clickhouse-server:23.4.2.11-alpine +volumes: + data: +""" + NEW = """ +services: + app: + image: "ghcr.io/plausible/community-edition:v3.2.1" + db: + image: pgautoupgrade/pgautoupgrade:18-alpine + plausible_events_db: + image: clickhouse/clickhouse-server:24.12-alpine +volumes: + data: +""" + + def _windows(self, old=None, new=None): + pages = {"to": new if new is not None else self.NEW, + "from": old if old is not None else self.OLD} + with unittest.mock.patch.object(A, "_fetch", lambda u, h=None: pages["to" if "to" in u else "from"]), \ + unittest.mock.patch.object(A, "_gitea_auth", lambda u: {}): + return A.windows_from_compose("http://x/to", "http://x/from")[0] + + def test_app_service_leads_and_sidecars_follow(self): + w = self._windows() + self.assertEqual(w[0], ("community-edition", "v2.0.0", "v3.2.1")) + self.assertIn(("clickhouse-server", "23.4.2.11-alpine", "24.12-alpine"), w) + + def test_unchanged_images_are_not_windows(self): + # pgautoupgrade is identical in both; inventing a window for it would be a false count. + self.assertNotIn("pgautoupgrade", [n for n, _, _ in self._windows()]) + + def test_a_changed_image_REPO_is_still_the_same_service(self): + # plausible/analytics -> ghcr.io/plausible/community-edition. Keyed by image repo this reads + # as one image vanishing and another appearing, and the app window is lost entirely. + w = self._windows() + self.assertTrue(any(n == "community-edition" and f == "v2.0.0" for n, f, _ in w)) + + def test_no_change_yields_no_windows(self): + self.assertEqual(self._windows(old=self.NEW, new=self.NEW), []) + + def test_templated_tags_are_skipped(self): + new = self.NEW.replace('ghcr.io/plausible/community-edition:v3.2.1', 'ghost:${IMAGE_VERSION}') + self.assertNotIn("ghost", [n for n, _, _ in self._windows(new=new)]) + + +class TestImageNameMatching(unittest.TestCase): + """An image name and its advisory source rarely spell each other exactly.""" + + def test_matches_when_the_image_name_is_LONGER_than_the_source(self): + # clickhouse/clickhouse-server vs source ClickHouse/ClickHouse — one-directional matching + # dropped this window silently when the key came from a compose file. + rep = run_scan([gh("ClickHouse/ClickHouse", [adv("CVE-1", patched="23.10.2.13")])], + images=[("clickhouse-server", "23.4.2.11", "24.12")], + urls=["https://github.com/ClickHouse/ClickHouse"]) + self.assertIn("github-advisories:ClickHouse/ClickHouse", rep["windows"]) + self.assertEqual(rep["cve_count_fixed"], 1) + + def test_matches_when_the_image_name_is_SHORTER_than_the_source(self): + rep = run_scan([gh("redis/redis", [adv("CVE-2", patched="7.4.1")])], + images=[("redis", "7.4", "8.10")], urls=["https://github.com/redis/redis"]) + self.assertEqual(rep["cve_count_fixed"], 1) + + class TestAdjudicationEvidenceAssembly(unittest.TestCase): """Pass 2's JUDGEMENT is a model's and not testable; what IS testable is what it gets shown."""