From feb05287ef0a725ad5fca83d676a80ac3cd622b7 Mon Sep 17 00:00:00 2001 From: Stefan Kofler Date: Fri, 28 Aug 2026 11:24:31 +0200 Subject: [PATCH] Five defects the curacoach v1.0.0-qa10 report surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each one produced a confident wrong answer rather than an error, and each asked someone for work that could not be done as written. A remediation action dropped the npm scope. purl_name() took the last path segment, so @nestjs/core, @sigstore/core and @strapi/core all became "core" and one report carried three actions titled "upgrade core". The unit key is built from that name, so two scoped packages sharing a last segment inside one artifact would also have merged into a single action. Splitting on the first @ was the same defect twice: an unencoded scoped purl answered "npm". A prerelease was printed as the fix. The Fixed-in column pooled every fixed version in a component group and took sorted(...)[-1]. multer's advisories publish 2.2.0 and 3.0.0-alpha.2, @babel/core's publish 7.29.6 and 8.0.0-rc.6, and string order puts the alpha last — so the column named a version a released product cannot adopt while a stable fix stood beside it. A prerelease is now dropped from the fix list wherever a stable one exists, and the column picks per state. The new prerelease-only status covers the case where an advisory publishes nothing but a prerelease; that case appears in none of qa10's 64 advisories and is held by tests alone. Separate from none-published because the answer differs: track the stable release rather than carry a compensating control for good. Narrow by construction, since a hyphen is not a prerelease marker in 31.1-jre or 2.36-9. The same column also named too low a version. sorted() is lexical, so vite, whose advisories publish 6.4.2 through 8.0.16 and 8.0.5, printed 8.0.5 — below the version that carries the fix. Ordered numerically now. An advisory without a purl aborted the whole scan. affected[].package.purl is optional in OSV, and ("" | split("@")) is [] rather than [""] in jq, so the index yielded null and startswith(null) ended the program. Latent today because OSV populates the field, fatal for the entire run when it does not. Staleness asked the same question every release. package:collection and url_launcher are pinned by the Dart SDK constraint and can never look current against a 12-month window, so every Flutter product recorded the same reason again at every release. Answered by process default instead, keyed on the pub.dev verified publisher, which is a fact from the registry. npm has no equivalent: its author field is free text and never earns it. The row stays in section 4 with its reason, since the staleness is real. The backstop read the deployment history live whatever --now said, so a report dated T could rest on a deployment made after T and the same evidence gave different answers on different days. Verified against the real registries, a real OSV advisory shape, the 7783 purls in the published documents, and the rendered PDF. Baseline 211 passed on main; 227 offline and 233 with TEST_NETWORK=1 here. Co-Authored-By: Claude Opus 5 (1M context) --- soup-discovery/IMPLEMENTATION.md | 5 + soup-discovery/policy-defaults.yml | 22 ++ soup-discovery/scripts/backstop-report.py | 20 +- soup-discovery/scripts/check-currency.py | 60 +++- soup-discovery/scripts/group-remediation.py | 48 ++- soup-discovery/scripts/render-vdr-pdf.py | 65 +++- soup-discovery/scripts/scan-vulns.sh | 49 ++- soup-discovery/scripts/validate-policy.sh | 20 +- soup-discovery/tests/run-tests.sh | 325 ++++++++++++++++++++ 9 files changed, 589 insertions(+), 25 deletions(-) diff --git a/soup-discovery/IMPLEMENTATION.md b/soup-discovery/IMPLEMENTATION.md index de06ac4..51da64b 100644 --- a/soup-discovery/IMPLEMENTATION.md +++ b/soup-discovery/IMPLEMENTATION.md @@ -133,6 +133,8 @@ dependency_currency.max_behind .major / .minor / .patch | 0 / 1 / unlimited | `unlimited` patch is refused in TR-03161 scope. dependency_currency.stale_after| 12m| — dependency_currency. +stale_exempt_publishers| dart.dev, flutter.dev| Publishers whose staleness is answered by the process rather than per product: an SDK-pinned package releases on the platform cadence and can never look current. Keyed on the registry-*verified* publisher, which today only pub.dev exposes — an npm `author` string is free text and never earns it. Answers staleness only; being behind the update limit still owes an upgrade. Adding a publisher is a widening and needs `dependency_currency.reason`. +dependency_currency. obsolescence_may_be_accepted| false | `true` is refused in TR-03161 scope. production_release.tag_pattern| ^v?\d+\\.\d+\\.\d+$ | Selects the document level only. onboarded @@ -178,6 +180,9 @@ quickbird:soup:direct-without-record (+ :direct-without-record-name) | A component the manifests mark as a direct choice, with no SOUP record, chosen, shipped, never approved. Count in the metadata, one named entry per component. quickbird:currency:latest :status / :detail | The latest available version next to the shipped one, per component: current, behind, stale, stale-and-behind, or unknown with the reason. +quickbird:currency:publisher +:stale-exempt | The registry-verified publisher (pub only), and the process-default reason where one answers this component's staleness. The row stays in section 4 of the report either way — the staleness is a fact — but an exempt row is unshaded and carries the reason instead of "No decision recorded." +quickbird:vuln:fix | `available`, `prerelease-only`, `none-published` or `unknown`. `prerelease-only` means upstream has a fix but only as an alpha/rc, which a released product cannot adopt: the work is to track the stable release, not to bump. Separate from `none-published`, where no fix exists at all and the answer is a compensating control or a VEX statement. The scanner version is pinned, currently syft 1.51.0, and never `latest`: the component list must not change because a scanner updated itself between two runs of the same commit. diff --git a/soup-discovery/policy-defaults.yml b/soup-discovery/policy-defaults.yml index 32b7ad5..0e07714 100644 --- a/soup-discovery/policy-defaults.yml +++ b/soup-discovery/policy-defaults.yml @@ -135,6 +135,28 @@ dependency_currency: # answer — there is nothing to upgrade to. 12 months matches the analysis period the # SOUP records already use in grq-3 ("Is maintained and support is available"). stale_after: 12m + # Staleness answered by the process instead of per product. + # + # A package published by the platform vendor releases on the platform cadence, not on its + # own: package:collection and url_launcher are pinned by the Dart SDK constraint and can + # never look current against a 12-month window. Without this every Flutter product carries + # the same handful of rubber-stamp decisions at every release, which is the outcome + # WI-006-09 exists to remove. + # + # Keyed on the *verified* publisher. pub.dev proves domain ownership before it shows one, + # so this is a fact read from the registry. npm has no equivalent — its `author` field is + # free text set by whoever publishes — so no npm package is exempt here and one that is + # stale still needs a reason in its SOUP record. + # + # Deliberately narrow. google.dev is not on this list: it publishes a grab-bag rather than + # the SDK, and visibility_detector (last release 2023) is exactly the finding a product + # should still have to answer. + # + # Exempts staleness only. A package from a listed publisher that is behind the update + # limit is still behind and still owes an upgrade. + stale_exempt_publishers: + - dart.dev + - flutter.dev # Written explicitly, not left implicit: validate-policy.sh's TR-03161 check already # treats an absent key the same as false, so this changes nothing about what is allowed. # It exists so policy.effective.json shows the value in force instead of being silent diff --git a/soup-discovery/scripts/backstop-report.py b/soup-discovery/scripts/backstop-report.py index bb1c23c..8223520 100755 --- a/soup-discovery/scripts/backstop-report.py +++ b/soup-discovery/scripts/backstop-report.py @@ -81,12 +81,19 @@ def _gh_json(args, timeout=60): return None -def production_deploys(repo): +def production_deploys(repo, not_after=None): """Production deployment timestamps with a tag ref, newest first. Returns (dates, basis, environments_seen). `dates` is None only if the records could not be read — which is not the same as a product that never deployed and must not read as one. + `not_after` bounds the lookup to the report clock. Without it the deployment history was + read live whatever `--now` said, so a report dated T could claim a cadence that only + holds because of a deployment made after T — and the same evidence directory produced + different answers on different days, which is the property a frozen clock exists to + remove. In a live run `not_after` is the moment the run started, so nothing is excluded + that the run could have known about. + Filtered server-side by environment. Paginating the whole deployment history does not work at this scale: one product has ~25,000 records across its environments and the unfiltered walk timed out, reporting a product with 1059 production deploys as unknown. Filtered, the same answer @@ -121,6 +128,15 @@ def production_deploys(repo): prod_rows.extend(got) suffix = "" + if not_after is not None: + kept = [x for x in prod_rows + if (t := parse_ts(x.get("at"))) is not None and t <= not_after] + excluded = len(prod_rows) - len(kept) + prod_rows = kept + if excluded: + suffix += (f" ({excluded} record(s) after {not_after.date().isoformat()} excluded — " + f"a report cannot rest on a deployment made after the date it states)") + if not prod_rows: return ([], f"no deployment records at all{suffix}" if not envs else f"no production deployment records{suffix}", envs) @@ -351,7 +367,7 @@ def main(): "detail": "the run record carries no repo, so releases cannot be read"}) else: iv = parse_interval_days(interval) - dates, basis, envs = production_deploys(repo) + dates, basis, envs = production_deploys(repo, not_after=now) if dates is None: cadence.append({"product": product, "declared": interval, "status": "unknown", "detail": f"could not read the deployment records for {repo}. " diff --git a/soup-discovery/scripts/check-currency.py b/soup-discovery/scripts/check-currency.py index 1f7c6a0..51f9a3e 100755 --- a/soup-discovery/scripts/check-currency.py +++ b/soup-discovery/scripts/check-currency.py @@ -80,6 +80,26 @@ def fetch(url, timeout=20, accept=None): return json.loads(r.read().decode("utf-8")) +def pub_publisher(name): + """pub.dev verified publisher domain for a package, or None. + + A second request, which nothing else here needs — the package document does not carry + the publisher. Worth the round trip because it is the only *verified* publisher identity + any of these registries exposes: pub.dev proves domain ownership before it will show + one. npm has no equivalent, its `author` is free text set by whoever publishes, which is + why the staleness exemption keys on this field and not on `supplier`. + + A failed lookup returns None and the package simply is not exempt, so the failure + direction is to keep reporting a finding rather than to silence one. + """ + try: + d = fetch(f"https://pub.dev/api/packages/{name}/publisher") + return (d or {}).get("publisherId") or None + except (urllib.error.URLError, urllib.error.HTTPError, KeyError, + json.JSONDecodeError, TimeoutError): + return None + + def latest_version(purl, meta=None): """(version, published_iso, error). Returns None rather than guessing. @@ -145,9 +165,16 @@ def latest_version(purl, meta=None): if eco == "pub": full = fetch(REGISTRY["pub"].format(name=name)) d = full["latest"] - if meta is not None and full.get("isDiscontinued"): - meta["deprecated"] = "discontinued" + ( - f" (replaced by {full.get('replacedBy')})" if full.get("replacedBy") else "") + if meta is not None: + if full.get("isDiscontinued"): + meta["deprecated"] = "discontinued" + ( + f" (replaced by {full.get('replacedBy')})" if full.get("replacedBy") else "") + who = pub_publisher(name) + if who: + meta["publisher"] = who + # pub components carried no supplier at all until now; the verified + # publisher is the best answer the registry has to that question. + meta.setdefault("supplier", who) return d["version"], d.get("published"), None if eco == "golang": d = fetch(REGISTRY["golang"].format(name=name)) @@ -238,6 +265,10 @@ def annotate_bom(path, notes): extra.append({"name": "quickbird:currency:detail", "value": n["detail"]}) if n.get("deprecated"): extra.append({"name": "quickbird:currency:deprecated", "value": n["deprecated"][:200]}) + if n.get("publisher"): + extra.append({"name": "quickbird:currency:publisher", "value": str(n["publisher"])}) + if n.get("stale_exempt"): + extra.append({"name": "quickbird:currency:stale-exempt", "value": n["stale_exempt"]}) # supplier and license go into the CycloneDX standard fields — that is where every # other consumer expects them; nothing is overwritten that the scanner already knew if n.get("supplier") and not c.get("supplier"): @@ -310,6 +341,12 @@ def limit(key, default): # not by a run — the default is unlimited, so no default-configured product could show it. max_patch = limit("patch", "unlimited") stale_days = parse_window(cur_policy.get("stale_after", "12m")) + # Publishers whose staleness is answered by the process rather than per product. See + # policy-defaults.yml for what belongs in here and why it is keyed on the verified + # publisher rather than on a supplier string. + exempt_publishers = {str(x).strip().lower() + for x in (cur_policy.get("stale_exempt_publishers") or []) + if str(x).strip()} if args.now: now = datetime.fromisoformat(str(args.now).replace("Z", "+00:00")) if now.tzinfo is None: @@ -407,6 +444,15 @@ def check(c): else "update-available" if b != (0, 0, 0) else "current") note = {"status": status, "latest": latest} note.update({k: v for k, v in meta.items()}) + pub_id = (meta.get("publisher") or "").lower() + # `not meta.get("deprecated")` because a discontinued package is a finding whatever + # its publisher: the exemption answers "upstream is quiet", not "upstream is gone". + exempt = bool(is_stale and not over and not meta.get("deprecated") + and pub_id and pub_id in exempt_publishers) + if exempt: + note["stale_exempt"] = ( + f"Accepted by process default: {meta['publisher']} releases on the " + f"platform cadence and is pinned by the SDK constraint.") if meta.get("deprecated"): note["status"] = "deprecated" note["detail"] = f"declared deprecated by the registry: {meta['deprecated'][:120]}" @@ -444,6 +490,13 @@ def check(c): entry["finding"] = "behind" entry["action"] = f"upgrade to {latest}" + # A publisher exemption answers staleness and nothing else. A platform vendor + # ships versions behind the update limit like anyone else, and that finding + # keeps its upgrade — which is why `not over` is part of the test above. + if exempt: + entry["justified"] = True + entry["exempt_publisher"] = meta["publisher"] + entry["reason"] = note["stale_exempt"] if name in reasons: entry["justified"] = True entry["reason"] = reasons[name] @@ -487,6 +540,7 @@ def check(c): "stale_with_no_upgrade": len([r for r in flagged if r["finding"] == "upstream-stale-and-we-are-current"]), "justified": len(justified), + "stale_exempt_by_publisher": len([r for r in justified if r.get("exempt_publisher")]), "stale_images": len(stale_images), "unknown": len(unknown), }, diff --git a/soup-discovery/scripts/group-remediation.py b/soup-discovery/scripts/group-remediation.py index bc0d456..f877022 100644 --- a/soup-discovery/scripts/group-remediation.py +++ b/soup-discovery/scripts/group-remediation.py @@ -54,6 +54,7 @@ import re import subprocess import sys +from urllib.parse import unquote from collections import defaultdict from datetime import datetime, timezone @@ -140,10 +141,30 @@ def purl_type(purl): def purl_name(purl): - """Readable package name: the last path segment before the version.""" - body = (purl or "").split("?")[0].split("@")[0] - seg = [s for s in body.replace("pkg:", "", 1).split("/") if s] - return seg[-1] if len(seg) > 1 else (seg[0] if seg else "?") + """Readable package name, namespace included. + + The last path segment on its own is not a name. `pkg:npm/%40nestjs/core` and + `pkg:npm/%40sigstore/core` both reduce to "core", which produced three unrelated + actions all titled "upgrade core" in one report — and because the unit key below is + built from this name, two scoped packages sharing a last segment inside the same + artifact would collapse into a single action carrying one of the two names. + + Splitting on the *first* `@` was the second half of the same defect: an unencoded + scoped purl (`pkg:npm/@nestjs/core@11.1.14`) lost everything after `pkg:npm/` and the + function answered "npm". The version separator is the last `@`, and only when what + follows it is not another path segment. + """ + s = (purl or "").split("?")[0].split("#")[0] + if s.startswith("pkg:"): + s = s[4:] + at = s.rfind("@") + if at > 0 and "/" not in s[at:]: + s = s[:at] + seg = [unquote(x) for x in s.split("/") if x] + if len(seg) < 2: + return seg[0] if seg else "?" + # seg[0] is the purl type; everything after it is namespace + name. + return "/".join(seg[1:]) def main(): @@ -222,6 +243,17 @@ def main(): action = (f"no upgrade path in {artifact.replace('quickbird:artifact:', '')} — " f"the advisory publishes no fixed version, so this needs a " f"compensating control or a VEX statement, not a bump") + elif fx == "prerelease-only": + # Keyed per package, unlike no-upgrade-path above, because what is being + # waited on differs per package: multer's stable 3.0.0 and babel's 8.0.0 are + # unrelated releases on unrelated schedules, and one action covering both + # could only ever be half closed. + key = ("no-stable-upgrade-path", artifact, purl_name(c["purl"])) + action = (f"no stable upgrade for {purl_name(c['purl'])} in " + f"{artifact.replace('quickbird:artifact:', '')} — the only fixed " + f"version published is a prerelease, which a released product " + f"cannot adopt; track the stable release, add a compensating " + f"control, or record a VEX statement") elif ptype in OS_PKG_TYPES: key = ("base-image-bump", artifact) action = (f"bump the base image of " @@ -234,7 +266,7 @@ def main(): u = units.setdefault(key, { "kind": key[0], "artifact": artifact, "action": action, "findings": [], "components": set(), "fix_status": set(), - "no_fix": set(), + "no_fix": set(), "no_stable_fix": set(), }) u["findings"].append(fid) u["components"].add(f"{c['name']}@{c['version']}") @@ -242,6 +274,8 @@ def main(): member_of[fid].add(key) if fx == "none-published": u["no_fix"].add(fid) + if fx == "prerelease-only": + u["no_stable_fix"].add(fid) by_track = {f["id"]: f for f in doc.get("findings", [])} out_units = [] @@ -283,6 +317,10 @@ def earliest(field): # Even a bump may not clear these: the advisory publishes no fixed version. Carried # on the unit so it is visible without splitting the action in two. "findings_without_published_fix": len(u["no_fix"]), + # A fix exists upstream but only as a prerelease. Separate from the line above + # because the answer differs: this one is waiting on a release date, not on a + # compensating control. + "findings_without_stable_fix": len(u["no_stable_fix"]), "mitigation_due": earliest("mitigation_due"), "remediation_due": earliest("remediation_due"), "fix_status": sorted(u["fix_status"]), diff --git a/soup-discovery/scripts/render-vdr-pdf.py b/soup-discovery/scripts/render-vdr-pdf.py index 6fa3d7d..30f3031 100644 --- a/soup-discovery/scripts/render-vdr-pdf.py +++ b/soup-discovery/scripts/render-vdr-pdf.py @@ -27,6 +27,7 @@ import datetime import hashlib import json +import re import subprocess import sys from collections import deque @@ -86,6 +87,16 @@ def esc(s): return (str(s or "").replace("&", "&").replace("<", "<").replace(">", ">")) +def version_key(v): + """Order versions numerically. Plain string order puts 8.0.5 above 8.0.16. + + vite's advisories in one report publish both, and the Fixed-in column named 8.0.5 — a + version below the one that actually carries the fix. + """ + return [(0, int(part)) if part.isdigit() else (1, part) + for part in re.split(r"[._+\-]", str(v)) if part] + + def table(data, widths, shade=None): """shade: list of row indices -> colour""" style = [ @@ -210,6 +221,8 @@ def build(args): # ---- applied rules --------------------------------------------------------- mb = (policy.get("dependency_currency") or {}).get("max_behind") or {} + exempt_pubs = [str(x) for x in + ((policy.get("dependency_currency") or {}).get("stale_exempt_publishers") or [])] tr = policy.get("tracks") or {} def clocks(t): @@ -226,6 +239,10 @@ def clocks(t): f"no upstream release for > {policy.get('dependency_currency', {}).get('stale_after', '12m')} " f"→ replace, fork, or accept with reason", "dependency_currency" in project_keys), + ("Staleness exemption", + (", ".join(exempt_pubs) + " — staleness only, an update is still owed" + if exempt_pubs else "none: every stale component needs its own reason"), + "dependency_currency" in project_keys), ("Vulnerability classification", f"KEV → {clocks('kev')} · CVSS ≥ 9.0 → {clocks('immediate')} · " f"7.0–8.9 → {clocks('expedited')} · below → next window · unscored → Planned until scored", @@ -273,6 +290,9 @@ def clocks(t): within = [(c, p) for c, p, st in cur if st == "update-available"] stale = [(c, p) for c, p, st in cur if st == "stale"] deprecated = [(c, p) for c, p, st in cur if st == "deprecated"] + # Still listed in section 4 — the staleness is a fact and stays visible — but answered by + # the process, so it is not a number anyone has to act on. + stale_exempt = [(c, p) for c, p in stale if p.get("quickbird:currency:stale-exempt")] by_comp = {} for v in vulns: @@ -314,7 +334,7 @@ def cvss_of(v): tiles = [ (str(len(beyond)), "libraries beyond the update limit", BAD), (f"{n_crit} / {n_high}", "open Critical / High CVEs", BAD), - (str(len(stale) + len(deprecated)), "stale or deprecated", WARN), + (str(len(stale) + len(deprecated) - len(stale_exempt)), "stale or deprecated", WARN), (f"{n_kev} / {n_overdue}", "KEV / deadlines overdue", GOOD if (n_kev + n_overdue) == 0 else BAD), (f"{n_findings} \u2192 {n_units if n_units is not None else 'n/a'}", @@ -337,7 +357,9 @@ def cvss_of(v): f"Fix availability: " f"{fixes.count('available')} with a published fix · " f"{fixes.count('none-published')} without · " - f"{len(fixes) - fixes.count('available') - fixes.count('none-published')} undetermined.", + f"{fixes.count('prerelease-only')} only as a prerelease · " + f"{len(fixes) - fixes.count('available') - fixes.count('none-published') - fixes.count('prerelease-only')}" + f" undetermined.", small)) el.append(Paragraph( "Row shading: rule violated, no accepted decision · " @@ -441,12 +463,26 @@ def epss_num(v): ids = ", ".join(link(v.get("id"), (v.get("source") or {}).get("url")) for v in vs[:3]) if len(vs) > 3: ids += f' and {len(vs) - 3} further' - fx = sorted({f for v in vs - for f in (props(v).get("quickbird:vuln:fix-versions", "") or "").split(", ") - if f}) + def versions_in(state): + return sorted({f for v in vs + if props(v).get("quickbird:vuln:fix") == state + for f in (props(v).get("quickbird:vuln:fix-versions", "") + or "").split(", ") + if f}, key=version_key) + + # Split by state rather than pooling every fix-version in the group: a component + # with a stable fix for one CVE and only a prerelease for another would otherwise + # show whichever sorted last, which is how an alpha ends up printed as the answer. + fx_avail, fx_pre = versions_in("available"), versions_in("prerelease-only") fstates = {props(v).get("quickbird:vuln:fix", "?") for v in vs} - if fx: - fixed = f"{esc(fx[-1])}" + if fx_avail: + fixed = f"{esc(fx_avail[-1])}" + elif fx_pre: + # Bold and unqualified, the version read as something to bump to. It is not: + # a released product cannot adopt an alpha, so the column has to say which + # kind of version this is. + fixed = (f'{esc(fx_pre[-1])}
' + f'prerelease only
') elif fstates == {"none-published"}: fixed = 'no fix published' else: @@ -507,15 +543,22 @@ def epss_num(v): Paragraph("No decision recorded.", cell), ]) for c, p in sorted(g["stale"], key=lambda cp: (cp[0].get("name") or "").lower()): - shade[len(rows)] = SEV1 + exempt = p.get("quickbird:currency:stale-exempt") + if not exempt: + shade[len(rows)] = SEV1 + who = p.get("quickbird:currency:publisher") rows.append([ Paragraph(esc(c.get("name")), cell), Paragraph(esc(c.get("version")), cell), Paragraph(esc(p.get("quickbird:currency:detail", "")), small), - Paragraph("active flag not set", small), - Paragraph("No decision recorded.", cell), + Paragraph(f"verified publisher {esc(who)}" if who else "active flag not set", + small), + Paragraph(esc(exempt) if exempt else "No decision recorded.", cell), ]) - el.append(table(rows, [44 * mm, 26 * mm, 44 * mm, 26 * mm, 30 * mm], shade)) + # Status carries a full sentence once a publisher exemption is in force, and + # Registry status carries "verified publisher ". Both were sized for two + # words. Total unchanged at 170mm. + el.append(table(rows, [38 * mm, 22 * mm, 38 * mm, 30 * mm, 42 * mm], shade)) # ---- 5 remediation actions ---------------------------------------------------- el.append(Paragraph("5  Remediation actions", h2)) diff --git a/soup-discovery/scripts/scan-vulns.sh b/soup-discovery/scripts/scan-vulns.sh index b22cdbb..2c1c221 100755 --- a/soup-discovery/scripts/scan-vulns.sh +++ b/soup-discovery/scripts/scan-vulns.sh @@ -182,20 +182,54 @@ jq --slurpfile vulns "$TMP/vulns.json" --slurpfile affects "$TMP/affects.json" ' ($seg[-1]) ] | unique ; + # A fixed version that exists only as a prerelease is not a fix anyone here can apply: an + # alpha or an rc cannot ship in a released medical device. It is also not "no fix + # published" — upstream has one, it is simply not stable yet — and the two call for + # different work. None-published needs a compensating control or a VEX statement for + # good; a prerelease needs the stable release tracked. Reported as "available" (the + # behaviour before this) it produced an action, "upgrade multer to 3.0.0-alpha.2", that + # nobody could carry out and nobody could close. + # + # Deliberately narrow. The suffix convention only holds in ecosystems that use semver + # prereleases, and a hyphen on its own is not one: Maven publishes 31.1-jre and Debian + # 2.36-9 as ordinary releases. An unrecognised token leaves the status at "available", + # so the failure direction is to keep offering an upgrade rather than to withdraw one. + def semver_prerelease_ecosystem($e): + ($e | ascii_downcase + | IN("npm","go","pub","crates.io","packagist","hex","nuget","rubygems","maven")); + def is_prerelease($v): + ($v | tostring | ascii_downcase | split("+")[0] + | test("-(alpha|beta|rc|pre|preview|dev|canary|next|snapshot|nightly|milestone|m[0-9]|cr[0-9])")); + def fix_for($adv; $purl): (purl_keys($purl)) as $keys | ($purl | ascii_downcase | split("?")[0] | split("@")[0]) as $mine | ( [ $adv.fixes[]? | . as $fx - | ($fx.purl | split("@")[0]) as $fp + # `// ""` twice, not once: OSV marks affected[].package.purl optional, and in jq + # ("" | split("@")) is [] rather than [""], so the index yields null and + # `startswith(null)` aborts the whole program — one advisory without a purl would + # take down the entire scan, not just its own match. + | ((($fx.purl // "") | split("@") | .[0]) // "") as $fp | select( ($fp != "" and ($mine | startswith($fp))) or ($fx.name != "" and ($fx.name | IN($keys[]))) ) ] ) as $m + | ( [ $m[] | . as $fx | $fx.fixed[] + | select( (semver_prerelease_ecosystem($fx.ecosystem) and is_prerelease(.)) | not ) ] + | unique ) as $stable + | ( [ $m[] | . as $fx | $fx.fixed[] + | select( semver_prerelease_ecosystem($fx.ecosystem) and is_prerelease(.) ) ] + | unique ) as $pre | if ($m | length) == 0 then # Could not tie the advisory to this component. Reporting "no fix" here would be a # claim we have not established. {status:"unknown", fixed:[], why:"the advisory does not name this package in a shape that could be matched"} - elif ([$m[].fixed[]] | length) > 0 then - {status:"available", fixed:([$m[].fixed[]] | unique), why:null} + elif ($stable | length) > 0 then + {status:"available", fixed:$stable, why:null} + elif ($pre | length) > 0 then + {status:"prerelease-only", fixed:$pre, + why:("the only fixed version the advisory publishes is a prerelease (" + ($pre | join(", ")) + + ") — a released product cannot adopt it, so the work here is to track the stable " + + "release, add a compensating control, or record a VEX statement, not to bump")} elif ([$m[] | select(.has_range)] | length) > 0 then {status:"none-published", fixed:[], why:"the advisory gives an affected range but publishes no fixed version — mitigation here is a compensating control or a VEX statement, not an upgrade"} @@ -230,6 +264,15 @@ jq --slurpfile vulns "$TMP/vulns.json" --slurpfile affects "$TMP/affects.json" ' [ {name:"quickbird:vuln:fix", value:"available"}, {name:"quickbird:vuln:fix-versions", value: ([ $fx[] | select(.status=="available") | .fixed[] ] | unique | join(", "))} ] + # After "available", so a component with a stable fix on one + # affected package and only a prerelease on another still + # reports the upgrade it can actually perform. + elif any($fx[]; .status == "prerelease-only") then + [ {name:"quickbird:vuln:fix", value:"prerelease-only"}, + {name:"quickbird:vuln:fix-versions", + value: ([ $fx[] | select(.status=="prerelease-only") | .fixed[] ] | unique | join(", "))}, + {name:"quickbird:vuln:fix-note", + value: ([ $fx[] | select(.status=="prerelease-only") | .why ] | first // "")} ] elif (($fx | length) > 0 and all($fx[]; .status == "none-published")) then [ {name:"quickbird:vuln:fix", value:"none-published"}, {name:"quickbird:vuln:fix-note", value: ($fx[0].why // "")} ] diff --git a/soup-discovery/scripts/validate-policy.sh b/soup-discovery/scripts/validate-policy.sh index 354bb9c..908e5cf 100755 --- a/soup-discovery/scripts/validate-policy.sh +++ b/soup-discovery/scripts/validate-policy.sh @@ -51,7 +51,7 @@ UNKNOWN=$(jq -r --argjson known "$KNOWN_TOP" 'keys - $known | join(", ")' <<<"$P for spec in \ 'tracks:.tracks // {} | [.[] | keys[]] | unique:["mitigation","remediation","reason"]' \ 'epss:.epss // {} | keys:["elevated","high","reason"]' \ - 'dependency_currency:.dependency_currency // {} | keys:["max_behind","stale_after","obsolescence_may_be_accepted","reason"]' \ + 'dependency_currency:.dependency_currency // {} | keys:["max_behind","stale_after","stale_exempt_publishers","obsolescence_may_be_accepted","reason"]' \ 'dependency_currency.max_behind:.dependency_currency.max_behind // {} | keys:["major","minor","patch"]' \ 'alerts:.alerts // {} | keys:["threshold","slack_channel"]' \ 'breach:.breach // {} | keys:["decision_within","risk_acceptance_approvers"]' \ @@ -216,6 +216,24 @@ for lvl in major minor patch; do fi done +# --- currency: an added staleness exemption is a widening --------------------- +# Adding a publisher here stops a class of findings from asking for a decision, so it goes +# the same way as any other relaxation: allowed, but only with a reason on record. +if jq -e '.dependency_currency | has("stale_exempt_publishers")' <<<"$P" >/dev/null 2>&1; then + if ! jq -e '.dependency_currency.stale_exempt_publishers | type == "array"' <<<"$P" >/dev/null 2>&1; then + err "$POLICY: dependency_currency.stale_exempt_publishers must be a list of publisher identities" + else + ADDED=$(jq -r --argjson d "$(jq -c '.dependency_currency.stale_exempt_publishers // []' <<<"$D")" \ + '[.dependency_currency.stale_exempt_publishers[]] - $d | join(", ")' <<<"$P") + if [[ -n "$ADDED" ]]; then + reason=$(jq -r '.dependency_currency.reason // ""' <<<"$P") + [[ -n "$reason" ]] \ + && warn "$POLICY: staleness exemption extended to $ADDED — \"$reason\"" \ + || err "$POLICY: dependency_currency.stale_exempt_publishers adds $ADDED beyond the process default. An exemption means those components never ask for a decision. Add 'reason:' under dependency_currency, or remove them." + fi + fi +fi + # --- alerts ------------------------------------------------------------------ CH=$(jq -r '.alerts.slack_channel // ""' <<<"$P") [[ -n "$CH" ]] || warn "$POLICY: no alerts.slack_channel — runs will still write their record, but nobody is notified" diff --git a/soup-discovery/tests/run-tests.sh b/soup-discovery/tests/run-tests.sh index df6f27d..9858ad0 100755 --- a/soup-discovery/tests/run-tests.sh +++ b/soup-discovery/tests/run-tests.sh @@ -1608,6 +1608,38 @@ test_units_no_published_fix_is_not_an_upgrade() { assert "$(jq -r '.units[0].findings_without_published_fix' "$TMP/ru.json")" "1" } +# Regression: the action name dropped the npm scope, so @nestjs/core, @sigstore/core and +# @strapi/core all produced actions titled "upgrade core" — and since the unit key is built +# from that name, two of them inside one artifact merged into a single action. Seen on +# curacoach v1.0.0-qa10, which listed three separate "upgrade core" rows. +test_units_scoped_packages_do_not_collide() { + jq -n '{bomFormat:"CycloneDX",specVersion:"1.6", + metadata:{component:{name:"p","bom-ref":"p",type:"application"}}, + components:[{"bom-ref":"c1",type:"library",name:"@nestjs/core",version:"11.1.14", + purl:"pkg:npm/%40nestjs/core@11.1.14", + properties:[{name:"quickbird:component:artifact",value:"quickbird:artifact:web"}]}, + {"bom-ref":"c2",type:"library",name:"@sigstore/core",version:"2.0.0", + purl:"pkg:npm/%40sigstore/core@2.0.0", + properties:[{name:"quickbird:component:artifact",value:"quickbird:artifact:web"}]}], + vulnerabilities:[{id:"CVE-A",affects:[{ref:"c1"}],properties:[{name:"quickbird:vuln:fix",value:"available"}]}, + {id:"CVE-B",affects:[{ref:"c2"}],properties:[{name:"quickbird:vuln:fix",value:"available"}]}]}' \ + > "$TMP/sc-bom.json" + jq -n '{findings:[{id:"CVE-A",track:"planned",kev:false,remediation_due:"2026-11-03T00:00:00+00:00"}, + {id:"CVE-B",track:"planned",kev:false,remediation_due:"2026-11-03T00:00:00+00:00"}]}' \ + > "$TMP/sc-f.json" + python3 "$S/group-remediation.py" "$TMP/sc-f.json" "$TMP/sc-bom.json" --out "$TMP/sc.json" 2>/dev/null || return 1 + assert "$(jq -r '.units | length' "$TMP/sc.json")" "2" || return 1 + jq -re '[.units[].action] | sort == ["upgrade @nestjs/core in web","upgrade @sigstore/core in web"]' \ + "$TMP/sc.json" >/dev/null +} + +# The version separator is the last @, not the first: an unencoded scoped purl used to lose +# everything after "pkg:npm/" and the action was titled "upgrade npm". +test_units_unencoded_scope_keeps_its_name() { + mkunits "quickbird:artifact:web" "pkg:npm/@nestjs/core@11.1.14" available planned || return 1 + assert "$(jq -r '.units[0].action' "$TMP/ru.json")" "upgrade @nestjs/core in web" +} + # Grouping must never move a deadline outward: the unit takes the earliest of its members. test_units_take_the_worst_track_and_earliest_deadline() { jq -n '{bomFormat:"CycloneDX",specVersion:"1.6", @@ -1850,6 +1882,58 @@ test_net_currency_against_real_registries() { assert "$(jq -r '.summary.beyond_policy' "$TMP/co.json")" "1" } +# The staleness exemption rests on one external contract: pub.dev serving a verified +# publisher at /api/packages//publisher. If that endpoint or its field name changes, +# every exemption quietly stops applying — the safe direction, but worth knowing about +# rather than discovering through a report that grew five findings. +test_net_pub_publisher_endpoint_still_answers() { + need_net || return 77 + python3 - "$S/check-currency.py" <<'PUBPY' +import importlib.util, sys +spec = importlib.util.spec_from_file_location("cc", sys.argv[1]) +cc = importlib.util.module_from_spec(spec); spec.loader.exec_module(cc) +assert cc.pub_publisher("collection") == "dart.dev", cc.pub_publisher("collection") +assert cc.pub_publisher("url_launcher") == "flutter.dev", cc.pub_publisher("url_launcher") +# a package with no verified publisher must come back None, not raise +cc.pub_publisher("this_package_does_not_exist_qb_test") +PUBPY +} + +# The deployment history was read live whatever --now said, so a report dated T could rest on +# a deployment made after T, and the same evidence gave different answers on different days. +# That is what made net_backstop_cadence_counts_production_releases_only go red on its own: +# alvie deployed on 2026-08-12 and a run frozen at 2026-08-02 counted it. +test_backstop_release_lookup_is_bounded_by_now() { + python3 - "$S/backstop-report.py" <<'BSPY' +import importlib.util, sys +from datetime import datetime, timezone +spec = importlib.util.spec_from_file_location("bs", sys.argv[1]) +bs = importlib.util.module_from_spec(spec); spec.loader.exec_module(bs) + +ROWS = [{"at": "2026-08-12T12:37:39Z", "env": "Production", "ref": "v1.0.9"}, + {"at": "2026-04-21T08:55:57Z", "env": "Production", "ref": "v1.0.7"}] + +def fake_gh(args, timeout=60): + return [{"name": "Production"}] if "environments" in args[0] else list(ROWS) + +bs._gh_json = fake_gh +cutoff = datetime(2026, 8, 2, 18, 0, tzinfo=timezone.utc) + +dates, basis, _ = bs.production_deploys("x/y", not_after=cutoff) +assert dates == ["2026-04-21T08:55:57Z"], dates +assert "excluded" in basis, basis + +# unbounded keeps the newer one, so the filter is the only thing that changed +dates, _, _ = bs.production_deploys("x/y") +assert dates[0] == "2026-08-12T12:37:39Z", dates + +# a bound that removes everything reports "no records", never a false "broken" +dates, basis, _ = bs.production_deploys("x/y", not_after=datetime(2020, 1, 1, tzinfo=timezone.utc)) +assert dates == [], dates +assert "excluded" in basis, basis +BSPY +} + # ---------------------------------------------------------------- backstop mkrun() { jq -n --arg p "$1" --arg at "${2}T09:00:00+00:00" --arg v "$3" --argjson syn "${4:-false}" \ '{schema:"quickbird.kev-monitor-run/v1",product:$p,run_at:$at,verdict:$v,synthetic:$syn}' \ @@ -2808,6 +2892,94 @@ assert over, "5 patches behind a limit of 1 must be over" PYEOF } +# A verified publisher answers staleness for a whole class of components. package:collection +# and url_launcher are pinned by the Dart SDK constraint and can never look current against a +# 12-month window, so without this every Flutter product records the same reason at every +# release. Keyed on the pub.dev verified publisher, never on a free-text author. +# +run_currency_pub() { + jq -n --arg p "$1" --arg v "$2" --arg n "$6" \ + '{bomFormat:"CycloneDX",specVersion:"1.6",components:[ + {"bom-ref":"a",type:"library",name:$n,version:$v,purl:$p, + properties:[{name:"quickbird:dependency:scope",value:"direct"}]}]}' > "$TMP/pubc-bom.json" + CC_LATEST="$3" CC_PUBLISHED="$4" CC_PUBLISHER="$5" \ + python3 - "$S/check-currency.py" "$TMP/pubc-bom.json" "$TMP/cp.json" "$TMP/pubc-out.json" <<'CURPY' +import importlib.util, os, sys +spec = importlib.util.spec_from_file_location("cc", sys.argv[1]) +cc = importlib.util.module_from_spec(spec); spec.loader.exec_module(cc) +bom, pol, out = sys.argv[2], sys.argv[3], sys.argv[4] +who = os.environ.get("CC_PUBLISHER", "-") +latest, published = os.environ["CC_LATEST"], os.environ["CC_PUBLISHED"] + +def fake_fetch(url, timeout=20, accept=None): + if url.endswith("/publisher"): + if who == "-": + raise KeyError("no verified publisher") + return {"publisherId": who} + if "pub.dev" in url: + return {"latest": {"version": latest, "published": published}} + # npm: `author` is free text, which is the point of the negative test + return {"dist-tags": {"latest": latest}, "time": {latest: published}, + "author": ("" if who == "-" else who)} + +cc.fetch = fake_fetch +sys.argv = ["check-currency.py", bom, pol, "--out", out, "--annotate-bom", bom, + "--now", "2026-08-28T00:00:00Z", "--jobs", "1"] +raise SystemExit(cc.main()) +CURPY +} + +bomprop() { jq -r --arg n "$1" '.components[0].properties[]|select(.name==$n)|.value' "$TMP/pubc-bom.json"; } + +test_currency_verified_publisher_exempts_staleness() { + run_currency_pub "pkg:pub/collection@1.19.1?hosted_url=pub.dev" 1.19.1 1.19.1 "2024-10-22T00:00:00Z" dart.dev collection \ + >/dev/null 2>&1 || return 1 + assert "$(jq -r '.summary.stale_exempt_by_publisher' "$TMP/pubc-out.json")" "1" || return 1 + assert "$(jq -r '.summary.stale' "$TMP/pubc-out.json")" "0" || return 1 + assert "$(jq -r '.justified[0].exempt_publisher' "$TMP/pubc-out.json")" "dart.dev" || return 1 + # the staleness itself stays in the bundle, so the report still lists the row + assert "$(bomprop quickbird:currency:status)" "stale" || return 1 + contains "$(bomprop quickbird:currency:stale-exempt)" "dart.dev" +} + +# The exemption covers staleness and nothing else: a platform package that is two majors +# behind still owes the upgrade. +test_currency_publisher_exemption_does_not_cover_being_behind() { + run_currency_pub "pkg:pub/url_launcher@6.3.2?hosted_url=pub.dev" 6.3.2 8.0.0 "2025-07-11T00:00:00Z" flutter.dev url_launcher \ + >/dev/null 2>&1 || return 1 + assert "$(jq -r '.summary.beyond_policy' "$TMP/pubc-out.json")" "1" || return 1 + assert "$(jq -r '.summary.stale_exempt_by_publisher' "$TMP/pubc-out.json")" "0" || return 1 + assert "$(jq -r '.beyond_policy[0].finding' "$TMP/pubc-out.json")" "upstream-stale-and-we-are-behind" +} + +# google.dev is deliberately not on the default list: it publishes a grab-bag rather than the +# SDK, and visibility_detector (last release 2023) is exactly the finding a product should +# still have to answer. +test_currency_unlisted_publisher_still_needs_a_decision() { + run_currency_pub "pkg:pub/visibility_detector@0.4.0?hosted_url=pub.dev" 0.4.0 0.4.0 "2023-03-09T00:00:00Z" google.dev visibility_detector \ + >/dev/null 2>&1 || return 1 + assert "$(jq -r '.summary.stale' "$TMP/pubc-out.json")" "1" || return 1 + assert "$(jq -r '.summary.stale_exempt_by_publisher' "$TMP/pubc-out.json")" "0" || return 1 + assert "$(bomprop quickbird:currency:stale-exempt)" "" +} + +# npm has no verified publisher — `author` is free text set by whoever publishes — so an npm +# package can never earn the exemption, however its author field reads. +test_currency_npm_author_cannot_earn_an_exemption() { + run_currency_pub "pkg:npm/tslib@2.8.1" 2.8.1 2.8.1 "2024-11-01T00:00:00Z" dart.dev tslib \ + >/dev/null 2>&1 || return 1 + assert "$(jq -r '.summary.stale' "$TMP/pubc-out.json")" "1" || return 1 + assert "$(jq -r '.summary.stale_exempt_by_publisher' "$TMP/pubc-out.json")" "0" +} + +# A publisher lookup that fails must not silence a finding. +test_currency_unreachable_publisher_lookup_does_not_exempt() { + run_currency_pub "pkg:pub/collection@1.19.1?hosted_url=pub.dev" 1.19.1 1.19.1 "2024-10-22T00:00:00Z" - collection \ + >/dev/null 2>&1 || return 1 + assert "$(jq -r '.summary.stale' "$TMP/pubc-out.json")" "1" || return 1 + assert "$(jq -r '.summary.stale_exempt_by_publisher' "$TMP/pubc-out.json")" "0" +} + # Regression: unit kev membership compared a string to True and was always empty. test_units_list_their_kev_members() { cat > "$TMP/kf.json" <<'EOF' @@ -2946,6 +3118,108 @@ EOF assert "$?" "1" } +# A fixed version that exists only as a prerelease is not a fix a released product can +# apply. Reported as "available" it produced the action "upgrade multer to 3.0.0-alpha.2" — +# on an expedited track, with a deadline nobody could meet by doing the thing it named. +# Seen on curacoach v1.0.0-qa10 for multer and @babel/core. +fake_osv() { + # — serves querybatch and the per-advisory fetch from a stub curl. + mkdir -p "$TMP/osvbin" + printf '%s' "$1" > "$TMP/osv-adv.json" + cat > "$TMP/osvbin/curl" <<'FAKE' +#!/usr/bin/env bash +out=""; url=""; prev="" +for a in "$@"; do + [[ "$prev" == "-o" ]] && out="$a" + case "$a" in http*) url="$a" ;; esac + prev="$a" +done +case "$url" in + *querybatch*) body='{"results":[{"vulns":[{"id":"OSV-PRE"}]}]}' ;; + */vulns/OSV-PRE) body=$(cat "$FAKE_ADV") ;; + *) body='{}' ;; +esac +if [[ -n "$out" ]]; then printf '%s' "$body" > "$out"; else printf '%s' "$body"; fi +FAKE + chmod +x "$TMP/osvbin/curl" +} + +run_scan() { # -> $TMP/sv-pre.json + jq -n --arg p "$1" '{bomFormat:"CycloneDX",specVersion:"1.6", + components:[{"bom-ref":"a",type:"library",name:"lib",version:"1.0.0",purl:$p}]}' \ + > "$TMP/sv-pre-bom.json" + FAKE_ADV="$TMP/osv-adv.json" PATH="$TMP/osvbin:$PATH" \ + bash "$S/scan-vulns.sh" "$TMP/sv-pre-bom.json" "$TMP/sv-pre.json" >/dev/null 2>&1 +} + +fixprop() { jq -r --arg n "$1" '.vulnerabilities[0].properties[]|select(.name==$n)|.value' "$TMP/sv-pre.json"; } + +test_scan_vulns_prerelease_only_fix_is_not_available() { + fake_osv '{"id":"OSV-PRE","aliases":["CVE-2026-2359"],"summary":"s", + "severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected":[{"package":{"name":"multer","ecosystem":"npm"}, + "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.0.0-alpha.2"}]}]}]}' + run_scan "pkg:npm/multer@2.0.2" || return 1 + assert "$(fixprop quickbird:vuln:fix)" "prerelease-only" || return 1 + assert "$(fixprop quickbird:vuln:fix-versions)" "3.0.0-alpha.2" || return 1 + contains "$(fixprop quickbird:vuln:fix-note)" "cannot adopt it" +} + +# A stable fix anywhere in the advisory is still the answer — the prerelease branch must not +# withdraw an upgrade that exists. +test_scan_vulns_stable_fix_wins_over_prerelease() { + fake_osv '{"id":"OSV-PRE","aliases":["CVE-2026-0001"],"summary":"s", + "severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected":[{"package":{"name":"lodash","ecosystem":"npm","purl":"pkg:npm/lodash"}, + "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"5.0.0-rc.1"},{"fixed":"4.18.0"}]}]}]}' + run_scan "pkg:npm/lodash@4.17.23" || return 1 + assert "$(fixprop quickbird:vuln:fix)" "available" || return 1 + assert "$(fixprop quickbird:vuln:fix-versions)" "4.18.0" +} + +# A hyphen is not a prerelease marker. Maven ships 31.1-jre and Debian 2.36-9 as ordinary +# releases; treating those as prereleases would withdraw a real upgrade. +test_scan_vulns_hyphenated_release_is_not_a_prerelease() { + fake_osv '{"id":"OSV-PRE","aliases":["CVE-2026-0002"],"summary":"s", + "severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected":[{"package":{"name":"com.google.guava:guava","ecosystem":"Maven"}, + "ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"31.1-jre"}]}]}]}' + run_scan "pkg:maven/com.google.guava/guava@30.0-jre" || return 1 + assert "$(fixprop quickbird:vuln:fix)" "available" || return 1 + assert "$(fixprop quickbird:vuln:fix-versions)" "31.1-jre" +} + +# An OS package revision is not a prerelease either. +test_scan_vulns_os_package_revision_is_not_a_prerelease() { + fake_osv '{"id":"OSV-PRE","aliases":["CVE-2026-0003"],"summary":"s", + "severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected":[{"package":{"name":"glibc","ecosystem":"Debian:12"}, + "ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.36-9"}]}]}]}' + run_scan "pkg:deb/debian/glibc@2.36-8?distro=debian-12" || return 1 + assert "$(fixprop quickbird:vuln:fix)" "available" +} + +# affected[].package.purl is optional in the OSV schema, and in jq ("" | split("@")) is [] +# rather than [""], so the index returned null and startswith(null) aborted the whole +# program — one advisory without a purl failed the entire scan, not just its own match. +test_scan_vulns_advisory_without_a_purl_does_not_abort_the_scan() { + fake_osv '{"id":"OSV-PRE","aliases":["CVE-2026-0004"],"summary":"s", + "severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected":[{"package":{"name":"lib","ecosystem":"npm"}, + "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}]}]}' + run_scan "pkg:npm/lib@1.0.0" || return 1 + assert "$(fixprop quickbird:vuln:fix)" "available" +} + +# The unit for a prerelease-only fix is a tracking item, not an upgrade, and it is keyed per +# package: two packages waiting on two unrelated stable releases are two pieces of work. +test_units_prerelease_only_is_not_an_upgrade() { + mkunits "quickbird:artifact:web" "pkg:npm/multer@2.0.2" prerelease-only expedited || return 1 + assert "$(jq -r '.units[0].kind' "$TMP/ru.json")" "no-stable-upgrade-path" || return 1 + assert "$(jq -r '.units[0].findings_without_stable_fix' "$TMP/ru.json")" "1" || return 1 + jq -re '.units[0].action | test("no stable upgrade for multer in web")' "$TMP/ru.json" >/dev/null +} + # ------------------------------------------------- the two report renderers need_reportlab() { python3 -c 'import reportlab' 2>/dev/null || { echo "reportlab not installed"; return 77; }; } @@ -2984,6 +3258,38 @@ render_fixture() { EOF } +# A component with a stable fix for one CVE and only a prerelease for another must show the +# stable one. Pooling every fix-version in the group and taking the last printed whichever +# sorted highest, which is how an alpha reaches the column as the answer. +test_render_mixed_fix_states_shows_the_stable_version() { + need_reportlab || return 77 + command -v pdftotext >/dev/null 2>&1 || { echo "pdftotext not installed"; return 77; } + cat > "$TMP/mx-bundle.json" <<'EOF' +{"bomFormat":"CycloneDX","specVersion":"1.6", + "metadata":{"component":{"bom-ref":"root","type":"application","name":"prod","version":"v1.0.0"}}, + "components":[ + {"bom-ref":"a","type":"library","name":"mixed","version":"1.0.0","purl":"pkg:npm/mixed@1.0.0", + "properties":[{"name":"quickbird:dependency:scope","value":"direct"}]}], + "vulnerabilities":[ + {"id":"CVE-A","affects":[{"ref":"a"}], + "properties":[{"name":"quickbird:finding:track","value":"planned"}, + {"name":"quickbird:finding:cvss","value":"5.0"}, + {"name":"quickbird:vuln:fix","value":"available"}, + {"name":"quickbird:vuln:fix-versions","value":"2.0.0"}]}, + {"id":"CVE-B","affects":[{"ref":"a"}], + "properties":[{"name":"quickbird:finding:track","value":"planned"}, + {"name":"quickbird:finding:cvss","value":"5.0"}, + {"name":"quickbird:vuln:fix","value":"prerelease-only"}, + {"name":"quickbird:vuln:fix-versions","value":"3.0.0-alpha.1"}]}]} +EOF + python3 "$S/render-vdr-pdf.py" "$TMP/mx-bundle.json" "$TMP/mx.pdf" \ + --policy "$TMP/cp.json" --date 2026-08-28 >/dev/null 2>&1 || return 1 + local txt; txt=$(pdftotext -layout "$TMP/mx.pdf" - 2>/dev/null) + contains "$txt" "2.0.0" || return 1 + grep -q "3.0.0-alpha.1" <<<"$txt" && { echo "the prerelease was printed as the fix"; return 1; } + return 0 +} + test_render_sbom_report() { need_reportlab || return 77 render_fixture @@ -2999,6 +3305,25 @@ test_render_vdr_report() { [[ -s "$TMP/rf-vdr.pdf" ]] } +# String order puts 8.0.5 above 8.0.16, and Fixed-in printed whichever sorted last — a +# version below the one carrying the fix. vite in the curacoach qa10 report is the real case. +test_render_fix_version_order_is_numeric() { + need_reportlab || return 77 + S="$S" python3 - <<'PYEOF' +import importlib.util, os +spec = importlib.util.spec_from_file_location("r", os.environ["S"] + "/render-vdr-pdf.py") +m = importlib.util.module_from_spec(spec) +try: + spec.loader.exec_module(m) +except SystemExit: + pass +k = m.version_key +assert sorted(["6.4.2", "7.3.5", "8.0.16", "8.0.5"], key=k)[-1] == "8.0.16" +assert sorted(["2.9.0", "2.10.0"], key=k)[-1] == "2.10.0" +assert sorted(["31.0.1-jre", "31.1-jre"], key=k)[-1] == "31.1-jre" +PYEOF +} + # ---------------------------------------------------------------- network test_net_enrichment_against_live_feeds() { need_net || return 77