diff --git a/README.md b/README.md index eaca9e5..b568d79 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ report_generator from-folder --artifacts ./sample [--path ] `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 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..7574397 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +python = "3.14" diff --git a/report_generator/context.py b/report_generator/context.py index 813c3c9..af56abd 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -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 = "" -# 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: diff --git a/report_generator/rendering.py b/report_generator/rendering.py index e087cb5..9cd6fb5 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -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) diff --git a/report_generator/template/report.css b/report_generator/template/report.css index 9db88c7..0b53d08 100644 --- a/report_generator/template/report.css +++ b/report_generator/template/report.css @@ -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); +} diff --git a/report_generator/template/report.html.j2 b/report_generator/template/report.html.j2 index 04e263d..90e8ecd 100644 --- a/report_generator/template/report.html.j2 +++ b/report_generator/template/report.html.j2 @@ -121,8 +121,17 @@ {% endif %} +{% if contents %} +

Table of Contents

+
    + {% for entry in contents %} +
  1. {{ entry.title }}
  2. + {% endfor %} +
+{% endif %} + {% if scan %} -

{{ scan.title }}

+

{{ scan.title }}

{{ facts(scan.overview, wide=true) }} {% for stage in scan.stages %} @@ -133,7 +142,7 @@ {% endif %} {% if project %} -

{{ project.title }}

+

{{ project.title }}

{{ facts(project.overview, wide=true) }} {% if project.languages %} @@ -150,7 +159,7 @@ {% endif %} {% if summary %} -

{{ summary.title }}

+

{{ summary.title }}

{{ facts(summary.overview, wide=true) }} {% for block in summary.distributions %} @@ -164,12 +173,12 @@ {% endif %} {% if critical %} -

{{ critical.title }}

+

{{ critical.title }}

{{ grid(critical.table) }} {% endif %} {% for section in sections %} - {{ section.title }} + {{ section.title }} {% for group in section.groups %}

{{ group.title }}