From 7a7d4085964edfb6d6e74b98c35c966a3ba11f04 Mon Sep 17 00:00:00 2001 From: Stefan Kofler Date: Fri, 28 Aug 2026 17:47:33 +0200 Subject: [PATCH] One row per library, naming the artefacts it sits in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since a Dockerfile candidate is scanned as the image it produces, 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 and sections 2 and 4 listed the same library up to three times — three rows for one decision, and headline counts to match: one report showed 38 libraries beyond the update limit where there were 19. Rows are collapsed by purl and the artefacts merged, printed under the library name so it stays visible where each one sits. The bundle keeps the copies: CycloneDX carries a component per occurrence and the artefact assignment is used elsewhere. Column widths rebalanced within the same 170mm to fit the artefact list. --- soup-discovery/scripts/render-vdr-pdf.py | 83 +++++++++++++++++------- soup-discovery/tests/run-tests.sh | 28 ++++++++ 2 files changed, 88 insertions(+), 23 deletions(-) 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'