Step-by-step specification of cc-ci-plan/advisory-scan.py: inputs, the three source classes and why each is ranked where it is, the union, both classification paths (patched-version ranges, and the advisory-publish-date fallback for version-scheme changes), the output contract, and how /recipe-report must read it. Each rule records the production wrong answer that motivated it — the false 133 from cross-image counting, the n8n misclassification from reading only vulnerabilities[0], the '?' sprawl from url punctuation and benign-404s, and the 'never emit 0 for an undetermined count' rule. Claims cross-checked against the implementation. Keep this file in the same commit as any behaviour change.
9.6 KiB
Advisory scan — specification
What cc-ci-plan/advisory-scan.py does, step by step, and why each step exists. This documents the
implementation as it stands (2026-08-10); if you change the code, change this file in the same commit.
Role. A deterministic, per-recipe CVE detector run as a pre-step of /recipe-upgrade (step 2a).
It is strictly additive: it never replaces the release-note reading the upgrade agent already does.
The CVE count reported for a recipe is the union of what the agent read and what this scan found;
the scan may never lower a count established by reading.
Why it exists. gitea 1.27.1 fixed CVE-2026-60004 and CVE-2026-59774 (both CVSS 9.8). The weekly
report printed gitea's CVE count as 1, then none. The upgrade agent had read the GitHub release
notes, which name neither — both were announced only in the vendor's blog security section — and the
report then derived security content from those notes plus model knowledge, which predates the CVEs.
Nothing in the pipeline queried an advisory source. This scan closes that hole.
Inputs
advisory-scan.py <recipe> [--from <version>] [--to <version>] [--json] [--registry DIR]
| Input | Meaning |
|---|---|
<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 |
--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. |
Exit code is always 0 — this is informational. Failures are reported, never raised.
Step 1 — Collect source URLs from the registry
Read cc-ci-plan/upstream/<recipe>.md and extract every http(s)://… URL.
Trailing markdown punctuation is stripped (` ' " * . , ; : > )). The registry
is markdown, so URLs appear inside backticks and quotes; capturing the punctuation produced fetches of
https://docs.n8n.io/release-notes/\`` which 404, and made immich and n8n render ?` for no real reason.
Registry hygiene matters. The scan can only look where the registry points. Two classes of defect have been found and fixed by running it: a wrong URL (
pgautoupgrade/pgautoupgrade, which 404s — the repo ispgautoupgrade/docker-pgautoupgrade) and a missing one (gitea's CVEs are announced atblog.gitea.com, which the registry didn't list). When a vendor publishes security notes somewhere the registry lacks, add it.
Step 2 — Query the sources
Three source classes, each recording its own status so "checked, none found" is never confused with "not checked".
2a. GitHub Security Advisories — PRIMARY
For every github.com/<owner>/<repo> URL in the registry:
GET /repos/<owner>/<repo>/security-advisories.
Captured per advisory: cve_id, ghsa_id, severity, summary, published_at, and all
vulnerabilities[] entries' vulnerable_version_range + patched_versions (joined with ;).
- All entries, not just the first. An advisory carries one entry per patched release line —
n8n patches three (1.123.32, 2.17.4, 2.18.1). Reading only
vulnerabilities[0]silently dropped the line a deployment was actually on, and misclassified CVE-2026-42231/42232 as out-of-window. - Pagination via the
Link rel="next"cursor, to exhaustion (cap 20 hops). This endpoint returns at most 100 rows and ignores?page=— it re-returns the same rows, which silently truncates busy projects. discourse has 286 advisories; a single page cannot even cover one upgrade window. - HTTP 404 ⇒
no-advisories-published— a benign absence (many sidecar images publish none), not a failure. Conflating the two pushed nearly every recipe to?and destroyed the signal.
This source is primary because it carries severity and version ranges, making "fixed by this upgrade" computable rather than guessed.
2b. Vendor release / security pages
Every other registry URL is fetched, HTML-stripped, and scanned for CVE-\d{4}-\d{4,7}, keeping ±160
characters of context per hit.
URLs containing <, >, {, }, VERSION, or vX.Y.Z are skipped as templates — they are
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.
2c. OSV.dev — supplementary
Only when the recipe has an entry in OSV_PACKAGES (ecosystem + package) and a version is given.
Measured, not assumed. For the two gitea CVEs, OSV 404'd on both and returned only Go dependency advisories for the package; NVD's API had them by neither CPE, CVE id, nor keyword. Advisory databases lag the vendor, which is why 2a and 2b lead and this is supplementary.
Step 3 — Union
All findings merge into one CVE map: id → {sources[], severity, ghsa, vulnerable_range, patched, published_at, context}. A CVE seen by several sources keeps them all.
Step 4 — Classify against the upgrade window
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. Why: discourse once reported 133, of which 34 were redis CVEs — includingCVE-2021-21309, patched in redis 6.0.11 in 2021 — counted purely because 6.0.11 sits numerically inside discourse's3.5.3 → 2026.7.1range.B. Never emit a number you cannot justify. If neither method below can order the window, the count is
null/UNKNOWN, never0. A0in a security column asserts safety.
4a. By patched version (preferred — exact)
patched_versions is a range expression (">= 2.18.1"), possibly several joined by ;. Extract
every version-looking token; the advisory is fixed-by-this-upgrade if any patched version p
satisfies from < p <= to, using a loose numeric key (leading integers per dot-part).
4b. By advisory publish date (fallback — temporal)
Used only when 4a cannot be trusted: a version-scheme change, detected as the leading version
component jumping by ≥ SCHEME_JUMP (100) — e.g. semver 3.5.3 → calver 2026.7.1.
Version strings are unorderable across such a jump (2025.12.2 compares "newer" than 3.5.3 while
shipping earlier), but release dates always order. So:
- Resolve
--fromand--toto git tag dates on the primary repo (triesv<version>then<version>; annotated tag → tagger date, else commit date). - An advisory counts as fixed when
date_from < published_at <= date_to.
Both the method and the resolved window appear in the output. This reproduces, automatically, the hand
count that established discourse 3.5.3 (2025-12-30) → 2026.7.1 (2026-07-31) = 123 CVEs.
Assumption: the vendor publishes advisories at fix time (true for discourse). The count includes first-party plugin advisories where the vendor files them on the same repo — which is why a plugin-rich project scores far higher than a monolith, not a statement about relative security.
4c. Otherwise
count_known = false, cve_count_fixed = null, and the markdown headline reads
"CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count" with an explicit
"This is NOT zero".
Step 5 — Output
Markdown (default) for pasting into the per-recipe upgrade log, or --json.
| Field | Meaning |
|---|---|
cve_count_fixed |
Count, or null when undeterminable |
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 |
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 |
sources_failed[] |
Genuine failures only |
sources_benign[] |
no-advisories-published, skipped: template URL |
How consumers must read it
/recipe-report renders the cve column from the union of this scan and the agent's own reading:
- a clean scan → its number, including
0; - failed sources, or
UNKNOWN→?, nevernone— a blank reads as "clean", which is exactly how two CVSS-9.8 gitea RCEs were published asnone; - no upgrade this run →
0, not?— nothing an upgrade could have fixed; - benign notes → never
?.
? must stay rare: it means we tried and could not tell, not we did not look. A rash of ? is
a bug to raise in the report's Addendum, not a normal outcome — every instance so far traced to a defect
in this tool or stale registry data.
Known limits
- One window per scan. Sidecar bumps (redis, postgres) are not counted; they appear as unclassified. Per-image windows would be the natural extension.
- Date-based counts are temporal, not exact — they assume publish-at-fix-time.
- Registry-bound. Unlisted vendor security pages are invisible; the scan cannot know what it was never pointed at.
- Rate limit without a token is 60/hr — a full weekly sweep will exhaust it and degrade to failed sources (visibly, but degraded).