diff --git a/soup-discovery/scripts/render-vdr-pdf.py b/soup-discovery/scripts/render-vdr-pdf.py
index 30f3031..75f5e56 100644
--- a/soup-discovery/scripts/render-vdr-pdf.py
+++ b/soup-discovery/scripts/render-vdr-pdf.py
@@ -97,6 +97,42 @@ def version_key(v):
for part in re.split(r"[._+\-]", str(v)) if part]
+def artifacts_of(p):
+ """The artefacts a component was found in, without the property prefix."""
+ raw = p.get("quickbird:component:artifact", "") or ""
+ return [x.strip().replace("quickbird:artifact:", "")
+ for x in raw.split(",") if x.strip()]
+
+
+def one_row_per_library(entries):
+ """Collapse the copies of one library into a single row, merging their artefacts.
+
+ Since a Dockerfile candidate is scanned as the image it produces, the same library is
+ found once per image that contains it and once in the lockfile that declares it. The
+ currency note is stamped by purl, so every copy carries it and the table listed the same
+ library three times — three rows for one decision, and a headline count to match.
+ """
+ out, seen = [], {}
+ for c, p in entries:
+ key = c.get("purl") or (c.get("name"), c.get("version"))
+ if key in seen:
+ for a in artifacts_of(p):
+ if a not in seen[key][2]:
+ seen[key][2].append(a)
+ continue
+ seen[key] = [c, p, artifacts_of(p)]
+ out.append(seen[key])
+ return [(c, p, arts) for c, p, arts in out]
+
+
+def lib_cell(c, arts, style, small_style):
+ """Library name with the artefacts it sits in underneath."""
+ txt = esc(c.get("name"))
+ if arts:
+ txt += (f'
in {esc(", ".join(sorted(arts)))}')
+ return Paragraph(txt, style)
+
+
def table(data, widths, shade=None):
"""shade: list of row indices -> colour"""
style = [
@@ -286,13 +322,13 @@ def clocks(t):
st = p.get("quickbird:currency:status")
if st:
cur.append((c, p, st))
- beyond = [(c, p) for c, p, st in cur if st in ("behind", "stale-and-behind")]
- 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"]
+ beyond = one_row_per_library([(c, p) for c, p, st in cur if st in ("behind", "stale-and-behind")])
+ within = one_row_per_library([(c, p) for c, p, st in cur if st == "update-available"])
+ stale = one_row_per_library([(c, p) for c, p, st in cur if st == "stale"])
+ deprecated = one_row_per_library([(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")]
+ stale_exempt = [e for e in stale if e[1].get("quickbird:currency:stale-exempt")]
by_comp = {}
for v in vulns:
@@ -369,10 +405,10 @@ def cvss_of(v):
# ---- 2 updates available -----------------------------------------------------
el.append(Paragraph("2 Updates available", h2))
upd_groups = {}
- for c, p in beyond:
- upd_groups.setdefault(group_of(c), {"beyond": [], "within": []})["beyond"].append((c, p))
- for c, p in within:
- upd_groups.setdefault(group_of(c), {"beyond": [], "within": []})["within"].append((c, p))
+ for c, p, a in beyond:
+ upd_groups.setdefault(group_of(c), {"beyond": [], "within": []})["beyond"].append((c, p, a))
+ for c, p, a in within:
+ upd_groups.setdefault(group_of(c), {"beyond": [], "within": []})["within"].append((c, p, a))
if not upd_groups:
el.append(Paragraph("none", small))
sec = 0
@@ -386,24 +422,25 @@ def cvss_of(v):
f"{len(g['within'])} within", h3))
rows = [["Library", "Installed", "Latest", "Detail", "Status"]]
shade = {}
- for c, p in sorted(g["beyond"], key=lambda cp: (cp[0].get("name") or "").lower()):
+ for c, p, arts in sorted(g["beyond"], key=lambda cp: (cp[0].get("name") or "").lower()):
shade[len(rows)] = SEV2
rows.append([
- Paragraph(esc(c.get("name")), cell),
+ lib_cell(c, arts, cell, small),
Paragraph(esc(c.get("version")), cell),
Paragraph(f"{esc(p.get('quickbird:currency:latest', '?'))}", cell),
Paragraph(esc(p.get("quickbird:currency:detail", "beyond limit")), small),
Paragraph("No decision recorded.", cell),
])
- for c, p in sorted(g["within"], key=lambda cp: (cp[0].get("name") or "").lower()):
+ for c, p, arts in sorted(g["within"], key=lambda cp: (cp[0].get("name") or "").lower()):
rows.append([
- Paragraph(esc(c.get("name")), cell),
+ lib_cell(c, arts, cell, small),
Paragraph(esc(c.get("version")), cell),
Paragraph(esc(p.get("quickbird:currency:latest", "?")), cell),
Paragraph("within limits", small),
Paragraph("no decision required", small),
])
- el.append(table(rows, [44 * mm, 22 * mm, 22 * mm, 44 * mm, 38 * mm], shade))
+ # Library carries the artefact list underneath the name now. Total unchanged at 170mm.
+ el.append(table(rows, [56 * mm, 18 * mm, 18 * mm, 38 * mm, 40 * mm], shade))
# ---- 3 libraries with CVEs -----------------------------------------------------
el.append(Paragraph("3 Libraries with CVEs", h2))
@@ -516,10 +553,10 @@ def versions_in(state):
if not deprecated:
el.append(Paragraph("Declared deprecated / unmaintained: none detected.", small))
stale_groups = {}
- for c, p in deprecated:
- stale_groups.setdefault(group_of(c), {"dep": [], "stale": []})["dep"].append((c, p))
- for c, p in stale:
- stale_groups.setdefault(group_of(c), {"dep": [], "stale": []})["stale"].append((c, p))
+ for c, p, a in deprecated:
+ stale_groups.setdefault(group_of(c), {"dep": [], "stale": []})["dep"].append((c, p, a))
+ for c, p, a in stale:
+ stale_groups.setdefault(group_of(c), {"dep": [], "stale": []})["stale"].append((c, p, a))
if not stale_groups:
el.append(Paragraph("No library on its latest version has exceeded the staleness "
"window.", small))
@@ -533,22 +570,22 @@ def versions_in(state):
h3))
rows = [["Library", "Installed = latest", "Detail", "Registry status", "Status"]]
shade = {}
- for c, p in sorted(g["dep"], key=lambda cp: (cp[0].get("name") or "").lower()):
+ for c, p, arts in sorted(g["dep"], key=lambda cp: (cp[0].get("name") or "").lower()):
shade[len(rows)] = SEV2
rows.append([
- Paragraph(esc(c.get("name")), cell),
+ lib_cell(c, arts, cell, small),
Paragraph(esc(c.get("version")), cell),
Paragraph(esc(p.get("quickbird:currency:detail", "")), small),
Paragraph('deprecated', cell),
Paragraph("No decision recorded.", cell),
])
- for c, p in sorted(g["stale"], key=lambda cp: (cp[0].get("name") or "").lower()):
+ for c, p, arts in sorted(g["stale"], key=lambda cp: (cp[0].get("name") or "").lower()):
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),
+ lib_cell(c, arts, cell, small),
Paragraph(esc(c.get("version")), cell),
Paragraph(esc(p.get("quickbird:currency:detail", "")), small),
Paragraph(f"verified publisher {esc(who)}" if who else "active flag not set",
@@ -558,7 +595,7 @@ def versions_in(state):
# 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))
+ el.append(table(rows, [52 * mm, 23 * mm, 31 * mm, 26 * mm, 38 * mm], shade))
# ---- 5 remediation actions ----------------------------------------------------
el.append(Paragraph("5 Remediation actions", h2))
diff --git a/soup-discovery/tests/run-tests.sh b/soup-discovery/tests/run-tests.sh
index 58321a3..76e5cf8 100755
--- a/soup-discovery/tests/run-tests.sh
+++ b/soup-discovery/tests/run-tests.sh
@@ -3421,6 +3421,34 @@ test_render_vdr_report() {
# 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.
+# A Dockerfile candidate is scanned as the image it produces, so a library is found once per
+# image that contains it and once in the lockfile that declares it. The currency note is stamped
+# by purl, so every copy carried it: three rows for one decision, and a headline count to match.
+test_render_one_row_per_library_across_artefacts() {
+ 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
+def comp(art):
+ return ({"name": "lib", "version": "1.0.0", "purl": "pkg:npm/lib@1.0.0"},
+ {"quickbird:component:artifact": art})
+entries = [comp("quickbird:artifact:web"),
+ comp("quickbird:artifact:web-dockerfiles-Dockerfile.node-final"),
+ comp("quickbird:artifact:web")]
+out = m.one_row_per_library(entries)
+assert len(out) == 1, out
+assert sorted(out[0][2]) == ["web", "web-dockerfiles-Dockerfile.node-final"], out[0][2]
+# a different version is a different library and keeps its own row
+other = ({"name": "lib", "version": "2.0.0", "purl": "pkg:npm/lib@2.0.0"}, {})
+assert len(m.one_row_per_library(entries + [other])) == 2
+PYEOF
+}
+
test_render_fix_version_order_is_numeric() {
need_reportlab || return 77
S="$S" python3 - <<'PYEOF'