Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ace30b4d | ||
|
|
dab3edf3c2 | ||
|
|
db37f1618b | ||
|
|
4bad1ea6db | ||
|
|
ef58e33102 | ||
|
|
c352ea9058 | ||
|
|
fae2fbe21b | ||
|
|
6ebc35bc18 |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: cc-ci-cleanup
|
||||
description: Tidy the fleet's open recipe PRs. Reconciles every mirror from TRUE upstream first (which alone closes PRs upstream already merged), then surveys every open PR deterministically, CLOSES the ones that can no longer be merged or were never meant to be (CI sweep artifacts, obsolete bumps, superseded duplicates) with a reason, and reports prioritised action items for the ones that SHOULD merge — what specifically is blocking each. NEVER merges a recipe PR. Invoke as /cc-ci-cleanup [recipe ...] [--dry-run].
|
||||
---
|
||||
|
||||
# cc-ci-cleanup
|
||||
|
||||
Open recipe PRs accumulate and rot. Some were never meant to merge (CI sweep artifacts), some were
|
||||
overtaken (upstream merged the same change, or a newer PR supersedes them), and some genuinely should
|
||||
land but are quietly blocked. Left alone the list becomes noise, and a real CVE fix hides in it.
|
||||
|
||||
This skill separates those three, acts on the first two, and hands you a short list for the third.
|
||||
|
||||
**Boundaries.** It **CLOSES** irrelevant PRs and **NEVER MERGES** any recipe PR — those change what
|
||||
deploys on other people's infrastructure, so a human merges them (see AGENTS.md). Closing is the only
|
||||
write it performs, always with a comment saying why.
|
||||
|
||||
## Arguments
|
||||
- `<recipe> …` — limit to these recipes (else every recipe in `cc-ci-plan/used-recipes.md`).
|
||||
- `--dry-run` — classify and report, close nothing.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Reconcile every mirror from TRUE upstream — MANDATORY, FIRST
|
||||
```
|
||||
cc-ci-plan/reconcile-upstream.sh --all # or: reconcile-upstream.sh <recipe>...
|
||||
```
|
||||
**Do not skip this and do not reorder it.** Every signal in step 2 is measured against the mirror's
|
||||
`main`; against a stale mirror they are all wrong. This step also does a chunk of the cleanup by
|
||||
itself — it closes any PR whose changes upstream has already merged.
|
||||
|
||||
> On the first real run (2026-08-11) this alone closed **three** PRs that looked pending and were
|
||||
> already merged upstream: discourse #6 (carrying **140 CVEs**), keycloak #6 (**12 CVEs**), n8n #5.
|
||||
> All three had been reported to the operator as outstanding work. mailu #6 went the same way earlier
|
||||
> the same day. Reconciling is not hygiene, it is how you avoid recommending work that is already done.
|
||||
|
||||
### 2. Survey every open PR (deterministic — no judgement yet)
|
||||
```
|
||||
python3 cc-ci-plan/pr-survey.py [recipe ...] # add --json for the raw facts
|
||||
```
|
||||
Per PR it measures: `behind_main`, `ahead`, `mergeable`, `diff_files`, the images it **adds**, which
|
||||
of those are **already in main**, `obsolete`, the newest `!testme` verdict + build, `branch_kind`,
|
||||
and age/idle days. It decides nothing — that is this skill's job.
|
||||
|
||||
### 3. Classify
|
||||
|
||||
**CLOSE — cannot merge, or was never meant to.** Each needs a *positive* reason, not an absence:
|
||||
|
||||
| signal | why it is closeable |
|
||||
|---|---|
|
||||
| `branch_kind: ci-artifact` (`ci/*`) | regall/cfold sweeps and `!testme` probes — harness artifacts, never intended to merge |
|
||||
| `obsolete: true` | every image it adds is **already pinned in main** — it has nothing left to contribute |
|
||||
| superseded | a newer PR on the same recipe makes the same bump (name both numbers in the comment) |
|
||||
| `diff_files: 0` | genuinely empty diff — nothing to merge |
|
||||
|
||||
**NEVER close on:**
|
||||
- `DIFF-UNREADABLE` — the diff could not be fetched, which is NOT an empty diff. gitea #4 reads that
|
||||
way (force-pushed branch) while being a verified, green, needed fix.
|
||||
- any field that came back `null`/unknown.
|
||||
- a PR that carries a **CVE fix** and is the only thing carrying it, even if it looks stale — report it
|
||||
instead. Losing a security fix to tidiness is far worse than a long PR list.
|
||||
- `--dry-run`.
|
||||
|
||||
**NEEDS WORK — should merge, something blocks it.** Give the *specific* next action:
|
||||
| signal | action item |
|
||||
|---|---|
|
||||
| `mergeable: false` | conflicts — rebase the branch on `main` and re-run `!testme` |
|
||||
| `behind_main > 0` | out of date — rebase, then re-verify (a green from before main moved proves nothing) |
|
||||
| `ci: failed` | diagnose via `/ci-test-review`; classify recipe-bug vs stale test |
|
||||
| `ci: never-run` | run `!testme` |
|
||||
| blocked on the operator | say exactly what is needed (a secret, an upstream release, a decision) |
|
||||
|
||||
**READY — green, current, no conflicts.** Action item is simply: review and merge.
|
||||
|
||||
### 4. Close the CLOSE set (skip entirely under `--dry-run`)
|
||||
Comment first, then close. The comment must say **which signal** made it closeable and **what to do
|
||||
if that is wrong** ("reopen if …"), so a wrong call is cheap to undo. Never close silently.
|
||||
|
||||
### 5. Report
|
||||
Order by what deserves attention, not by recipe name:
|
||||
|
||||
1. **CVE-carrying PRs that should merge** — most severe first, with the CVE ids.
|
||||
2. Other **READY** PRs (green + current).
|
||||
3. **NEEDS WORK**, each with its one specific action.
|
||||
4. **CLOSED this run**, with the reason for each.
|
||||
5. Anything **deliberately left alone** despite looking stale, and why.
|
||||
|
||||
End with a one-line summary: `N open → C closed, R ready to merge, W need work`.
|
||||
|
||||
## Guardrails
|
||||
- **Never merge a recipe PR.** Create/verify/close only; the operator merges.
|
||||
- **Reconcile first, always.** Judging a PR against a stale mirror is how you close good work or
|
||||
recommend work that is already done.
|
||||
- **Close only on a positive signal**, never on "looks old". Age alone is not a reason — several
|
||||
60-day-old PRs here are green and mergeable.
|
||||
- **Never close a lone CVE fix.** Report it, however stale.
|
||||
- Every close gets a comment with its reason and a reopen hint.
|
||||
@@ -104,6 +104,18 @@ CRITICAL came from, and an image with no window is not counted at all.
|
||||
distinction was the difference between two false zeros and the truth (both recipes turned out fine,
|
||||
but nothing in the survey said so).
|
||||
|
||||
### 2c. Know which recipes CANNOT see CVEs at all
|
||||
```
|
||||
python3 cc-ci-plan/audit-sources.py --security-sources
|
||||
```
|
||||
A recipe whose sources yield **no CVE data at all** cannot produce a meaningful `0` — nothing was
|
||||
measured, the same way a missing registry file cannot. As of 2026-08-11 that is **mattermost-lts**
|
||||
(its GitHub advisory feed is empty and its security bulletins are client-side rendered) and
|
||||
**mumble**. Render those as **`?`**, not `0`, and say why in the notes.
|
||||
|
||||
An *unparseable page* is NOT the same thing: it is harmless when the same project also publishes an
|
||||
advisory feed (redis, gitea, minio, clickhouse all do). Only "no usable source for this image" counts.
|
||||
|
||||
### 3. Run the advisory scan over that window
|
||||
```
|
||||
python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py <recipe> --from <old-app> --to <new-app> \
|
||||
|
||||
@@ -31,6 +31,12 @@ Then present the roster grouped as follows, and close with the situation guide.
|
||||
PR). `--with-tests` also fixes that recipe's stale test.
|
||||
- **/recipe-report** — (re)generate the weekly report page for report.ci.commoninternet.net.
|
||||
|
||||
**Keeping the PR list honest**
|
||||
- **/cc-ci-cleanup** — reconciles every mirror from true upstream (which alone closes PRs upstream
|
||||
already merged), then closes the open recipe PRs that can no longer merge or were never meant to
|
||||
(CI sweep artifacts, obsolete bumps, superseded duplicates) and reports what is actually blocking
|
||||
the ones that should land. Never merges.
|
||||
|
||||
**Security (CVEs)**
|
||||
- **/cve-check** — fleet-wide CVE sweep with **no upgrading**: for every recipe, work out what
|
||||
upgrade is available (per image, sidecars included), scan it for CVEs, and publish a CVE report.
|
||||
@@ -81,6 +87,7 @@ ARM skills never touch cc-ci infra. After a submodule bump run `scripts/gen-ccte
|
||||
| "Run the weekly upgrades now" | `/upgrade-all` (or `systemctl start cc-ci-upgrade-all.service`) |
|
||||
| "Upgrade just <recipe>" | `/recipe-upgrade <recipe>` |
|
||||
| "The report site is stale/missing a week" | `/recipe-report` |
|
||||
| "The open PR list is a mess / what should I merge?" | `/cc-ci-cleanup` |
|
||||
| "What CVEs are we exposed to right now?" | `/cve-check` (read-only, no PRs) |
|
||||
| "A CVE just dropped — check and patch it" | `/cve-check-and-upgrade` (add `--min-severity high` to skip the noise) |
|
||||
| "Is <recipe> vulnerable?" | `/cve-check <recipe>` |
|
||||
|
||||
@@ -39,6 +39,8 @@ keeps landing in pass 2, the fix is a new deterministic method in pass 1. §4c i
|
||||
```
|
||||
advisory-scan.py <recipe> [--from <version>] [--to <version>]
|
||||
[--image <name>=<from>:<to>]... [--adjudicate] [--json] [--registry DIR]
|
||||
|
||||
advisory-scan.py <recipe> --compose-to <URL> [--compose-from <URL>] # windows derived, not typed
|
||||
```
|
||||
|
||||
| Input | Meaning |
|
||||
@@ -46,6 +48,8 @@ advisory-scan.py <recipe> [--from <version>] [--to <version>]
|
||||
| `<recipe>` | Recipe name; selects `cc-ci-plan/upstream/<recipe>.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**. |
|
||||
@@ -103,8 +107,21 @@ URLs containing `<`, `>`, `{`, `}`, `VERSION`, or `vX.Y.Z` are **skipped as temp
|
||||
human documentation (`…/changelog/v<VERSION>/`), not fetchable, and counting them as failures is wrong.
|
||||
|
||||
This is the source that would have caught gitea: the vendor blog names both CVEs, the GitHub release
|
||||
page names neither. A CVE found **only** here carries no version data, so pass 1 cannot place it — it
|
||||
goes to pass 2 (§6).
|
||||
page names neither.
|
||||
|
||||
**When the page is a changelog organised by release, each CVE is attributed to the release heading it
|
||||
appears under** (`Changes with nginx 1.31.3`, `## v1.31.3`, …) and that becomes its fixed-in version.
|
||||
Without this, a project that publishes no advisory feed can never contribute a CVE:
|
||||
|
||||
> **nginx publishes NO GitHub security advisories.** Every nginx CVE we can see comes from
|
||||
> `nginx.org/en/CHANGES`. Scraping ids out of it without attributing them to a release left them with
|
||||
> no patched version, so they were never classifiable — and every nginx bump in the fleet reported
|
||||
> **0** forever. nginx is a sidecar in most recipes. Measured: `1.31.1 → 1.31.3` fixes **six** CVEs
|
||||
> (three in .2, three in .3); lasuite-docs#7 went 0 → 6 and lasuite-drive#6 went 0 → 3 on this alone.
|
||||
|
||||
A changelog CVE is tied to a window by the **image name appearing in the page URL** (window `nginx` ↔
|
||||
`nginx.org/...`). A CVE found on a vendor page with no attributable release still has no version data,
|
||||
so pass 1 cannot place it — it goes to pass 2 (§6).
|
||||
|
||||
### 2c. OSV.dev — supplementary
|
||||
|
||||
@@ -140,6 +157,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
|
||||
|
||||
+217
-5
@@ -272,6 +272,51 @@ def github_advisories(urls: list[str]) -> list[dict]:
|
||||
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
|
||||
|
||||
|
||||
_BLOB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/blob/(.+)$")
|
||||
|
||||
|
||||
def _raw_if_blob(url: str) -> str:
|
||||
"""A GitHub *blob* URL is an HTML viewer, not the file.
|
||||
|
||||
The registry pointed ONLYOFFICE's CHANGELOG.md at its blob page. Fetching that returns 636KB of
|
||||
markup in which the release headings do not survive HTML-stripping, so 24 CVEs were visible and
|
||||
NONE attributable to a release — the same shape of blind spot as nginx. The raw URL attributes
|
||||
all 24. Normalising here fixes every registry entry at once, present and future."""
|
||||
m = _BLOB_RE.match(url)
|
||||
return f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/{m.group(3)}" if m else url
|
||||
|
||||
|
||||
def vendor_pages(urls: list[str]) -> list[dict]:
|
||||
"""Fetch each registry URL and regex out CVE ids, with a little surrounding context."""
|
||||
out = []
|
||||
@@ -284,14 +329,15 @@ def vendor_pages(urls: list[str]) -> list[dict]:
|
||||
# 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": {}}
|
||||
entry = {"source": u, "status": "ok", "cves": [], "context": {}, "fixed_in": {}}
|
||||
try:
|
||||
text = _fetch(u)
|
||||
text = _fetch(_raw_if_blob(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)
|
||||
@@ -595,7 +641,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
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})
|
||||
"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():
|
||||
@@ -616,7 +663,8 @@ 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("cves", []))})
|
||||
for cve in entry.get("cves", []):
|
||||
record(cve, entry["source"], context=entry["context"].get(cve))
|
||||
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)
|
||||
@@ -656,12 +704,22 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
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 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)
|
||||
window_key[src] = key
|
||||
report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()}
|
||||
|
||||
def _classify_window(src, wf, wt):
|
||||
@@ -719,6 +777,32 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
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:
|
||||
@@ -893,6 +977,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 +1095,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/<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; "
|
||||
@@ -911,6 +1109,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(':')
|
||||
|
||||
@@ -42,6 +42,11 @@ _spec = importlib.util.spec_from_file_location("resolve_images", os.path.join(HE
|
||||
RI = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(RI)
|
||||
|
||||
# advisory-scan supplies the source-fetching + changelog-attribution used by --security-sources
|
||||
_aspec = importlib.util.spec_from_file_location("advisory_scan", os.path.join(HERE, "advisory-scan.py"))
|
||||
A = importlib.util.module_from_spec(_aspec)
|
||||
_aspec.loader.exec_module(A)
|
||||
|
||||
REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", os.path.join(HERE, "upstream"))
|
||||
USED_RECIPES = os.path.join(HERE, "used-recipes.md")
|
||||
DEPRECATION_RE = re.compile(
|
||||
@@ -108,6 +113,36 @@ def newest_tag_date(registry: str, repo: str, tag: str) -> str | None:
|
||||
return (d.get("results") or [{}])[0].get("last_updated")
|
||||
|
||||
|
||||
def security_source_audit(recipe: str) -> list[dict]:
|
||||
"""Per source: are its CVEs USABLE, or merely visible?
|
||||
|
||||
The nginx lesson. nginx publishes no GitHub advisories; all its CVEs live in nginx.org/en/CHANGES.
|
||||
The scan saw them and could do nothing with them, because nothing said which release fixed which
|
||||
CVE — so every nginx bump in the fleet reported 0. Attribution (advisory-scan §2b) fixed that for
|
||||
changelogs organised by release, but a page that lists CVEs with NO release structure is still a
|
||||
blind spot: visible, uncountable. This finds those.
|
||||
|
||||
Per source: `advisory-feed` (structured, best), `changelog` (CVEs attributable to a release),
|
||||
`unattributable` (CVEs present but no release structure — BLIND), or `no-cve-data`.
|
||||
"""
|
||||
urls, _ = _registry_urls(recipe)
|
||||
out = []
|
||||
for entry in A.github_advisories(urls):
|
||||
out.append({"source": entry["source"], "kind": "advisory-feed",
|
||||
"status": entry["status"], "cves": len(entry.get("advisories") or []),
|
||||
"usable": len(entry.get("advisories") or [])})
|
||||
for entry in A.vendor_pages(urls):
|
||||
if entry["status"].startswith("skipped"):
|
||||
continue
|
||||
n = len(entry.get("cves") or [])
|
||||
attributed = len(entry.get("fixed_in") or {})
|
||||
kind = ("no-cve-data" if n == 0 else
|
||||
"changelog" if attributed else "unattributable")
|
||||
out.append({"source": entry["source"], "kind": kind, "status": entry["status"],
|
||||
"cves": n, "usable": attributed})
|
||||
return out
|
||||
|
||||
|
||||
def audit_recipe(recipe: str, ssh: str | None, quiet_days: int) -> dict:
|
||||
out = {"recipe": recipe, "findings": [], "images": [], "sources": []}
|
||||
try:
|
||||
@@ -214,9 +249,41 @@ def main() -> int:
|
||||
ap.add_argument("--ssh", default=None)
|
||||
ap.add_argument("--quiet-days", type=int, default=365)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--security-sources", action="store_true",
|
||||
help="audit whether each recipe's CVE sources are USABLE (structured advisory "
|
||||
"feed / release-attributable changelog) or merely visible")
|
||||
a = ap.parse_args()
|
||||
|
||||
recipes = a.recipes or all_recipes()
|
||||
if a.security_sources:
|
||||
# What matters is whether the RECIPE can see CVEs at all — not whether some individual page
|
||||
# is unparseable. A page with no release structure is harmless when the same project also
|
||||
# publishes an advisory feed (redis, gitea, minio, clickhouse all do); it is only a blind
|
||||
# spot when nothing else covers that project.
|
||||
blind_recipes, noisy = [], 0
|
||||
for r in recipes:
|
||||
rows = security_source_audit(r)
|
||||
feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0]
|
||||
logs = [x for x in rows if x["kind"] == "changelog"]
|
||||
unattr = [x for x in rows if x["kind"] == "unattributable"]
|
||||
noisy += len(unattr)
|
||||
usable = len(feeds) + len(logs)
|
||||
if usable == 0:
|
||||
blind_recipes.append(r)
|
||||
print(f"!! {r}: NO USABLE CVE SOURCE — {len(unattr)} unparseable page(s), "
|
||||
f"0 advisory feeds, 0 attributable changelogs")
|
||||
for x in rows:
|
||||
print(f" {x['kind']:15} {x['source'][:64]} ({x['cves']} CVEs)")
|
||||
else:
|
||||
print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)"
|
||||
+ (f", {len(unattr)} unparseable page(s) (redundant — covered by a feed)"
|
||||
if unattr else ""))
|
||||
for x in logs:
|
||||
print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})")
|
||||
print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source"
|
||||
+ (f": {', '.join(blind_recipes)}" if blind_recipes else "")
|
||||
+ f" · {noisy} unparseable page(s) elsewhere (harmless where a feed covers them)")
|
||||
return 0
|
||||
reports = [audit_recipe(r, a.ssh, a.quiet_days) for r in recipes]
|
||||
if a.json:
|
||||
print(json.dumps(reports, indent=2))
|
||||
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pr-survey — deterministic facts about every open recipe PR, for /cc-ci-cleanup to judge.
|
||||
|
||||
Open recipe PRs rot in specific, detectable ways. This gathers the evidence; it does NOT decide
|
||||
anything — closing a PR is a judgement the skill makes, with these facts in hand.
|
||||
|
||||
RUN `reconcile-upstream.sh --all` FIRST. Every signal below is measured against the mirror's `main`,
|
||||
and an unreconciled mirror makes all of them wrong: on 2026-08-11 three PRs (discourse #6 carrying
|
||||
140 CVEs, keycloak #6 carrying 12, n8n #5) looked pending against a stale mirror while upstream had
|
||||
already merged them. This tool refuses to guess about that — see `reconciled_recently`.
|
||||
|
||||
Per PR:
|
||||
behind_main commits on main not in the branch — the "out of date" measure
|
||||
ahead commits on the branch not on main
|
||||
mergeable gitea's own verdict (false = conflicts, needs a rebase)
|
||||
diff_files files the PR touches (0 = nothing left to merge)
|
||||
adds_images the `+ image:` lines it introduces
|
||||
already_in_main those `+ image:` lines ALREADY present in main -> the bump landed another way
|
||||
obsolete true when every image it adds is already in main (nothing to contribute)
|
||||
ci newest `!testme` verdict + build number parsed from the PR comments
|
||||
branch_kind upgrade / fix / ci-artifact (`ci/*` sweep + probe branches) / other
|
||||
age_days, stale_days (since last update)
|
||||
|
||||
pr-survey.py [recipe ...] [--json]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
USED_RECIPES = os.path.join(HERE, "used-recipes.md")
|
||||
TESTENV = os.environ.get("CCCI_TESTENV", "/srv/cc-ci/.testenv")
|
||||
NS = "recipe-maintainers"
|
||||
|
||||
|
||||
def _env() -> dict:
|
||||
e = {}
|
||||
try:
|
||||
for ln in open(TESTENV):
|
||||
ln = ln.strip()
|
||||
if "=" in ln and not ln.startswith("#"):
|
||||
k, v = ln.split("=", 1)
|
||||
e[k] = v.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return e
|
||||
|
||||
|
||||
ENV = _env()
|
||||
GITEA = os.environ.get("GITEA_URL") or ENV.get("GITEA_URL", "git.autonomic.zone")
|
||||
_AUTH = base64.b64encode(
|
||||
f"{os.environ.get('GITEA_USERNAME') or ENV.get('GITEA_USERNAME','')}:"
|
||||
f"{os.environ.get('GITEA_PASSWORD') or ENV.get('GITEA_PASSWORD','')}".encode()
|
||||
).decode()
|
||||
|
||||
|
||||
def _get(path: str, raw: bool = False):
|
||||
req = urllib.request.Request(
|
||||
f"https://{GITEA}{path}",
|
||||
headers={"Authorization": f"Basic {_AUTH}", "User-Agent": "cc-ci-pr-survey"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
body = r.read()
|
||||
return body.decode(errors="replace") if raw else json.loads(body)
|
||||
|
||||
|
||||
def _days(iso: str | None) -> int | None:
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return (datetime.now(timezone.utc) - d).days
|
||||
|
||||
|
||||
def _branch_kind(ref: str) -> str:
|
||||
if ref.startswith("ci/"):
|
||||
return "ci-artifact" # regall/cfold sweeps + testme probes; never meant to merge
|
||||
if ref.startswith("upgrade"):
|
||||
return "upgrade"
|
||||
if re.match(r"^(fix|feat|chore|revert)", ref):
|
||||
return "fix"
|
||||
return "other"
|
||||
|
||||
|
||||
def _main_images(recipe: str) -> set[str]:
|
||||
"""Image refs pinned on the mirror's main — the baseline a PR is judged against."""
|
||||
out = set()
|
||||
for f in ("compose.yml",):
|
||||
try:
|
||||
txt = _get(f"/{NS}/{recipe}/raw/branch/main/{f}", raw=True)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for m in re.finditer(r"^\s*image:\s*[\"']?([^\"'\s]+)", txt, re.M):
|
||||
out.add(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def _ci_verdict(recipe: str, number: int) -> dict:
|
||||
"""Newest cc-ci !testme outcome recorded on the PR."""
|
||||
try:
|
||||
cs = _get(f"/api/v1/repos/{NS}/{recipe}/issues/{number}/comments?limit=100")
|
||||
except Exception: # noqa: BLE001
|
||||
return {"verdict": "unknown", "build": None}
|
||||
for c in reversed(cs):
|
||||
b = c.get("body") or ""
|
||||
if "cc-ci:testme" not in b:
|
||||
continue
|
||||
m = re.search(r"/cc-ci/(\d+)", b)
|
||||
if "✅" in b or "passed" in b:
|
||||
return {"verdict": "passed", "build": m.group(1) if m else None}
|
||||
if "❌" in b or "failure" in b:
|
||||
return {"verdict": "failed", "build": m.group(1) if m else None}
|
||||
if "⏳" in b or "in progress" in b:
|
||||
return {"verdict": "running", "build": m.group(1) if m else None}
|
||||
return {"verdict": "never-run", "build": None}
|
||||
|
||||
|
||||
def survey_pr(recipe: str, pr: dict, main_images: set[str]) -> dict:
|
||||
n = pr["number"]
|
||||
head = pr["head"]["ref"]
|
||||
row = {
|
||||
"recipe": recipe, "number": n, "title": pr.get("title", ""), "head": head,
|
||||
"url": pr.get("html_url"), "branch_kind": _branch_kind(head),
|
||||
"age_days": _days(pr.get("created_at")), "stale_days": _days(pr.get("updated_at")),
|
||||
"mergeable": pr.get("mergeable"),
|
||||
}
|
||||
try:
|
||||
row["behind_main"] = _get(
|
||||
f"/api/v1/repos/{NS}/{recipe}/compare/{urllib.parse.quote(head, safe='')}...main"
|
||||
).get("total_commits", 0)
|
||||
row["ahead"] = _get(
|
||||
f"/api/v1/repos/{NS}/{recipe}/compare/main...{urllib.parse.quote(head, safe='')}"
|
||||
).get("total_commits", 0)
|
||||
except Exception: # noqa: BLE001
|
||||
row["behind_main"], row["ahead"] = None, None
|
||||
# A FAILED diff fetch must never look like an empty diff: gitea#4 404s on .diff (force-pushed
|
||||
# branch) and would otherwise be flagged EMPTY-DIFF and closed — while being a verified, green,
|
||||
# needed fix. Unknown is its own state.
|
||||
diff = None
|
||||
try:
|
||||
body = _get(f"/{NS}/{recipe}/pulls/{n}.diff", raw=True)
|
||||
if body.lstrip().startswith(("diff --git", "From ")) or not body.strip():
|
||||
diff = body
|
||||
except Exception: # noqa: BLE001
|
||||
diff = None
|
||||
row["diff_files"] = None if diff is None else len(re.findall(r"^diff --git ", diff, re.M))
|
||||
adds = re.findall(r"^\+\s*image:\s*[\"']?([^\"'\s]+)", diff or "", re.M)
|
||||
row["adds_images"] = sorted(set(adds))
|
||||
row["already_in_main"] = sorted({i for i in set(adds) if i in main_images})
|
||||
# Nothing left to contribute: it touches files but every image it introduces is already pinned.
|
||||
# Only claim obsolete when the diff was actually READ. No diff, no verdict.
|
||||
row["obsolete"] = diff is not None and bool(adds) and set(adds).issubset(main_images)
|
||||
row["ci"] = _ci_verdict(recipe, n)
|
||||
return row
|
||||
|
||||
|
||||
def all_recipes() -> list[str]:
|
||||
out = []
|
||||
for ln in open(USED_RECIPES):
|
||||
p = ln.split()
|
||||
if len(p) >= 2 and not ln.startswith(("#", "`")) and p[1] in ("weekly", "external"):
|
||||
out.append(p[0])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("recipes", nargs="*")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = []
|
||||
for r in (a.recipes or all_recipes()):
|
||||
try:
|
||||
prs = _get(f"/api/v1/repos/{NS}/{r}/pulls?state=open&limit=50")
|
||||
except urllib.error.HTTPError as e:
|
||||
rows.append({"recipe": r, "error": f"HTTP {e.code}"})
|
||||
continue
|
||||
if not prs:
|
||||
continue
|
||||
mi = _main_images(r)
|
||||
for pr in prs:
|
||||
rows.append(survey_pr(r, pr, mi))
|
||||
|
||||
if a.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"{len(rows)} open PR(s)\n")
|
||||
for x in sorted(rows, key=lambda z: (z.get("recipe", ""), z.get("number", 0))):
|
||||
if x.get("error"):
|
||||
print(f" {x['recipe']}: {x['error']}")
|
||||
continue
|
||||
flags = []
|
||||
if x["obsolete"]:
|
||||
flags.append("OBSOLETE(images already in main)")
|
||||
if x["branch_kind"] == "ci-artifact":
|
||||
flags.append("CI-ARTIFACT")
|
||||
if x["diff_files"] == 0:
|
||||
flags.append("EMPTY-DIFF")
|
||||
if x["diff_files"] is None:
|
||||
flags.append("DIFF-UNREADABLE(do not close on this)")
|
||||
if x["mergeable"] is False:
|
||||
flags.append("CONFLICTS")
|
||||
if (x["behind_main"] or 0) > 0:
|
||||
flags.append(f"BEHIND-{x['behind_main']}")
|
||||
print(f" {x['recipe']}#{x['number']:<3} {x['title'][:52]}")
|
||||
print(f" {x['branch_kind']:12} age={x['age_days']}d idle={x['stale_days']}d "
|
||||
f"ci={x['ci']['verdict']}({x['ci']['build'] or '-'}) files={x['diff_files'] if x['diff_files'] is not None else '?'}")
|
||||
if x["adds_images"]:
|
||||
print(f" adds: {', '.join(i.split('/')[-1] for i in x['adds_images'][:4])}")
|
||||
if flags:
|
||||
print(f" >> {' | '.join(flags)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -537,6 +537,133 @@ class TestReleaseLineSemantics(unittest.TestCase):
|
||||
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2025-49844"])
|
||||
|
||||
|
||||
class TestChangelogAttribution(unittest.TestCase):
|
||||
"""Projects that publish no advisory feed still say which release fixed what — in their changelog."""
|
||||
|
||||
CHANGES = """
|
||||
Changes with nginx 1.31.3 11 Aug 2026
|
||||
*) Security: a flaw ... (CVE-2026-60005)
|
||||
*) Security: another ... (CVE-2026-56434)
|
||||
|
||||
Changes with nginx 1.31.2 04 Aug 2026
|
||||
*) Security: something ... (CVE-2026-48142)
|
||||
|
||||
Changes with nginx 1.31.1 21 Jul 2026
|
||||
*) Security: older ... (CVE-2026-9256)
|
||||
|
||||
Changes with nginx 1.20.0 01 Jan 2021
|
||||
*) Security: ancient ... (CVE-2013-2028)
|
||||
"""
|
||||
|
||||
def test_each_cve_is_attributed_to_the_release_that_fixed_it(self):
|
||||
got = A._changelog_versions(self.CHANGES)
|
||||
self.assertEqual(got["CVE-2026-60005"], "1.31.3")
|
||||
self.assertEqual(got["CVE-2026-48142"], "1.31.2")
|
||||
self.assertEqual(got["CVE-2026-9256"], "1.31.1")
|
||||
self.assertEqual(got["CVE-2013-2028"], "1.20.0")
|
||||
|
||||
def _scan(self, wfrom, wto):
|
||||
# nginx publishes NO GitHub advisories — the feed is empty and the changelog is everything.
|
||||
return run_scan(
|
||||
[gh("nginx/nginx", [])],
|
||||
[{"source": "https://nginx.org/en/CHANGES", "status": "ok",
|
||||
"cves": sorted(A._changelog_versions(self.CHANGES)),
|
||||
"context": {}, "fixed_in": A._changelog_versions(self.CHANGES)}],
|
||||
images=[("nginx", wfrom, wto)], urls=["https://github.com/nginx/nginx"])
|
||||
|
||||
def test_window_counts_only_the_releases_it_crosses(self):
|
||||
rep = self._scan("1.31.1", "1.31.3") # 1.31.1 is the FROM, so its CVE is already fixed
|
||||
self.assertEqual(set(rep["fixed_by_this_upgrade"]),
|
||||
{"CVE-2026-48142", "CVE-2026-56434", "CVE-2026-60005"})
|
||||
|
||||
def test_a_narrower_window_counts_fewer(self):
|
||||
rep = self._scan("1.31.2", "1.31.3")
|
||||
self.assertEqual(set(rep["fixed_by_this_upgrade"]), {"CVE-2026-56434", "CVE-2026-60005"})
|
||||
|
||||
def test_ancient_entries_are_not_swept_in(self):
|
||||
# The changelog lists the project's whole history; only the crossed releases may count.
|
||||
rep = self._scan("1.31.1", "1.31.3")
|
||||
self.assertNotIn("CVE-2013-2028", rep["fixed_by_this_upgrade"])
|
||||
|
||||
def test_evidence_is_recorded(self):
|
||||
rep = self._scan("1.31.1", "1.31.3")
|
||||
self.assertEqual(rep["resolved_by_changelog"]["CVE-2026-60005"], "1.31.3")
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user