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'