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