Updates to new format #1

Open
fklement wants to merge 17 commits from CipherCell/pdf-reporting:updates-to-new-format into main
pull from: CipherCell/pdf-reporting:updates-to-new-format
6 changed files with 290 additions and 167 deletions
Showing only changes of commit 2e2ab63232 - Show all commits

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
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 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
+2
View File
@@ -0,0 +1,2 @@
[tools]
python = "3.14"
+221 -150
View File
@@ -17,6 +17,8 @@ inside the web application that happens to import them today.
from __future__ import annotations
import re
from collections import Counter
from datetime import UTC, datetime
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.
UNKNOWN = "<xxx>"
# How many of each list the report carries. A report is read, not audited; the full set is in the
# artifacts it was built from.
# How many entries the summary lists carry. The sections themselves list every finding.
MOST_CRITICAL = 6
CARDS_PER_GROUP = 8
BARS = 10
# 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.
"""
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
context: dict[str, Any] = {
"cover": _cover(sources, metadata, sast, sca),
"scan": _scan(sources, metadata),
"project": _project(metadata),
"summary": _summary(sast, sca, documents.get("metrics")),
"sections": [],
"cover": _cover(sources, metadata, tools, sca),
"summary": _summary(tools, sca, documents.get("metrics")),
"sections": [_tool_section(name, report) for name, report in tools],
}
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"]:
context["critical"] = critical
if sast is not None:
context["sections"].append(_sast_section(sast))
if sca is not None:
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
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 ------------------------------------------------------------------------------
@@ -118,12 +149,12 @@ def _stated(value: Any) -> str: # noqa: ANN401
def _cover(
sources: ReportSources,
metadata: dict[str, Any],
sast: SarifReport | None,
tools: list[tuple[str, SarifReport]],
sca: VdrReport | None,
) -> dict[str, Any]:
"""The first page."""
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 {
"scan_id": str(sources.scan_id),
@@ -143,19 +174,20 @@ def _cover(
_fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")),
_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:
"""How many issues of each severity survived filtering, over both halves of the pipeline."""
def _severity_ledger(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any] | None:
"""How many issues of each severity survived filtering, over every tool and the dependencies."""
counts = dict.fromkeys(SEVERITY_ORDER, 0)
for finding in _true_findings(sast):
counts[SEVERITY_OF_LEVEL.get(finding.level, "Low")] += 1
for _, report in tools:
for finding in _kept(report):
counts[_finding_severity(finding)] += 1
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()):
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)]
return {
"id": "scan",
"title": "CipherCell Scan Information",
"overview": [
_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 {}
return {
"id": "project",
"title": "Project Information",
"overview": [
_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."""
true_sast = _true_findings(sast)
sca_total = sca.total if sca else 0
raw = (sast.total if sast else 0) + sca_total
filtered = sast.false_positive_count if sast else 0
raw = sum(report.total for _, report in tools) + sca_total
true_issues = [f"{len(_kept(report))} {name}" for name, report in tools] + ([f"{sca_total} SCA"] if sca else [])
summary = {
"id": "summary",
"title": "Summary",
"overview": [
_fact("Raw findings", raw or None),
# Only SAST findings carry a filtering verdict. A vulnerability suppressed in
# Dependency-Track never reaches the report at all, so the two cannot be added up.
_fact("Filtered", f"{filtered} (SAST)" if sast else None),
_fact("True issues", f"{len(true_sast)} SAST, {sca_total} SCA" if sast or sca else None),
# Each half stated only where its artifact was there. A scan that ran no SCA stage
# 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),
# Only SARIF findings can be set aside. A vulnerability suppressed in Dependency-Track
# never reaches the report at all, so the two cannot be added up.
_fact(
"False positives / suppressed",
", ".join(f"{len(_set_aside(report))} {name}" for name, report in tools) 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
@@ -340,12 +377,9 @@ def _remedy(value: dict[str, Any]) -> str | None:
return None
def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
"""How many distinct kinds of issue each half of the pipeline found."""
counted = []
if sast is not None:
counted.append(f"{sast.rule_count} SAST rules")
def _issue_types(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[str]:
"""How many distinct kinds of true issue each tool, and the dependency check, found."""
counted = [f"{len({one.rule_id for one in _kept(report)})} {name} rules" for name, report in tools]
if sca is not None:
counted.append(f"{sca.component_count} affected components")
@@ -353,17 +387,20 @@ def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
return counted
def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[str, Any]]:
"""Where the issues concentrate: by rule for SAST, by package for SCA."""
def _distributions(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[dict[str, Any]]:
"""Where the issues concentrate: by rule for each SARIF tool, by package for SCA."""
blocks = []
if findings:
blocks.append(
{
"title": f"True SAST issues by rule ({len(findings)})",
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
}
)
for name, report in tools:
findings = _kept(report)
if findings:
blocks.append(
{
"title": f"True {name} issues by rule ({len(findings)})",
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
}
)
if sca and sca.vulnerabilities:
blocks.append(
@@ -450,139 +487,152 @@ def _caveat(shown: list[Any], grouped: list[Any], largest: int) -> str:
return f"{len(shown)} of {len(grouped)} shown; {scale}"
def _critical(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any]:
"""The handful worth reading first, whichever half of the pipeline found them."""
rows: list[dict[str, Any]] = []
def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any]:
"""The handful worth reading first, whichever tool found them."""
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(
(
severity,
{
"cells": [
name,
{"value": severity, "kind": "severity"},
_stated(finding.rule_id),
{"value": _stated(_where(finding)), "kind": "mono"},
_stated(_text(finding.message)),
]
},
)
)
for vulnerability in sca.vulnerabilities if sca else []:
severity = _vdr_severity(vulnerability)
rows.append(
{
"cells": [
"SAST",
{"value": _finding_severity(finding), "kind": "severity"},
_stated(finding.rule_id),
{"value": _stated(_where(finding)), "kind": "mono"},
_stated(finding.message),
]
}
(
severity,
{
"cells": [
"SCA",
{"value": severity, "kind": "severity"},
_stated(vulnerability.id),
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
_stated(vulnerability.description),
]
},
)
)
for vulnerability in sorted(
sca.vulnerabilities if sca else [],
key=lambda one: SEVERITY_ORDER.index(_vdr_severity(one)),
):
rows.append(
{
"cells": [
"SCA",
{"value": _vdr_severity(vulnerability), "kind": "severity"},
_stated(vulnerability.id),
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
_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 {
"id": "critical",
"title": "Most Critical Vulnerabilities",
"table": {
"columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
"rows": rows[:MOST_CRITICAL],
"columns": ["Source", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
"rows": [row for _, row in rows[:MOST_CRITICAL]],
},
}
def _sast_section(sast: SarifReport) -> dict[str, Any]:
"""The SAST findings, split into what was kept and what was set aside."""
true_findings = _true_findings(sast)
filtered = [one for one in sast.findings if one.false_positive]
groups: list[dict[str, Any]] = []
def _tool_section(name: str, report: SarifReport) -> dict[str, Any]:
"""Every finding of one tool that was not set aside."""
kept = _kept(report)
group: dict[str, Any] = {"title": f"True Issues ({len(kept)})", "cards": [_finding_card(one) for one in kept]}
if true_findings:
groups.append(
{
"title": f"True Issues ({len(true_findings)})",
"cards": [_finding_card(one) for one in true_findings[:CARDS_PER_GROUP]],
}
)
if not kept:
group["note"] = f"Every finding {name} reported is listed under False Positives and Suppressed Findings."
if filtered:
groups.append(
{
"title": f"Filtered Issues ({len(filtered)})",
"note": (
"The following findings were reported by the scanners but assessed as not exploitable in "
"this artifact. Each entry carries the verdict that filtered it."
),
"cards": [_finding_card(one, filtered_out=True) for one in filtered[:CARDS_PER_GROUP]],
"table": _filtered_table(filtered),
}
)
return {"title": "SAST Vulnerabilities", "groups": groups}
return {"id": _anchor(name), "title": name, "new_page": True, "groups": [group]}
def _finding_card(finding: Finding, *, filtered_out: bool = False) -> dict[str, Any]:
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": "Overview",
"note": (
"The following findings were reported but set aside: classified as false positives, or "
"suppressed by the tool that reported them. Each entry carries the reason it was set aside."
),
"table": _set_aside_table(grouped),
}
]
groups += [
{"title": f"{name} ({len(findings)})", "cards": [_finding_card(one, set_aside=True) for one in findings]}
for name, findings in grouped
]
return {
"id": "set-aside",
"title": "False Positives and Suppressed Findings",
"new_page": True,
"groups": groups,
}
def _finding_card(finding: Finding, *, set_aside: bool = False) -> dict[str, Any]:
"""One SARIF finding."""
rows = [
_row("Severity", _finding_severity(finding), kind="severity"),
_row("Location", _where(finding), kind="mono"),
_row("Description", finding.message),
rows = []
# A finding a tool ruled out carries no severity worth stating.
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),
]
if filtered_out:
rows.append(
_row(
"Filtering Reason",
finding.polygraph_reasoning,
aside=True,
token=finding.polygraph_verdict,
)
if set_aside:
reason = finding.polygraph_reasoning or "; ".join(
one.justification for one in finding.suppressions if one.justification
)
else:
# Polygraph's verdict where it looked at this one, and nothing where it did not: silence is
# not a clean bill of health, and must not be written as one.
rows.append(_row("Evidence", finding.verdict_label or None))
rows.append(_row("Verdict", finding.verdict_label or "Suppressed"))
rows.append(_row("Reason", reason, aside=True))
elif finding.verdict_label:
rows.append(_row("Evidence", finding.verdict_label))
return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows}
def _filtered_table(findings: list[Finding]) -> dict[str, Any]:
"""How many of each rule were filtered, and on what grounds."""
grouped: dict[str, list[Finding]] = {}
def _set_aside_table(grouped: list[tuple[str, list[Finding]]]) -> dict[str, Any]:
"""How many findings of each tool and rule were set aside."""
rows: list[dict[str, Any]] = []
for finding in findings:
grouped.setdefault(finding.rule_id or UNKNOWN, []).append(finding)
for name, findings in grouped:
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]] = [
{
"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})
rows.append({"cells": ["Total", "", sum(len(findings) for _, findings in grouped)], "total": True})
return {
"title": f"Filtered SAST issues by rule ({len(findings)})",
"columns": ["Rule", "Filtered", "Dominant verdict"],
"rows": rows,
}
return {"columns": ["Tool", "Rule", "Set aside"], "rows": rows}
def _sca_section(sca: VdrReport) -> dict[str, Any]:
"""The dependency findings."""
return {
"title": "SBOM / SCA Vulnerabilities",
"id": "sca",
"title": "Dependency Vulnerabilities (SBOM / SCA)",
"new_page": True,
"groups": [
{
"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 ------------------------------------------------------------------------------
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."""
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:
@@ -648,13 +718,14 @@ def _vdr_severity(vulnerability: Vulnerability) -> str:
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:
return None
line = finding.location.start_line
return f"{finding.location.file}:{line}" if line else finding.location.file
return ", ".join(
f"{one.file}:{one.start_line}" if one.start_line else one.file
for one in [finding.location, *finding.extra_locations]
)
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.
INPUTS = {
"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"),
}
# What a report cannot be built without. The rest is reported as far as it was found: a scan that
# ran only half the pipeline is a shorter report, not a refusal.
REQUIRED = ("metadata",)
# A report needs at least one of these to have anything to say. The rest is reported as far as it
# was found: a scan that ran only half the pipeline is a shorter report, not a refusal.
REQUIRED_ANY = ("findings", "vulnerabilities")
# What the file is called. The scan it is about is in the name, because a directory of these is
# 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.
Raises:
PipelineError: The scan is missing an input the report cannot be built
without, or one of its documents cannot be parsed. Both are reported
by name: "no PDF was produced" leaves whoever ran it guessing which
stage of their pipeline did not finish.
PipelineError: The scan carries neither findings nor vulnerabilities, or
one of its documents cannot be parsed. Both are reported by name: "no
PDF was produced" leaves whoever ran it guessing which stage of their
pipeline did not finish.
"""
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
documents["metrics"] = metrics
missing = [name for name in REQUIRED if documents.get(name) is None]
if missing:
message = f"Scan {sources.scan_id} carries no {' and no '.join(missing)}, so no report can be built."
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."
raise PipelineError(message)
+23
View File
@@ -420,3 +420,26 @@ p.note {
.legend .caveat {
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>
{% 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 %}
<h1 style="break-before: page">{{ scan.title }}</h1>
<h1 id="{{ scan.id }}" style="break-before: page">{{ scan.title }}</h1>
{{ facts(scan.overview, wide=true) }}
{% for stage in scan.stages %}
@@ -133,7 +142,7 @@
{% endif %}
{% 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) }}
{% if project.languages %}
@@ -150,7 +159,7 @@
{% endif %}
{% 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) }}
{% for block in summary.distributions %}
@@ -164,12 +173,12 @@
{% endif %}
{% if critical %}
<h2 style="break-before: page">{{ critical.title }}</h2>
<h2 id="{{ critical.id }}" style="break-before: page">{{ critical.title }}</h2>
{{ grid(critical.table) }}
{% endif %}
{% 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 %}
<h2>{{ group.title }}</h2>