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