Add toc and include all findings in the report

This commit is contained in:
alexander committed 2026-09-24 20:33:00 +02:00
1 parent f775e1f1ed
commit 2e2ab63232
6 files changed
+265 -142

No files matched your search

+14
View File
@@ -36,6 +36,20 @@ report_generator from-folder --artifacts ./sample [--path <file-or-directory>]
`metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one `metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one
whose metadata is gone. whose metadata is gone.
The inputs are one SARIF file (`merged.sarif`, with one run per tool) and the vulnerability report
(`vdr.json`); either is enough on its own. `metadata.json` is optional, and without it the scan and
project pages are left out. The report has these parts:
1. the cover and a table of contents,
2. a summary and the most critical issues across all sources,
3. one section per SARIF run, named after its tool (e.g. CipherCell SAST, CipherCell Lynkeus),
4. the dependency vulnerabilities from the VDR,
5. at the end, *False Positives and Suppressed Findings*: every result Polygraph classified as a
false positive or that carries a SARIF suppression (such as the areas Lynkeus ruled out), with
the reason.
Every finding is listed; only the summary charts and the most-critical table are capped.
The two produce the same document. Everything after the artifacts are in hand is shared code, and The two produce the same document. Everything after the artifacts are in hand is shared code, and
the sample export in `sample/` was checked against the report built from the store for the same the sample export in `sample/` was checked against the report built from the store for the same
scan: 17 of 17 pages identical. The tests cover the folder path; comparing the two needs a store scan: 17 of 17 pages identical. The tests cover the folder path; comparing the two needs a store
+2
View File
@@ -0,0 +1,2 @@
[tools]
python = "3.14"
+196 -125
View File
@@ -17,6 +17,8 @@ inside the web application that happens to import them today.
from __future__ import annotations from __future__ import annotations
import re
from collections import Counter
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -32,10 +34,8 @@ if TYPE_CHECKING:
# What stands in for a field the stored artifacts do not answer. # What stands in for a field the stored artifacts do not answer.
UNKNOWN = "<xxx>" UNKNOWN = "<xxx>"
# How many of each list the report carries. A report is read, not audited; the full set is in the # How many entries the summary lists carry. The sections themselves list every finding.
# artifacts it was built from.
MOST_CRITICAL = 6 MOST_CRITICAL = 6
CARDS_PER_GROUP = 8
BARS = 10 BARS = 10
# The severity a SARIF level is reported under. SARIF names four levels and the report bands five, # The severity a SARIF level is reported under. SARIF names four levels and the report bands five,
@@ -65,31 +65,62 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str
the template renders as a shorter report. the template renders as a shorter report.
""" """
metadata = documents.get("metadata") or {} metadata = documents.get("metadata") or {}
sast = summarize_sarif(documents["findings"]) if documents.get("findings") else None tools = _tools(documents.get("findings"))
sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None
context: dict[str, Any] = { context: dict[str, Any] = {
"cover": _cover(sources, metadata, sast, sca), "cover": _cover(sources, metadata, tools, sca),
"scan": _scan(sources, metadata), "summary": _summary(tools, sca, documents.get("metrics")),
"project": _project(metadata), "sections": [_tool_section(name, report) for name, report in tools],
"summary": _summary(sast, sca, documents.get("metrics")),
"sections": [],
} }
critical = _critical(sast, sca) # Without the pipeline's metadata these two pages would be nothing but gaps.
if metadata:
context["scan"] = _scan(sources, metadata)
context["project"] = _project(metadata)
critical = _critical(tools, sca)
if critical["table"]["rows"]: if critical["table"]["rows"]:
context["critical"] = critical context["critical"] = critical
if sast is not None:
context["sections"].append(_sast_section(sast))
if sca is not None: if sca is not None:
context["sections"].append(_sca_section(sca)) context["sections"].append(_sca_section(sca))
set_aside = _set_aside_section(tools)
if set_aside is not None:
context["sections"].append(set_aside)
context["contents"] = _contents(context)
return context return context
def _tools(sarif: Any) -> list[tuple[str, SarifReport]]: # noqa: ANN401
"""The SARIF document split by the tool that produced each run, in the order the runs appear."""
if not isinstance(sarif, dict):
return []
runs = [run for run in sarif.get("runs") or [] if isinstance(run, dict)]
return [(_tool_name(run), summarize_sarif({"runs": [run]})) for run in runs]
def _tool_name(run: dict[str, Any]) -> str:
"""What the tool that produced a run calls itself."""
driver = (run.get("tool") or {}).get("driver") or {}
return str(driver.get("name") or driver.get("fullName") or UNKNOWN)
def _contents(context: dict[str, Any]) -> list[dict[str, str]]:
"""The table of contents: every top-level part of the report that is actually in it."""
parts = [context[key] for key in ("scan", "project", "summary", "critical") if key in context]
return [{"id": part["id"], "title": part["title"]} for part in [*parts, *context["sections"]]]
# --- the page ------------------------------------------------------------------------------ # --- the page ------------------------------------------------------------------------------
@@ -118,12 +149,12 @@ def _stated(value: Any) -> str: # noqa: ANN401
def _cover( def _cover(
sources: ReportSources, sources: ReportSources,
metadata: dict[str, Any], metadata: dict[str, Any],
sast: SarifReport | None, tools: list[tuple[str, SarifReport]],
sca: VdrReport | None, sca: VdrReport | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""The first page.""" """The first page."""
configuration = metadata.get("configuration") or {} configuration = metadata.get("configuration") or {}
technologies = [name for name, present in (("SAST", sast is not None), ("SCA", sca is not None)) if present] technologies = [name for name, _ in tools] + (["SCA"] if sca is not None else [])
return { return {
"scan_id": str(sources.scan_id), "scan_id": str(sources.scan_id),
@@ -143,19 +174,20 @@ def _cover(
_fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")),
_fact("Manufacturer", None), _fact("Manufacturer", None),
], ],
"severity_ledger": _severity_ledger(sast, sca), "severity_ledger": _severity_ledger(tools, sca),
} }
def _severity_ledger(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any] | None: def _severity_ledger(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any] | None:
"""How many issues of each severity survived filtering, over both halves of the pipeline.""" """How many issues of each severity survived filtering, over every tool and the dependencies."""
counts = dict.fromkeys(SEVERITY_ORDER, 0) counts = dict.fromkeys(SEVERITY_ORDER, 0)
for finding in _true_findings(sast): for _, report in tools:
counts[SEVERITY_OF_LEVEL.get(finding.level, "Low")] += 1 for finding in _kept(report):
counts[_finding_severity(finding)] += 1
for vulnerability in sca.vulnerabilities if sca else []: for vulnerability in sca.vulnerabilities if sca else []:
counts[_vdr_severity(vulnerability)] = counts.get(_vdr_severity(vulnerability), 0) + 1 counts[_vdr_severity(vulnerability)] += 1
if not any(counts.values()): if not any(counts.values()):
return None return None
@@ -174,6 +206,7 @@ def _scan(sources: ReportSources, metadata: dict[str, Any]) -> dict[str, Any]:
failed = [stage["label"] for stage in stages if stage.get("status") not in ("success", None)] failed = [stage["label"] for stage in stages if stage.get("status") not in ("success", None)]
return { return {
"id": "scan",
"title": "CipherCell Scan Information", "title": "CipherCell Scan Information",
"overview": [ "overview": [
_fact("Scan started at", _moment(metadata.get("pipeline_start_iso"))), _fact("Scan started at", _moment(metadata.get("pipeline_start_iso"))),
@@ -212,6 +245,7 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
configuration = metadata.get("configuration") or {} configuration = metadata.get("configuration") or {}
return { return {
"id": "project",
"title": "Project Information", "title": "Project Information",
"overview": [ "overview": [
_fact("Project", configuration.get("repository_name")), _fact("Project", configuration.get("repository_name")),
@@ -226,26 +260,29 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
} }
def _summary(sast: SarifReport | None, sca: VdrReport | None, metrics: dict[str, Any] | None) -> dict[str, Any]: def _summary(
tools: list[tuple[str, SarifReport]], sca: VdrReport | None, metrics: dict[str, Any] | None
) -> dict[str, Any]:
"""The counts, and where they concentrate.""" """The counts, and where they concentrate."""
true_sast = _true_findings(sast)
sca_total = sca.total if sca else 0 sca_total = sca.total if sca else 0
raw = (sast.total if sast else 0) + sca_total raw = sum(report.total for _, report in tools) + sca_total
filtered = sast.false_positive_count if sast else 0 true_issues = [f"{len(_kept(report))} {name}" for name, report in tools] + ([f"{sca_total} SCA"] if sca else [])
summary = { summary = {
"id": "summary",
"title": "Summary", "title": "Summary",
"overview": [ "overview": [
_fact("Raw findings", raw or None), _fact("Raw findings", raw or None),
# Only SAST findings carry a filtering verdict. A vulnerability suppressed in # Only SARIF findings can be set aside. A vulnerability suppressed in Dependency-Track
# Dependency-Track never reaches the report at all, so the two cannot be added up. # never reaches the report at all, so the two cannot be added up.
_fact("Filtered", f"{filtered} (SAST)" if sast else None), _fact(
_fact("True issues", f"{len(true_sast)} SAST, {sca_total} SCA" if sast or sca else None), "False positives / suppressed",
# Each half stated only where its artifact was there. A scan that ran no SCA stage ", ".join(f"{len(_set_aside(report))} {name}" for name, report in tools) or None,
# reports its SAST rules and says nothing about components, rather than nothing at all. ),
_fact("Distinct issue types (true)", ", ".join(_issue_types(sast, sca)) or None), _fact("True issues", ", ".join(true_issues) or None),
_fact("Distinct issue types (true)", ", ".join(_issue_types(tools, sca)) or None),
], ],
"distributions": _distributions(true_sast, sca), "distributions": _distributions(tools, sca),
} }
dependencies = metrics.get("dependencies") if isinstance(metrics, dict) else None dependencies = metrics.get("dependencies") if isinstance(metrics, dict) else None
@@ -340,12 +377,9 @@ def _remedy(value: dict[str, Any]) -> str | None:
return None return None
def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]: def _issue_types(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[str]:
"""How many distinct kinds of issue each half of the pipeline found.""" """How many distinct kinds of true issue each tool, and the dependency check, found."""
counted = [] counted = [f"{len({one.rule_id for one in _kept(report)})} {name} rules" for name, report in tools]
if sast is not None:
counted.append(f"{sast.rule_count} SAST rules")
if sca is not None: if sca is not None:
counted.append(f"{sca.component_count} affected components") counted.append(f"{sca.component_count} affected components")
@@ -353,14 +387,17 @@ def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
return counted return counted
def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[str, Any]]: def _distributions(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[dict[str, Any]]:
"""Where the issues concentrate: by rule for SAST, by package for SCA.""" """Where the issues concentrate: by rule for each SARIF tool, by package for SCA."""
blocks = [] blocks = []
for name, report in tools:
findings = _kept(report)
if findings: if findings:
blocks.append( blocks.append(
{ {
"title": f"True SAST issues by rule ({len(findings)})", "title": f"True {name} issues by rule ({len(findings)})",
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)), **_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
} }
) )
@@ -450,139 +487,152 @@ def _caveat(shown: list[Any], grouped: list[Any], largest: int) -> str:
return f"{len(shown)} of {len(grouped)} shown; {scale}" return f"{len(shown)} of {len(grouped)} shown; {scale}"
def _critical(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any]: def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any]:
"""The handful worth reading first, whichever half of the pipeline found them.""" """The handful worth reading first, whichever tool found them."""
rows: list[dict[str, Any]] = [] rows: list[tuple[str, dict[str, Any]]] = []
for finding in sorted(_true_findings(sast), key=lambda one: SEVERITY_ORDER.index(_finding_severity(one))): for name, report in tools:
for finding in _kept(report):
severity = _finding_severity(finding)
rows.append( rows.append(
(
severity,
{ {
"cells": [ "cells": [
"SAST", name,
{"value": _finding_severity(finding), "kind": "severity"}, {"value": severity, "kind": "severity"},
_stated(finding.rule_id), _stated(finding.rule_id),
{"value": _stated(_where(finding)), "kind": "mono"}, {"value": _stated(_where(finding)), "kind": "mono"},
_stated(finding.message), _stated(_text(finding.message)),
] ]
} },
)
) )
for vulnerability in sorted( for vulnerability in sca.vulnerabilities if sca else []:
sca.vulnerabilities if sca else [], severity = _vdr_severity(vulnerability)
key=lambda one: SEVERITY_ORDER.index(_vdr_severity(one)),
):
rows.append( rows.append(
(
severity,
{ {
"cells": [ "cells": [
"SCA", "SCA",
{"value": _vdr_severity(vulnerability), "kind": "severity"}, {"value": severity, "kind": "severity"},
_stated(vulnerability.id), _stated(vulnerability.id),
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"}, {"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
_stated(vulnerability.description), _stated(vulnerability.description),
] ]
} },
)
) )
# Sorted is stable, so within a severity each tool keeps its own order -- the rank, where it has one.
rows.sort(key=lambda pair: SEVERITY_ORDER.index(pair[0]))
return { return {
"id": "critical",
"title": "Most Critical Vulnerabilities", "title": "Most Critical Vulnerabilities",
"table": { "table": {
"columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"], "columns": ["Source", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
"rows": rows[:MOST_CRITICAL], "rows": [row for _, row in rows[:MOST_CRITICAL]],
}, },
} }
def _sast_section(sast: SarifReport) -> dict[str, Any]: def _tool_section(name: str, report: SarifReport) -> dict[str, Any]:
"""The SAST findings, split into what was kept and what was set aside.""" """Every finding of one tool that was not set aside."""
true_findings = _true_findings(sast) kept = _kept(report)
filtered = [one for one in sast.findings if one.false_positive] group: dict[str, Any] = {"title": f"True Issues ({len(kept)})", "cards": [_finding_card(one) for one in kept]}
groups: list[dict[str, Any]] = []
if true_findings: if not kept:
groups.append( group["note"] = f"Every finding {name} reported is listed under False Positives and Suppressed Findings."
{
"title": f"True Issues ({len(true_findings)})",
"cards": [_finding_card(one) for one in true_findings[:CARDS_PER_GROUP]],
}
)
if filtered: return {"id": _anchor(name), "title": name, "new_page": True, "groups": [group]}
groups.append(
def _set_aside_section(tools: list[tuple[str, SarifReport]]) -> dict[str, Any] | None:
"""The false positives and the suppressed findings of every tool, at the end of the report."""
grouped = [(name, _set_aside(report)) for name, report in tools]
grouped = [(name, findings) for name, findings in grouped if findings]
if not grouped:
return None
groups: list[dict[str, Any]] = [
{ {
"title": f"Filtered Issues ({len(filtered)})", "title": "Overview",
"note": ( "note": (
"The following findings were reported by the scanners but assessed as not exploitable in " "The following findings were reported but set aside: classified as false positives, or "
"this artifact. Each entry carries the verdict that filtered it." "suppressed by the tool that reported them. Each entry carries the reason it was set aside."
), ),
"cards": [_finding_card(one, filtered_out=True) for one in filtered[:CARDS_PER_GROUP]], "table": _set_aside_table(grouped),
"table": _filtered_table(filtered),
} }
) ]
groups += [
{"title": f"{name} ({len(findings)})", "cards": [_finding_card(one, set_aside=True) for one in findings]}
for name, findings in grouped
]
return {"title": "SAST Vulnerabilities", "groups": groups} return {
"id": "set-aside",
"title": "False Positives and Suppressed Findings",
"new_page": True,
"groups": groups,
}
def _finding_card(finding: Finding, *, filtered_out: bool = False) -> dict[str, Any]: def _finding_card(finding: Finding, *, set_aside: bool = False) -> dict[str, Any]:
"""One SARIF finding.""" """One SARIF finding."""
rows = [ rows = []
_row("Severity", _finding_severity(finding), kind="severity"),
_row("Location", _where(finding), kind="mono"), # A finding a tool ruled out carries no severity worth stating.
_row("Description", finding.message), if finding.level != "none":
rows.append(_row("Severity", _finding_severity(finding), kind="severity"))
# Not every finding points at a line: an area an agent ruled out names it in its message instead.
if finding.location is not None:
rows.append(_row("Location", _where(finding), kind="mono"))
rows += [
_row("Description", _text(finding.message)),
_row("Reported By", ", ".join(finding.tools) or None), _row("Reported By", ", ".join(finding.tools) or None),
] ]
if filtered_out: if set_aside:
rows.append( reason = finding.polygraph_reasoning or "; ".join(
_row( one.justification for one in finding.suppressions if one.justification
"Filtering Reason",
finding.polygraph_reasoning,
aside=True,
token=finding.polygraph_verdict,
) )
) rows.append(_row("Verdict", finding.verdict_label or "Suppressed"))
else: rows.append(_row("Reason", reason, aside=True))
# Polygraph's verdict where it looked at this one, and nothing where it did not: silence is elif finding.verdict_label:
# not a clean bill of health, and must not be written as one. rows.append(_row("Evidence", finding.verdict_label))
rows.append(_row("Evidence", finding.verdict_label or None))
return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows} return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows}
def _filtered_table(findings: list[Finding]) -> dict[str, Any]: def _set_aside_table(grouped: list[tuple[str, list[Finding]]]) -> dict[str, Any]:
"""How many of each rule were filtered, and on what grounds.""" """How many findings of each tool and rule were set aside."""
grouped: dict[str, list[Finding]] = {} rows: list[dict[str, Any]] = []
for finding in findings: for name, findings in grouped:
grouped.setdefault(finding.rule_id or UNKNOWN, []).append(finding) counts = Counter(one.rule_id or UNKNOWN for one in findings)
rows += [{"cells": [name, rule, count]} for rule, count in counts.most_common()]
rows: list[dict[str, Any]] = [ rows.append({"cells": ["Total", "", sum(len(findings) for _, findings in grouped)], "total": True})
{
"cells": [
rule,
len(group),
{"value": _stated(group[0].polygraph_verdict), "kind": "mono"},
]
}
for rule, group in sorted(grouped.items(), key=lambda pair: -len(pair[1]))
]
rows.append({"cells": ["Total", len(findings), ""], "total": True})
return { return {"columns": ["Tool", "Rule", "Set aside"], "rows": rows}
"title": f"Filtered SAST issues by rule ({len(findings)})",
"columns": ["Rule", "Filtered", "Dominant verdict"],
"rows": rows,
}
def _sca_section(sca: VdrReport) -> dict[str, Any]: def _sca_section(sca: VdrReport) -> dict[str, Any]:
"""The dependency findings.""" """The dependency findings."""
return { return {
"title": "SBOM / SCA Vulnerabilities", "id": "sca",
"title": "Dependency Vulnerabilities (SBOM / SCA)",
"new_page": True, "new_page": True,
"groups": [ "groups": [
{ {
"title": f"True Issues by CVEs ({sca.total})", "title": f"True Issues by CVEs ({sca.total})",
"cards": [_vulnerability_card(one) for one in sca.vulnerabilities[:CARDS_PER_GROUP]], "cards": [_vulnerability_card(one) for one in sca.vulnerabilities],
} }
], ],
} }
@@ -630,9 +680,29 @@ def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]:
# --- readings ------------------------------------------------------------------------------ # --- readings ------------------------------------------------------------------------------
def _true_findings(sast: SarifReport | None) -> list[Finding]: def _is_set_aside(finding: Finding) -> bool:
"""Whether a finding was classified as a false positive or suppressed by the tool."""
return finding.false_positive or bool(finding.suppressions)
def _kept(report: SarifReport) -> list[Finding]:
"""The findings that survived filtering.""" """The findings that survived filtering."""
return [one for one in sast.findings if not one.false_positive] if sast else [] return [one for one in report.findings if not _is_set_aside(one)]
def _set_aside(report: SarifReport) -> list[Finding]:
"""The findings that were filtered out or suppressed."""
return [one for one in report.findings if _is_set_aside(one)]
def _text(message: str) -> str:
"""A message on one line. Paragraph breaks -- a title above its description -- would otherwise run together."""
return re.sub(r"\s*\n\s*\n\s*", " — ", message.strip())
def _anchor(title: str) -> str:
"""An id the table of contents can link to."""
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "section"
def _finding_severity(finding: Finding) -> str: def _finding_severity(finding: Finding) -> str:
@@ -648,13 +718,14 @@ def _vdr_severity(vulnerability: Vulnerability) -> str:
def _where(finding: Finding) -> str | None: def _where(finding: Finding) -> str | None:
"""Where a finding points, as ``file:line``.""" """Where a finding points, as ``file:line``, every location of it."""
if finding.location is None: if finding.location is None:
return None return None
line = finding.location.start_line return ", ".join(
f"{one.file}:{one.start_line}" if one.start_line else one.file
return f"{finding.location.file}:{line}" if line else finding.location.file for one in [finding.location, *finding.extra_locations]
)
def _percent(share: float | None) -> str | None: def _percent(share: float | None) -> str | None:
+16 -12
View File
@@ -27,13 +27,19 @@ if TYPE_CHECKING:
# which stage produced it; the first one a scan carries wins. # which stage produced it; the first one a scan carries wins.
INPUTS = { INPUTS = {
"metadata": ("metadata.json", "meta.json"), "metadata": ("metadata.json", "meta.json"),
"findings": ("sast_scanner_ranked.sarif", "polygraph.sarif", "sast_scanner_combined.sarif", "prism.sarif"), "findings": (
"merged.sarif",
"sast_scanner_ranked.sarif",
"polygraph.sarif",
"sast_scanner_combined.sarif",
"prism.sarif",
),
"vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"), "vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"),
} }
# What a report cannot be built without. The rest is reported as far as it was found: a scan that # A report needs at least one of these to have anything to say. The rest is reported as far as it
# ran only half the pipeline is a shorter report, not a refusal. # was found: a scan that ran only half the pipeline is a shorter report, not a refusal.
REQUIRED = ("metadata",) REQUIRED_ANY = ("findings", "vulnerabilities")
# What the file is called. The scan it is about is in the name, because a directory of these is # What the file is called. The scan it is about is in the name, because a directory of these is
# otherwise unreadable. # otherwise unreadable.
@@ -50,17 +56,15 @@ def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tup
The file name the report should be saved under, and its bytes. The file name the report should be saved under, and its bytes.
Raises: Raises:
PipelineError: The scan is missing an input the report cannot be built PipelineError: The scan carries neither findings nor vulnerabilities, or
without, or one of its documents cannot be parsed. Both are reported one of its documents cannot be parsed. Both are reported by name: "no
by name: "no PDF was produced" leaves whoever ran it guessing which PDF was produced" leaves whoever ran it guessing which stage of their
stage of their pipeline did not finish. pipeline did not finish.
""" """
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()} documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
documents["metrics"] = metrics documents["metrics"] = metrics
missing = [name for name in REQUIRED if documents.get(name) is None] if all(documents.get(name) is None for name in REQUIRED_ANY):
message = f"Scan {sources.scan_id} carries no {' and no '.join(REQUIRED_ANY)}, so no report can be built."
if missing:
message = f"Scan {sources.scan_id} carries no {' and no '.join(missing)}, so no report can be built."
raise PipelineError(message) raise PipelineError(message)
+23
View File
@@ -420,3 +420,26 @@ p.note {
.legend .caveat { .legend .caveat {
color: var(--muted); color: var(--muted);
} }
/* --- Table of contents -------------------------------------------------------------------- */
/* Page numbers are read off where each link lands, so they stay right however long a section runs. */
.toc {
list-style: none;
padding: 0;
margin: 0;
}
.toc li {
padding: 1.4mm 0;
border-bottom: 0.4pt solid var(--border);
}
.toc a {
color: var(--ink);
text-decoration: none;
}
.toc a::after {
content: leader(".") target-counter(attr(href), page);
}
+14 -5
View File
@@ -121,8 +121,17 @@
</table> </table>
{% endif %} {% endif %}
{% if contents %}
<h1 style="break-before: page">Table of Contents</h1>
<ol class="toc">
{% for entry in contents %}
<li><a href="#{{ entry.id }}">{{ entry.title }}</a></li>
{% endfor %}
</ol>
{% endif %}
{% if scan %} {% if scan %}
<h1 style="break-before: page">{{ scan.title }}</h1> <h1 id="{{ scan.id }}" style="break-before: page">{{ scan.title }}</h1>
{{ facts(scan.overview, wide=true) }} {{ facts(scan.overview, wide=true) }}
{% for stage in scan.stages %} {% for stage in scan.stages %}
@@ -133,7 +142,7 @@
{% endif %} {% endif %}
{% if project %} {% if project %}
<h1 style="break-before: page">{{ project.title }}</h1> <h1 id="{{ project.id }}" style="break-before: page">{{ project.title }}</h1>
{{ facts(project.overview, wide=true) }} {{ facts(project.overview, wide=true) }}
{% if project.languages %} {% if project.languages %}
@@ -150,7 +159,7 @@
{% endif %} {% endif %}
{% if summary %} {% if summary %}
<h1>{{ summary.title }}</h1> <h1 id="{{ summary.id }}"{% if not project %} style="break-before: page"{% endif %}>{{ summary.title }}</h1>
{{ facts(summary.overview, wide=true) }} {{ facts(summary.overview, wide=true) }}
{% for block in summary.distributions %} {% for block in summary.distributions %}
@@ -164,12 +173,12 @@
{% endif %} {% endif %}
{% if critical %} {% if critical %}
<h2 style="break-before: page">{{ critical.title }}</h2> <h2 id="{{ critical.id }}" style="break-before: page">{{ critical.title }}</h2>
{{ grid(critical.table) }} {{ grid(critical.table) }}
{% endif %} {% endif %}
{% for section in sections %} {% for section in sections %}
<h1{% if section.new_page %} style="break-before: page"{% endif %}>{{ section.title }}</h1> <h1{% if section.id %} id="{{ section.id }}"{% endif %}{% if section.new_page %} style="break-before: page"{% endif %}>{{ section.title }}</h1>
{% for group in section.groups %} {% for group in section.groups %}
<h2>{{ group.title }}</h2> <h2>{{ group.title }}</h2>