From 2e2ab632329a19fdc9503181b65dd8cb821175f4 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:33:00 +0200 Subject: [PATCH 01/17] Add toc and include all findings in the report --- README.md | 14 + mise.toml | 2 + report_generator/context.py | 371 ++++++++++++++--------- report_generator/rendering.py | 28 +- report_generator/template/report.css | 23 ++ report_generator/template/report.html.j2 | 19 +- 6 files changed, 290 insertions(+), 167 deletions(-) create mode 100644 mise.toml 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 }}

-- 2.56.0 From 5959d9364bad67a09b6ac13ff2f364ca4f62339e Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:41:06 +0200 Subject: [PATCH 02/17] Retitle the report and drop the report and artifact ids The report now covers every finding from all tools, so "SAST/SCA Report" undersold it. The report and artifact ids were never filled in. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- report_generator/context.py | 8 +++----- report_generator/layout.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b568d79..a6807b1 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ vectors by `persistent_storage.utils.cvss_utils`. ### What it cannot fill in A field the artifacts do not answer is written as ``, never guessed or quietly dropped. Today -that is the report and artifact ids, the manufacturer, the analysed lines of code, the language +that is the manufacturer, the analysed lines of code, the language breakdown, and the tools and profile of each pipeline stage — the metadata records that a stage ran and how long it took, not what it ran. diff --git a/report_generator/context.py b/report_generator/context.py index af56abd..4ac3e29 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -25,6 +25,8 @@ from typing import TYPE_CHECKING, Any from .vendor.cipherverifier.core.services.sarif import summarize_sarif from .vendor.cipherverifier.core.services.vdr import summarize_vdr +from .layout import TITLE + if TYPE_CHECKING: from .vendor.cipherverifier.core.services.sarif import Finding, SarifReport from .vendor.cipherverifier.core.services.vdr import VdrReport, Vulnerability @@ -161,13 +163,9 @@ def _cover( "subject": _stated(configuration.get("repository_name")), "footer_date": datetime.now(UTC).strftime("%d %B %Y"), "logo": "cc_logo.png", - "title": "SAST/SCA Report", + "title": TITLE, "identity": [ _fact("Scan Identifier", sources.scan_id, kind="mono"), - # Neither exists in the store today: a scan has artifacts, and a report of it is not - # itself recorded as one. - _fact("Report ID", None, kind="mono"), - _fact("Artifact ID", None, kind="mono"), _fact("Technology", ", ".join(technologies) or None), _fact("Project", configuration.get("repository_name"), gap=True), _fact("Version", configuration.get("repository_version")), diff --git a/report_generator/layout.py b/report_generator/layout.py index ee268b4..b8ed974 100644 --- a/report_generator/layout.py +++ b/report_generator/layout.py @@ -23,7 +23,7 @@ TEMPLATE_NAME = "report.html.j2" STYLESHEET = TEMPLATE_DIR / "report.css" # What the document calls itself, in the PDF's own metadata rather than only on its first page. -TITLE = "SAST/SCA Report" +TITLE = "Complete Security Findings Report" AUTHOR = "CipherCell" -- 2.56.0 From dc8ed38d8c1880e729bec8fb3017b7bc2e93c893 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:41:56 +0200 Subject: [PATCH 03/17] Let the project name and the manufacturer be supplied on the command line Neither is recorded in the artifacts: the metadata's repository_name is empty and nothing names a manufacturer. --project wins over the metadata. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- report_generator/commands/generate.py | 37 ++++++++++++++++++++++++--- report_generator/context.py | 28 ++++++++++++++------ report_generator/rendering.py | 15 +++++++++-- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a6807b1..0bdb35a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Two ways to say where the artifacts are. ``` report_generator generate --scan_id [--path ] --config config.json + [--project ] [--manufacturer ] ``` **Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported @@ -30,6 +31,7 @@ them: ``` report_generator from-folder --artifacts ./sample [--path ] + [--project ] [--manufacturer ] ``` `--scan_id` is optional there: the pipeline writes its own id into the paths it records in @@ -96,7 +98,7 @@ vectors by `persistent_storage.utils.cvss_utils`. ### What it cannot fill in A field the artifacts do not answer is written as ``, never guessed or quietly dropped. Today -that is the manufacturer, the analysed lines of code, the language +that is the manufacturer (unless given with `--manufacturer`), the analysed lines of code, the language breakdown, and the tools and profile of each pipeline stage — the metadata records that a stage ran and how long it took, not what it ran. diff --git a/report_generator/commands/generate.py b/report_generator/commands/generate.py index bde6359..7c04422 100644 --- a/report_generator/commands/generate.py +++ b/report_generator/commands/generate.py @@ -32,11 +32,15 @@ class GenerateProperties(BaseModel): scan_id: The scan to report on. The store is keyed by it. path: Where to write the PDF. A directory is written into under the name the renderer chose; leaving it out writes into ``reports/``. + project: The name of the scanned project, as the report states it. + manufacturer: Who makes the scanned product, as the report states it. storage_config: The store the scan's artifacts are read from. """ scan_id: UUID = Field(description="The scan the report should be generated for.") path: Path | None = Field(default=None, description="Where to write the PDF report.") + project: str | None = Field(default=None, description="The name of the scanned project.") + manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.") storage_config: StorageConfiguration @@ -48,6 +52,8 @@ class FolderProperties(BaseModel): scan_id: The scan to report under. Read out of the artifacts when not given -- an export names its own run. path: Where to write the PDF, as above. + project: The name of the scanned project, as above. + manufacturer: Who makes the scanned product, as above. """ artifacts: Path = Field(description="Folder of exported artifacts to build the report from.") @@ -55,6 +61,8 @@ class FolderProperties(BaseModel): default=None, description="The scan to report under. Read from the artifacts when omitted." ) path: Path | None = Field(default=None, description="Where to write the PDF report.") + project: str | None = Field(default=None, description="The name of the scanned project.") + manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.") class GeneratedReport(BaseModel): @@ -89,7 +97,12 @@ def generate(properties: GenerateProperties) -> GeneratedReport: PipelineError: The scan cannot be read, is missing an input the report needs, or the destination cannot be written. """ - return _report(collect(properties.storage_config, properties.scan_id), properties.path) + return _report( + collect(properties.storage_config, properties.scan_id), + properties.path, + project=properties.project, + manufacturer=properties.manufacturer, + ) def from_folder(properties: FolderProperties) -> GeneratedReport: @@ -107,10 +120,23 @@ def from_folder(properties: FolderProperties) -> GeneratedReport: PipelineError: The folder cannot be read, names no scan, is missing an input the report needs, or the destination cannot be written. """ - return _report(collect_from_directory(properties.artifacts, properties.scan_id), properties.path, write_metrics=True) + return _report( + collect_from_directory(properties.artifacts, properties.scan_id), + properties.path, + project=properties.project, + manufacturer=properties.manufacturer, + write_metrics=True, + ) -def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool = False) -> GeneratedReport: +def _report( + sources: ReportSources, + path: Path | None, + *, + project: str | None = None, + manufacturer: str | None = None, + write_metrics: bool = False, +) -> GeneratedReport: """Render the collected artifacts and write the result. Everything after the artifacts are in hand, which is the same whichever of the two collected @@ -119,6 +145,9 @@ def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool = Args: sources: The scan and its files. path: Where the caller asked for the PDF, if anywhere in particular. + project: The project name the caller supplied, if any. + manufacturer: The manufacturer the caller supplied, if any. + write_metrics: Whether to write ``metrics.json`` beside the PDF. Returns: The report that was written. @@ -127,7 +156,7 @@ def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool = PipelineError: The report cannot be built or the destination not written. """ metrics = build_metrics(sources) - name, content = render(sources, metrics) + name, content = render(sources, metrics, project=project, manufacturer=manufacturer) destination = _destination(path, name) try: diff --git a/report_generator/context.py b/report_generator/context.py index 4ac3e29..b7602dd 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -53,13 +53,21 @@ SEVERITY_OF_LEVEL = { SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") -def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str, Any]: +def build_context( + sources: ReportSources, + documents: dict[str, Any], + *, + project: str | None = None, + manufacturer: str | None = None, +) -> dict[str, Any]: """Build the report context from a scan's parsed artifacts. Args: sources: The scan and its stored files. documents: The parsed artifacts by role -- ``metadata``, ``findings``, ``vulnerabilities`` -- each absent where the scan stored none. + project: The project name the caller supplied. Wins over the one in the metadata. + manufacturer: The manufacturer the caller supplied. The artifacts never name one. Returns: The context :func:`layout.render_pdf` @@ -67,11 +75,12 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str the template renders as a shorter report. """ metadata = documents.get("metadata") or {} + project = project or (metadata.get("configuration") or {}).get("repository_name") tools = _tools(documents.get("findings")) sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None context: dict[str, Any] = { - "cover": _cover(sources, metadata, tools, sca), + "cover": _cover(sources, metadata, tools, sca, project=project, manufacturer=manufacturer), "summary": _summary(tools, sca, documents.get("metrics")), "sections": [_tool_section(name, report) for name, report in tools], } @@ -79,7 +88,7 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str # Without the pipeline's metadata these two pages would be nothing but gaps. if metadata: context["scan"] = _scan(sources, metadata) - context["project"] = _project(metadata) + context["project"] = _project(metadata, project) critical = _critical(tools, sca) @@ -153,6 +162,9 @@ def _cover( metadata: dict[str, Any], tools: list[tuple[str, SarifReport]], sca: VdrReport | None, + *, + project: str | None, + manufacturer: str | None, ) -> dict[str, Any]: """The first page.""" configuration = metadata.get("configuration") or {} @@ -160,17 +172,17 @@ def _cover( return { "scan_id": str(sources.scan_id), - "subject": _stated(configuration.get("repository_name")), + "subject": _stated(project), "footer_date": datetime.now(UTC).strftime("%d %B %Y"), "logo": "cc_logo.png", "title": TITLE, "identity": [ _fact("Scan Identifier", sources.scan_id, kind="mono"), _fact("Technology", ", ".join(technologies) or None), - _fact("Project", configuration.get("repository_name"), gap=True), + _fact("Project", project, gap=True), _fact("Version", configuration.get("repository_version")), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), - _fact("Manufacturer", None), + _fact("Manufacturer", manufacturer), ], "severity_ledger": _severity_ledger(tools, sca), } @@ -238,7 +250,7 @@ def _stage(stage: dict[str, Any], sources: ReportSources) -> dict[str, Any]: } -def _project(metadata: dict[str, Any]) -> dict[str, Any]: +def _project(metadata: dict[str, Any], project: str | None) -> dict[str, Any]: """What was scanned.""" configuration = metadata.get("configuration") or {} @@ -246,7 +258,7 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]: "id": "project", "title": "Project Information", "overview": [ - _fact("Project", configuration.get("repository_name")), + _fact("Project", project), _fact("Version", configuration.get("repository_version")), # Nothing in the pipeline counts lines, so nothing here can report them. _fact("Analysed lines of code", None), diff --git a/report_generator/rendering.py b/report_generator/rendering.py index 9cd6fb5..c3c1cd8 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -46,11 +46,20 @@ REQUIRED_ANY = ("findings", "vulnerabilities") NAME = "report_{scan}.pdf" -def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tuple[str, bytes]: +def render( + sources: ReportSources, + metrics: dict[str, Any] | None = None, + *, + project: str | None = None, + manufacturer: str | None = None, +) -> tuple[str, bytes]: """Render the report of one scan. Args: sources: What :func:`sources.collect` found. + metrics: The metrics document, if one was built. + project: The project name the caller supplied; overrides the metadata's. + manufacturer: The manufacturer the caller supplied. Returns: The file name the report should be saved under, and its bytes. @@ -72,7 +81,9 @@ def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tup logging_context.logger.info(f"Rendering the report from {', '.join(found)}...") - return NAME.format(scan=sources.scan_id), render_pdf(build_context(sources, documents)) + context = build_context(sources, documents, project=project, manufacturer=manufacturer) + + return NAME.format(scan=sources.scan_id), render_pdf(context) def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401 -- 2.56.0 From 08a84774429e29552be57f7479ee90539c5372c2 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:42:25 +0200 Subject: [PATCH 04/17] Report the scanned commit as the project version The pipeline leaves repository_version empty, but tags the Dependency-Track upload with the commit it scanned. Use that when no version is stated. Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/report_generator/context.py b/report_generator/context.py index b7602dd..3ef1cd7 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -52,6 +52,9 @@ SEVERITY_OF_LEVEL = { SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") +# A full git commit SHA. +_COMMIT = re.compile(r"[0-9a-f]{40}") + def build_context( sources: ReportSources, @@ -167,7 +170,6 @@ def _cover( manufacturer: str | None, ) -> dict[str, Any]: """The first page.""" - configuration = metadata.get("configuration") or {} technologies = [name for name, _ in tools] + (["SCA"] if sca is not None else []) return { @@ -180,7 +182,7 @@ def _cover( _fact("Scan Identifier", sources.scan_id, kind="mono"), _fact("Technology", ", ".join(technologies) or None), _fact("Project", project, gap=True), - _fact("Version", configuration.get("repository_version")), + _fact("Version", _version(metadata), kind="mono"), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Manufacturer", manufacturer), ], @@ -250,16 +252,31 @@ def _stage(stage: dict[str, Any], sources: ReportSources) -> dict[str, Any]: } +def _version(metadata: dict[str, Any]) -> str | None: + """What version of the project was scanned: the stated one, else the commit the SBOM was uploaded for. + + The pipeline leaves ``repository_version`` empty but tags the Dependency-Track upload with the + commit it scanned, which is the most precise version there is. + """ + configuration = metadata.get("configuration") or {} + stated = configuration.get("repository_version") + + if stated: + return str(stated) + + commit = (configuration.get("deptrace_upload") or {}).get("project_version") + + return commit if isinstance(commit, str) and _COMMIT.fullmatch(commit) else None + + def _project(metadata: dict[str, Any], project: str | None) -> dict[str, Any]: """What was scanned.""" - configuration = metadata.get("configuration") or {} - return { "id": "project", "title": "Project Information", "overview": [ _fact("Project", project), - _fact("Version", configuration.get("repository_version")), + _fact("Version", _version(metadata), kind="mono"), # Nothing in the pipeline counts lines, so nothing here can report them. _fact("Analysed lines of code", None), ], -- 2.56.0 From ae04b50a6c282a683375200a62e4f13f77422450 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:42:32 +0200 Subject: [PATCH 05/17] Label the set-aside count Suppressed / False Positives Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/report_generator/context.py b/report_generator/context.py index 3ef1cd7..fbbfc3e 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -303,7 +303,7 @@ def _summary( # 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", + "Suppressed / False Positives", ", ".join(f"{len(_set_aside(report))} {name}" for name, report in tools) or None, ), _fact("True issues", ", ".join(true_issues) or None), -- 2.56.0 From 8629ba7bcf53dbe119b6b08860ce1cc3fab0376a Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 20:42:44 +0200 Subject: [PATCH 06/17] Drop the recommendation from the CVE cards The VDR carries no recommendation, so every card only ever showed . Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/report_generator/context.py b/report_generator/context.py index fbbfc3e..726b414 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -699,7 +699,6 @@ def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]: kind="mono", ), _row("Known for", f"{metrics.published_days_ago} days" if metrics.published_days_ago else None), - _row("Recommendation", vulnerability.recommendation), ], } -- 2.56.0 From 8f4730360511c885af8d3e453519a7144c97c83c Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:06:44 +0200 Subject: [PATCH 07/17] Grade SARIF findings by score, then CWE, then level A result's own security-severity score is banded like CVSS; without one, the finding is graded by a hand-built CWE table; failing that, by its SARIF level, one band lower than before. The scanners' levels said more about the tool than about the risk: a container running as root came out Critical and a hard-coded password Medium. The summariser only reads security-severity off the rule, and Lynkeus scores each result, so the runs are summarised here to keep that score. Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 40 +++++++---- report_generator/severity.py | 129 +++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 report_generator/severity.py diff --git a/report_generator/context.py b/report_generator/context.py index 726b414..be4efd3 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -22,10 +22,11 @@ from collections import Counter from datetime import UTC, datetime from typing import TYPE_CHECKING, Any -from .vendor.cipherverifier.core.services.sarif import summarize_sarif +from .vendor.cipherverifier.core.services.sarif import _build_finding, _build_report, _rules_by_id from .vendor.cipherverifier.core.services.vdr import summarize_vdr from .layout import TITLE +from .severity import finding_severity if TYPE_CHECKING: from .vendor.cipherverifier.core.services.sarif import Finding, SarifReport @@ -40,16 +41,6 @@ UNKNOWN = "" MOST_CRITICAL = 6 BARS = 10 -# The severity a SARIF level is reported under. SARIF names four levels and the report bands five, -# so the mapping is written down rather than inferred from the spelling. -SEVERITY_OF_LEVEL = { - "error": "Critical", - "warning": "High", - "note": "Medium", - "none": "Low", - "info": "Low", -} - SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") # A full git commit SHA. @@ -118,7 +109,28 @@ def _tools(sarif: Any) -> list[tuple[str, SarifReport]]: # noqa: ANN401 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] + return [(_tool_name(run), _summarize(run)) for run in runs] + + +def _summarize(run: dict[str, Any]) -> SarifReport: + """Summarise one run, as :func:`summarize_sarif` would, keeping each result's own score. + + The summariser reads ``security-severity`` off the rule only. Lynkeus scores each result instead, + and a result's own score is the more specific one, so it takes the rule's place. + """ + rules = _rules_by_id([run]) + findings = [] + + for result in run.get("results") or []: + finding = _build_finding(result, run, rules) + stated = (result.get("properties") or {}).get("security-severity") + + if stated is not None: + finding.rule_security_severity = str(stated) + + findings.append(finding) + + return _build_report(findings, ranked=any(finding.rank is not None for finding in findings)) def _tool_name(run: dict[str, Any]) -> str: @@ -732,8 +744,8 @@ def _anchor(title: str) -> str: def _finding_severity(finding: Finding) -> str: - """The severity band a SARIF level falls in.""" - return SEVERITY_OF_LEVEL.get(finding.level, "Low") + """The severity band a SARIF finding falls in; see :mod:`severity`.""" + return finding_severity(finding.rule_id, finding.level, finding.rule_security_severity) def _vdr_severity(vulnerability: Vulnerability) -> str: diff --git a/report_generator/severity.py b/report_generator/severity.py new file mode 100644 index 0000000..008f45b --- /dev/null +++ b/report_generator/severity.py @@ -0,0 +1,129 @@ +"""The severity a SARIF finding is reported under. + +In order of preference: + +1. The finding's own ``security-severity`` score, banded like CVSS. Where a tool scored the finding + itself -- Lynkeus does -- that is the most specific reading there is. +2. Its weakness class, from :data:`CWE_SEVERITY`. The tools do not agree on what their levels mean + -- Trivy's ``error`` for a container running as root and Bandit's ``note`` for a hard-coded + database password say more about the tool than about the risk -- so the CWE is preferred to them. +3. Its SARIF level, from :data:`SEVERITY_OF_LEVEL`, for a finding with neither. + +Each entry of the table is the severity a confirmed instance of that weakness typically carries, before any +context about the application is known: roughly where the CVEs filed under it land on the CVSS +bands. Critical is kept for classes that usually hand an attacker code execution or the whole data +store; weaknesses that only matter together with another flaw, or that harden rather than defend, +sit at Medium or Low. +""" + +from __future__ import annotations + +import re + +CWE_SEVERITY = { + # Injection that typically ends in code execution or full database access. + "CWE-77": "Critical", # Command injection + "CWE-78": "Critical", # OS command injection + "CWE-89": "Critical", # SQL injection + "CWE-94": "Critical", # Code injection + "CWE-95": "Critical", # Eval injection + "CWE-502": "Critical", # Deserialization of untrusted data + # Direct compromise of confidentiality or access control, usually needing some foothold. + "CWE-22": "High", # Path traversal + "CWE-96": "High", # Static code injection + "CWE-259": "High", # Hard-coded password + "CWE-287": "High", # Improper authentication + "CWE-306": "High", # Missing authentication for a critical function + "CWE-321": "High", # Hard-coded cryptographic key + "CWE-434": "High", # Unrestricted upload of dangerous file type + "CWE-611": "High", # XML external entities + "CWE-798": "High", # Hard-coded credentials + "CWE-862": "High", # Missing authorization + "CWE-863": "High", # Incorrect authorization + "CWE-918": "High", # Server-side request forgery + # Real weaknesses whose impact depends on context, or that need another flaw to be exploited. + "CWE-15": "Medium", # External control of a configuration setting + "CWE-20": "Medium", # Improper input validation + "CWE-79": "Medium", # Cross-site scripting + "CWE-200": "Medium", # Exposure of sensitive information + "CWE-250": "Medium", # Execution with unnecessary privileges + "CWE-269": "Medium", # Improper privilege management + "CWE-295": "Medium", # Improper certificate validation + "CWE-312": "Medium", # Cleartext storage of sensitive information + "CWE-319": "Medium", # Cleartext transmission of sensitive information + "CWE-326": "Medium", # Inadequate encryption strength + "CWE-327": "Medium", # Broken or risky cryptographic algorithm + "CWE-328": "Medium", # Weak hash + "CWE-330": "Medium", # Insufficiently random values + "CWE-338": "Medium", # Cryptographically weak PRNG + "CWE-352": "Medium", # Cross-site request forgery + "CWE-400": "Medium", # Uncontrolled resource consumption + "CWE-522": "Medium", # Insufficiently protected credentials + "CWE-532": "Medium", # Sensitive information in a log file + "CWE-601": "Medium", # Open redirect + "CWE-732": "Medium", # Incorrect permission assignment + "CWE-770": "Medium", # Allocation of resources without limits + "CWE-1333": "Medium", # Inefficient regular expression (ReDoS) + # Hygiene: worth fixing, rarely exploitable on its own. + "CWE-117": "Low", # Log injection + "CWE-209": "Low", # Information in an error message + "CWE-377": "Low", # Insecure temporary file + "CWE-390": "Low", # Error detected without action + "CWE-391": "Low", # Unchecked error condition + "CWE-614": "Low", # Sensitive cookie without the Secure flag + "CWE-703": "Low", # Improper handling of exceptional conditions + "CWE-754": "Low", # Improper check for unusual conditions + "CWE-755": "Low", # Improper handling of exceptional conditions + "CWE-1004": "Low", # Sensitive cookie without the HttpOnly flag +} + +# For a finding whose CWE is not in the table, or that has none. One band below the old one-to-one +# reading of the level, because a tool's "error" is not a statement that the finding is critical. +SEVERITY_OF_LEVEL = { + "error": "High", + "warning": "Medium", + "note": "Low", + "none": "Low", + "info": "Low", +} + +# The CVSS v3 qualitative bands, by their lower bounds. A score of 0 has no band of its own in the +# report and reads as Low. +SCORE_BANDS = ((9.0, "Critical"), (7.0, "High"), (4.0, "Medium")) + +_CWE = re.compile(r"CWE-(\d+)", re.IGNORECASE) + + +def finding_severity(rule_id: str, level: str, security_severity: str = "") -> str: + """The severity a SARIF finding is reported under. + + Args: + rule_id: The finding's rule. The pipeline's tools name their rules by CWE. + level: The SARIF level. + security_severity: The finding's ``security-severity`` score, empty where it has none. + + Returns: + One of Critical, High, Medium or Low. + """ + scored = _score_band(security_severity) + + if scored is not None: + return scored + + found = _CWE.search(rule_id) + known = CWE_SEVERITY.get(f"CWE-{found.group(1)}") if found else None + + return known or SEVERITY_OF_LEVEL.get(level, "Low") + + +def _score_band(stated: str) -> str | None: + """The band a ``security-severity`` score falls in, or nothing where it is not a score.""" + try: + score = float(stated) + except ValueError: + return None + + if not 0 <= score <= 10: + return None + + return next((name for bound, name in SCORE_BANDS if score >= bound), "Low") -- 2.56.0 From b380c736c9cd61dab46101d3afee6c9b38e8fe73 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:06:44 +0200 Subject: [PATCH 08/17] Name the dependency check CipherCell SCA on the cover Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/report_generator/context.py b/report_generator/context.py index be4efd3..040e12d 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -182,7 +182,7 @@ def _cover( manufacturer: str | None, ) -> dict[str, Any]: """The first page.""" - technologies = [name for name, _ in tools] + (["SCA"] if sca is not None else []) + technologies = [name for name, _ in tools] + (["CipherCell SCA"] if sca is not None else []) return { "scan_id": str(sources.scan_id), -- 2.56.0 From 9d78e9edf49d0506d5698cf9dc6f25364eb28c71 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:14:14 +0200 Subject: [PATCH 09/17] Remove the scan information section Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- report_generator/context.py | 66 +-------------- report_generator/layout.py | 1 - report_generator/sample.py | 103 ----------------------- report_generator/template/report.html.j2 | 11 --- 5 files changed, 6 insertions(+), 184 deletions(-) diff --git a/README.md b/README.md index 0bdb35a..c9bb541 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ report_generator from-folder --artifacts ./sample [--path ] 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: +(`vdr.json`); either is enough on its own. `metadata.json` is optional, and without it the project +page is 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, @@ -98,9 +98,8 @@ vectors by `persistent_storage.utils.cvss_utils`. ### What it cannot fill in A field the artifacts do not answer is written as ``, never guessed or quietly dropped. Today -that is the manufacturer (unless given with `--manufacturer`), the analysed lines of code, the language -breakdown, and the tools and profile of each pipeline stage — the metadata records that a stage ran -and how long it took, not what it ran. +that is the manufacturer (unless given with `--manufacturer`), the analysed lines of code and the language +breakdown. A measured zero survives: `0 filtered` is a reading, and a blank is a gap. diff --git a/report_generator/context.py b/report_generator/context.py index 040e12d..2fc90f2 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -79,9 +79,8 @@ def build_context( "sections": [_tool_section(name, report) for name, report in tools], } - # Without the pipeline's metadata these two pages would be nothing but gaps. + # Without the pipeline's metadata this page would be nothing but gaps. if metadata: - context["scan"] = _scan(sources, metadata) context["project"] = _project(metadata, project) critical = _critical(tools, sca) @@ -142,7 +141,7 @@ def _tool_name(run: dict[str, Any]) -> str: 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] + parts = [context[key] for key in ("project", "summary", "critical") if key in context] return [{"id": part["id"], "title": part["title"]} for part in [*parts, *context["sections"]]] @@ -224,46 +223,6 @@ def _severity_ledger(tools: list[tuple[str, SarifReport]], sca: VdrReport | None return {"title": "True issues by severity", "rows": rows} -def _scan(sources: ReportSources, metadata: dict[str, Any]) -> dict[str, Any]: - """What the pipeline did, stage by stage.""" - stages = [stage for stage in metadata.get("stages") or [] if isinstance(stage, dict)] - 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"))), - _fact("Scan duration", _duration(metadata.get("pipeline_duration_seconds"))), - _fact("Scan completed at", _moment(metadata.get("pipeline_end_iso"))), - _fact( - "Pipeline Steps", ", ".join(_stated(stage.get("label") or stage.get("key")) for stage in stages) or None - ), - _fact("Errors Detected", ", ".join(failed) if failed else "None" if stages else None), - ], - "stages": [_stage(stage, sources) for stage in stages], - } - - -def _stage(stage: dict[str, Any], sources: ReportSources) -> dict[str, Any]: - """One stage of the pipeline. - - The tools a stage ran and the profile it ran them under are not recorded in the metadata, so - they are left unstated. What is recorded is when it ran, how it ended, and -- indirectly -- what - it produced, which is read off the names of the files the scan stored. - """ - return { - "title": stage.get("label") or stage.get("key") or UNKNOWN, - "rows": [ - _fact("Selected Tools", None), - _fact("Profile", None), - _fact("Duration", _duration(stage.get("duration_seconds"))), - _fact("Created Artifacts", ", ".join(sorted(sources.files)) or None, kind="mono"), - _fact("Errors Detected", "None" if stage.get("status") == "success" else stage.get("status")), - ], - } - - def _version(metadata: dict[str, Any]) -> str | None: """What version of the project was scanned: the stated one, else the commit the SBOM was uploaded for. @@ -772,24 +731,3 @@ def _percent(share: float | None) -> str | None: return None return f"{share * 100:.2f} %" if share < 0.01 else f"{share * 100:.1f} %" - - -def _moment(stated: Any) -> str | None: # noqa: ANN401 - """An ISO timestamp as the report writes it, or nothing where it cannot be read.""" - if not isinstance(stated, str): - return None - - try: - return datetime.fromisoformat(stated).strftime("%A, %B %d, %Y %H:%M:%S (%Z)").replace("()", "").strip() - except ValueError: - return None - - -def _duration(seconds: Any) -> str | None: # noqa: ANN401 - """A number of seconds as ``HH:MM:SS``.""" - if not isinstance(seconds, (int, float)) or isinstance(seconds, bool): - return None - - whole = int(seconds) - - return f"{whole // 3600:02d}:{whole % 3600 // 60:02d}:{whole % 60:02d}" diff --git a/report_generator/layout.py b/report_generator/layout.py index b8ed974..944370f 100644 --- a/report_generator/layout.py +++ b/report_generator/layout.py @@ -32,7 +32,6 @@ AUTHOR = "CipherCell" # shorter report rather than one full of empty tables. CONTEXT = { "cover": "scan_id, footer_date, logo, title, identity[], severity_ledger{title, rows[]}", - "scan": "title, overview[], stages[{title, rows[], table?}]", "project": "title, overview[], languages{title, rows[]}", "summary": "title, overview[], distributions[{title, rows[], legend}]", "critical": "title, table{columns[], rows[]}", diff --git a/report_generator/sample.py b/report_generator/sample.py index 35ea060..71fd21b 100644 --- a/report_generator/sample.py +++ b/report_generator/sample.py @@ -97,108 +97,6 @@ def _cover() -> dict[str, Any]: } -def _scan() -> dict[str, Any]: - """What the pipeline did, stage by stage, and what each stage was configured with.""" - return { - "title": "CipherCell Scan Information", - "overview": [ - _fact("Scan started at", "Wednesday, September 16, 2026 05:43:14 (UTC)"), - _fact("Scan duration", "07:30:38"), - _fact("Scan completed at", "Wednesday, September 16, 2026 13:13:52 (UTC)"), - _fact( - "Pipeline Steps", - "Preanalysis, SAST, SBOM Dependency Tracking, SBOM Reachability, AI FP Detection, AI Ranking", - ), - _fact("Errors Detected", "None (2 warnings, see SBOM Reachability)"), - ], - "stages": [ - { - "title": "Pre-Analysis Settings", - "rows": [ - _fact("Selected Tools", "Propylon v0.9.4"), - _fact("Profile", "deterministic"), - _fact("Errors Detected", "None"), - ], - }, - { - "title": "SAST Settings", - "rows": [ - _fact("Selected Tools", "ruff v0.14.4, bandit v1.8.6, cppcheck v2.18.0, semgrep v1.145.0"), - _fact("Language Coverage", "C (cppcheck, semgrep), Python (ruff, bandit, semgrep)"), - _fact("Rule Profile", "security — ruleset ciphercell-sec-2026.03, sha256 8f2b41c6…d97a"), - _fact("Deduplication Method", "Deterministic"), - _fact("Created Artifacts", "sast_combined.sarif", kind="mono"), - _fact("Errors Detected", "None"), - ], - }, - { - "title": "SBOM Dependency Tracking Settings", - "rows": [ - _fact("Selected Tools", "syft v1.33.0, cdxgen v11.10.0, deptrace v0.7.2"), - _fact("Profile", "Standard SBOM"), - _fact("SBOM Format", "CycloneDX 1.6"), - _fact("Advisory Sources", "NVD, OSV.dev, GitHub Advisory Database, Sonatype OSS Index"), - _fact("Created Artifacts", "sbom.cdx.json, sbom.vdr.json", kind="mono"), - _fact("Errors Detected", "None"), - ], - }, - { - "title": "SBOM Reachability", - "rows": [ - _fact("Selected Tools", "CodeQL v2.24.1, Joern v4.0.312"), - _fact("Profile", "Default Taint Analysis"), - _fact("Created Artifacts", "sbom_annotated.vdr.json", kind="mono"), - _fact("Errors Detected", "2 warnings"), - ], - "table": { - "columns": ["ID", "Stage", "Warning"], - "rows": [ - { - "cells": [ - "W-01", - "Joern, JavaScript", - "2 of 148 entry points could not be resolved (dynamic require() in " - "server/plugins/loader.js:41). The 6 findings depending on these entry points " - "were escalated and are reported conservatively as true issues.", - ] - }, - { - "cells": [ - "W-02", - "CodeQL, C", - "Database build skipped for vendored sources under third_party/; excluded by " - "scan scope, no reachability verdict is claimed for those paths.", - ] - }, - ], - }, - }, - { - "title": "AI FP Detection", - "rows": [ - _fact("Selected Tools", "Polygraph v1.6.2"), - _fact("Profile", "static and escalation agent"), - _fact("AI Model", "Qwen3-32B, digest sha256:4ab1…c70f"), - _fact("Inference", "On-premise, CipherCell inference cluster"), - _fact("Reasoning Effort", "Default (classification), Default (escalation)"), - _fact("Response Cache", "Enabled"), - _fact("Context Enrichment", "Static (50 lines before, 50 lines after)"), - _fact("Errors Detected", "None"), - ], - }, - { - "title": "Ranking", - "rows": [ - _fact("Selected Tools", "Prism v0.4.1"), - _fact("Effort", "Standard"), - _fact("Context Size", "Minimal"), - _fact("Errors Detected", "None"), - ], - }, - ], - } - - def _project() -> dict[str, Any]: """What was scanned, and what it is written in.""" return { @@ -517,7 +415,6 @@ def sample_context() -> dict[str, Any]: """A complete context, covering every section the template can draw.""" return { "cover": _cover(), - "scan": _scan(), "project": _project(), "summary": _summary(), "critical": _critical(), diff --git a/report_generator/template/report.html.j2 b/report_generator/template/report.html.j2 index 90e8ecd..f6d3b74 100644 --- a/report_generator/template/report.html.j2 +++ b/report_generator/template/report.html.j2 @@ -130,17 +130,6 @@ {% endif %} -{% if scan %} -

{{ scan.title }}

- {{ facts(scan.overview, wide=true) }} - - {% for stage in scan.stages %} -

{{ stage.title }}

- {{ facts(stage.rows, wide=true) }} - {% if stage.table %}{{ grid(stage.table) }}{% endif %} - {% endfor %} -{% endif %} - {% if project %}

{{ project.title }}

{{ facts(project.overview, wide=true) }} -- 2.56.0 From 2d88ffa953e041bce268f246e5a0441d4020699c Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:14:14 +0200 Subject: [PATCH 10/17] Report lines of code and the main languages from cloc.txt The project page takes the size from cloc's sum and names the three to five largest languages, leaving out prose and data files such as Markdown, text, CSV and JSON, and anything under 1% of the code beyond the third. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++-- report_generator/cloc.py | 90 +++++++++++++++++++++++++++++++++++ report_generator/context.py | 35 +++++++++----- report_generator/rendering.py | 2 + 4 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 report_generator/cloc.py diff --git a/README.md b/README.md index c9bb541..6eba5f6 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ report_generator from-folder --artifacts ./sample [--path ] 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 project -page is left out. The report has these parts: +(`vdr.json`); either is enough on its own. `metadata.json` and `cloc.txt` (cloc's plain +table, for the lines of code and the three to five main languages) are optional; without either the +project page is 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, @@ -98,8 +99,8 @@ vectors by `persistent_storage.utils.cvss_utils`. ### What it cannot fill in A field the artifacts do not answer is written as ``, never guessed or quietly dropped. Today -that is the manufacturer (unless given with `--manufacturer`), the analysed lines of code and the language -breakdown. +that is the manufacturer (unless given with `--manufacturer`), and, when there is no `cloc.txt`, the +analysed lines of code and the language breakdown. A measured zero survives: `0 filtered` is a reading, and a blank is a gap. diff --git a/report_generator/cloc.py b/report_generator/cloc.py new file mode 100644 index 0000000..d189102 --- /dev/null +++ b/report_generator/cloc.py @@ -0,0 +1,90 @@ +"""Reads the project's size and languages out of the table cloc prints. + +cloc writes one row per language -- files, blank, comment and code lines -- and a ``SUM:`` row under +them. Only the code lines are reported: that is what "lines of code" means to a reader, and blanks +and comments say nothing about how much there is to analyse. +""" + +from __future__ import annotations + +import re +from typing import Any + +# How many languages the report names at most, and how many it names at least where there are that +# many. Between the two, a language has to carry :data:`MINIMUM_SHARE` of the code to be named. +MOST_LANGUAGES = 5 +FEWEST_LANGUAGES = 3 +MINIMUM_SHARE = 0.01 + +# What cloc counts that is not code anyone analyses: prose, and data files. Configuration and build +# files -- YAML, Dockerfiles, TOML -- are scanned like code and so stay in. +NOT_CODE = frozenset( + { + "AsciiDoc", + "CSV", + "JSON", + "JSON5", + "Markdown", + "reStructuredText", + "SVG", + "Text", + "TSV", + } +) + +# A row of the table: a name, which may contain spaces, then the four counts. +_ROW = re.compile(r"^(?P\S.*?)\s+(?P\d+)\s+\d+\s+\d+\s+(?P\d+)\s*$") + + +def parse_cloc(content: bytes | None) -> dict[str, Any] | None: + """Read cloc's table. + + Args: + content: The table as cloc printed it, or nothing where the scan stored none. + + Returns: + The ``files`` and ``code`` lines of the whole project, and its ``languages`` + with the same two counts each, largest first; ``None`` where there is no + table to read. + """ + if content is None: + return None + + languages = [] + total = None + + for line in content.decode("utf-8", errors="replace").splitlines(): + row = _ROW.match(line.strip()) + + if row is None: + continue + + counts = {"files": int(row["files"]), "code": int(row["code"])} + + if row["name"] == "SUM:": + total = counts + else: + languages.append({"name": row["name"], **counts}) + + if not languages: + return None + + # cloc leaves the sum out when it counted a single language. + total = total or { + "files": sum(one["files"] for one in languages), + "code": sum(one["code"] for one in languages), + } + + return {**total, "languages": sorted(languages, key=lambda one: -one["code"])} + + +def main_languages(cloc: dict[str, Any]) -> list[dict[str, Any]]: + """The languages worth naming: the largest few that are code, leaving out the negligible ones.""" + code = [one for one in cloc["languages"] if one["name"] not in NOT_CODE] + counted = sum(one["code"] for one in code) or 1 + + return [ + one + for rank, one in enumerate(code[:MOST_LANGUAGES]) + if rank < FEWEST_LANGUAGES or one["code"] / counted >= MINIMUM_SHARE + ] diff --git a/report_generator/context.py b/report_generator/context.py index 2fc90f2..6577cac 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any from .vendor.cipherverifier.core.services.sarif import _build_finding, _build_report, _rules_by_id from .vendor.cipherverifier.core.services.vdr import summarize_vdr +from .cloc import main_languages from .layout import TITLE from .severity import finding_severity @@ -79,9 +80,9 @@ def build_context( "sections": [_tool_section(name, report) for name, report in tools], } - # Without the pipeline's metadata this page would be nothing but gaps. - if metadata: - context["project"] = _project(metadata, project) + # Without the pipeline's metadata or cloc's count this page would be nothing but gaps. + if metadata or documents.get("cloc"): + context["project"] = _project(metadata, project, documents.get("cloc")) critical = _critical(tools, sca) @@ -240,24 +241,36 @@ def _version(metadata: dict[str, Any]) -> str | None: return commit if isinstance(commit, str) and _COMMIT.fullmatch(commit) else None -def _project(metadata: dict[str, Any], project: str | None) -> dict[str, Any]: - """What was scanned.""" +def _project(metadata: dict[str, Any], project: str | None, cloc: dict[str, Any] | None) -> dict[str, Any]: + """What was scanned, and how much of it there is, as cloc counted it.""" return { "id": "project", "title": "Project Information", "overview": [ _fact("Project", project), _fact("Version", _version(metadata), kind="mono"), - # Nothing in the pipeline counts lines, so nothing here can report them. - _fact("Analysed lines of code", None), + _fact("Analysed lines of code", f"{cloc['code']:,} in {cloc['files']:,} files" if cloc else None), ], - # Left as one unstated row rather than a table of zeroes: no stage reports a language - # breakdown, and a share invented from the file extensions of the findings would be a - # measurement of where the bugs are, not of what the project is written in. - "languages": {"title": "Languages", "rows": [{"label": UNKNOWN, "share": UNKNOWN}]}, + "languages": {"title": "Main languages", "rows": _languages(cloc)}, } +def _languages(cloc: dict[str, Any] | None) -> list[dict[str, Any]]: + """The main languages with their lines of code and share of the whole project. + + Left as one unstated row rather than a table of zeroes where cloc did not run: a share invented + from the file extensions of the findings would measure where the bugs are, not what the project + is written in. + """ + if not cloc: + return [{"label": UNKNOWN, "share": UNKNOWN}] + + return [ + {"label": one["name"], "share": f"{one['code']:,} lines ({one['code'] / cloc['code'] * 100:.1f} %)"} + for one in main_languages(cloc) + ] + + def _summary( tools: list[tuple[str, SarifReport]], sca: VdrReport | None, metrics: dict[str, Any] | None ) -> dict[str, Any]: diff --git a/report_generator/rendering.py b/report_generator/rendering.py index c3c1cd8..e37d30f 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any from .vendor.cc_common.api import PipelineError from .vendor.cc_common.api import context as logging_context +from .cloc import parse_cloc from .context import build_context from .layout import render_pdf @@ -72,6 +73,7 @@ def render( """ documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()} documents["metrics"] = metrics + documents["cloc"] = parse_cloc(sources.first("cloc.txt")) 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." -- 2.56.0 From b96b4315f177626d9f7c77e2bedf565def1e1a0e Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:20:06 +0200 Subject: [PATCH 11/17] Put the SBOM vulnerabilities before the per-tool sections Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- report_generator/context.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6eba5f6..6513f08 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ project page is 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, +3. the dependency vulnerabilities from the VDR, +4. one section per SARIF run, named after its tool (e.g. CipherCell Lynkeus, CipherCell SAST), 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. diff --git a/report_generator/context.py b/report_generator/context.py index 6577cac..6eb5d7e 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -90,7 +90,7 @@ def build_context( context["critical"] = critical if sca is not None: - context["sections"].append(_sca_section(sca)) + context["sections"].insert(0, _sca_section(sca)) set_aside = _set_aside_section(tools) -- 2.56.0 From e3b477d677c3db5b324e4ae05d4cd4abaea23c18 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:25:22 +0200 Subject: [PATCH 12/17] Lead the cover with manufacturer and project, end with the scan id Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/report_generator/context.py b/report_generator/context.py index 6eb5d7e..a2c35b1 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -191,12 +191,12 @@ def _cover( "logo": "cc_logo.png", "title": TITLE, "identity": [ - _fact("Scan Identifier", sources.scan_id, kind="mono"), - _fact("Technology", ", ".join(technologies) or None), - _fact("Project", project, gap=True), - _fact("Version", _version(metadata), kind="mono"), - _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Manufacturer", manufacturer), + _fact("Project", project), + _fact("Version", _version(metadata), kind="mono"), + _fact("Technology", ", ".join(technologies) or None, gap=True), + _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), + _fact("Scan Identifier", sources.scan_id, kind="mono"), ], "severity_ledger": _severity_ledger(tools, sca), } -- 2.56.0 From cce3e85e9dfd79fc14b3999b6490ca91de6c77b5 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:25:22 +0200 Subject: [PATCH 13/17] Drop the CVE metrics the artifacts cannot answer Time to exploit, CVE volume trends, CWE abstraction level and CWE relationships need exploitation history, earlier scans or the CWE catalog, none of which the VDR carries, so they only ever showed . Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/report_generator/context.py b/report_generator/context.py index a2c35b1..e683f38 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -345,13 +345,11 @@ def _metric_cards(dependencies: dict[str, Any]) -> list[dict[str, Any]]: }, { "title": "Temporal", - "note": "Published dates and vulnerability age are calculated from the advisory timestamps. Time-to-exploit and volume trends require historical exploitation or scan data that this export does not contain.", + "note": "Published dates and vulnerability age are calculated from the advisory timestamps.", "rows": [ _fact("Newest published", _published(published.get("newest"))), _fact("Mean vulnerability age", _days(published.get("mean_days"))), _fact("Median vulnerability age", _days(published.get("median_days"))), - _fact("Time to exploit", temporal.get("time_to_exploit_days")), - _fact("CVE volume trends", temporal.get("cve_volume_trends")), ], }, { @@ -361,8 +359,6 @@ def _metric_cards(dependencies: dict[str, Any]) -> list[dict[str, Any]]: _fact("Affected dependencies", surface.get("products")), _fact("Potential remedies", _remedy(upgrades)), _fact("CWE coverage", taxonomy.get("coverage")), - _fact("CWE abstraction level", taxonomy.get("abstraction_level")), - _fact("CWE relationships", taxonomy.get("relationships")), ], }, ] -- 2.56.0 From 303bcdf0b479afff3257cd73d71abf78e615c5d6 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:31:37 +0200 Subject: [PATCH 14/17] Report CipherCell SCA first, then Lynkeus, then SAST, everywhere The dependency check is named CipherCell SCA throughout, and the sources keep one order on the cover, in the summary, the most-critical table and the sections. Lynkeus before SAST no longer depends on the run order in the SARIF file. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- report_generator/context.py | 99 +++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 6513f08..65e0163 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ project page is 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. the dependency vulnerabilities from the VDR, -4. one section per SARIF run, named after its tool (e.g. CipherCell Lynkeus, CipherCell SAST), +3. the dependency vulnerabilities from the VDR, as CipherCell SCA, +4. one section per SARIF run, named after its tool: CipherCell Lynkeus, then CipherCell SAST, then + any other tool in the order its run appears, 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. diff --git a/report_generator/context.py b/report_generator/context.py index e683f38..8ff8768 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -44,6 +44,13 @@ BARS = 10 SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") +# What the dependency check is called wherever the report names its source. +SCA = "CipherCell SCA" + +# The order the sources are reported in, everywhere: the dependency check first, then the SARIF +# tools in this order. A tool not named here follows them, in the order its run appears. +TOOL_ORDER = ("CipherCell Lynkeus", "CipherCell SAST") + # A full git commit SHA. _COMMIT = re.compile(r"[0-9a-f]{40}") @@ -77,7 +84,8 @@ def build_context( context: dict[str, Any] = { "cover": _cover(sources, metadata, tools, sca, project=project, manufacturer=manufacturer), "summary": _summary(tools, sca, documents.get("metrics")), - "sections": [_tool_section(name, report) for name, report in tools], + "sections": ([_sca_section(sca)] if sca is not None else []) + + [_tool_section(name, report) for name, report in tools], } # Without the pipeline's metadata or cloc's count this page would be nothing but gaps. @@ -89,9 +97,6 @@ def build_context( if critical["table"]["rows"]: context["critical"] = critical - if sca is not None: - context["sections"].insert(0, _sca_section(sca)) - set_aside = _set_aside_section(tools) if set_aside is not None: @@ -109,7 +114,9 @@ def _tools(sarif: Any) -> list[tuple[str, SarifReport]]: # noqa: ANN401 runs = [run for run in sarif.get("runs") or [] if isinstance(run, dict)] - return [(_tool_name(run), _summarize(run)) for run in runs] + tools = [(_tool_name(run), _summarize(run)) for run in runs] + + return sorted(tools, key=lambda pair: TOOL_ORDER.index(pair[0]) if pair[0] in TOOL_ORDER else len(TOOL_ORDER)) def _summarize(run: dict[str, Any]) -> SarifReport: @@ -182,7 +189,7 @@ def _cover( manufacturer: str | None, ) -> dict[str, Any]: """The first page.""" - technologies = [name for name, _ in tools] + (["CipherCell SCA"] if sca is not None else []) + technologies = ([SCA] if sca is not None else []) + [name for name, _ in tools] return { "scan_id": str(sources.scan_id), @@ -277,7 +284,7 @@ def _summary( """The counts, and where they concentrate.""" sca_total = sca.total if sca 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 []) + true_issues = ([f"{sca_total} {SCA}"] if sca else []) + [f"{len(_kept(report))} {name}" for name, report in tools] summary = { "id": "summary", @@ -386,18 +393,30 @@ def _remedy(value: dict[str, Any]) -> str | None: 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] + counted = [f"{sca.component_count} {SCA} affected components"] if sca is not None else [] - if sca is not None: - counted.append(f"{sca.component_count} affected components") - - return counted + return counted + [f"{len({one.rule_id for one in _kept(report)})} {name} rules" for name, report in tools] 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.""" + """Where the issues concentrate: by package for SCA, by rule for each SARIF tool.""" blocks = [] + if sca and sca.vulnerabilities: + blocks.append( + { + "title": f"Most affected components, true {SCA} issues ({sca.component_count})", + **_bars( + _group( + [(one, name) for one in sca.vulnerabilities for name in one.component_names], + lambda pair: pair[1], + lambda pair: _vdr_severity(pair[0]), + ), + mono=True, + ), + } + ) + for name, report in tools: findings = _kept(report) @@ -409,21 +428,6 @@ def _distributions(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) } ) - if sca and sca.vulnerabilities: - blocks.append( - { - "title": f"Most affected components, true SCA issues ({sca.component_count})", - **_bars( - _group( - [(one, name) for one in sca.vulnerabilities for name in one.component_names], - lambda pair: pair[1], - lambda pair: _vdr_severity(pair[0]), - ), - mono=True, - ), - } - ) - return blocks @@ -498,6 +502,23 @@ def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> di """The handful worth reading first, whichever tool found them.""" rows: list[tuple[str, dict[str, Any]]] = [] + for vulnerability in sca.vulnerabilities if sca else []: + severity = _vdr_severity(vulnerability) + rows.append( + ( + severity, + { + "cells": [ + SCA, + {"value": severity, "kind": "severity"}, + _stated(vulnerability.id), + {"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"}, + _stated(vulnerability.description), + ] + }, + ) + ) + for name, report in tools: for finding in _kept(report): severity = _finding_severity(finding) @@ -516,24 +537,8 @@ def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> di ) ) - for vulnerability in sca.vulnerabilities if sca else []: - severity = _vdr_severity(vulnerability) - rows.append( - ( - severity, - { - "cells": [ - "SCA", - {"value": severity, "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. + # Sorted is stable, so within a severity the sources keep the report's order, and each tool its own + # -- the rank, where it has one. rows.sort(key=lambda pair: SEVERITY_ORDER.index(pair[0])) return { @@ -634,7 +639,7 @@ def _sca_section(sca: VdrReport) -> dict[str, Any]: """The dependency findings.""" return { "id": "sca", - "title": "Dependency Vulnerabilities (SBOM / SCA)", + "title": f"{SCA} — Dependency Vulnerabilities", "new_page": True, "groups": [ { -- 2.56.0 From ffcd68f9f8ab8507470ee06fc33516b3db6dadef Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:31:37 +0200 Subject: [PATCH 15/17] Add a CipherCell header to every page but the cover Co-Authored-By: Claude Opus 5.5 --- report_generator/template/report.css | 27 ++++++++++++++++++++++++ report_generator/template/report.html.j2 | 1 + 2 files changed, 28 insertions(+) diff --git a/report_generator/template/report.css b/report_generator/template/report.css index 0b53d08..c0e5585 100644 --- a/report_generator/template/report.css +++ b/report_generator/template/report.css @@ -62,6 +62,13 @@ size: A4; margin: 22mm 18mm 18mm 18mm; + @top-center { + content: element(page-header); + width: 100%; + vertical-align: bottom; + padding-bottom: 4mm; + } + @bottom-left { content: string(scan-footer); font-family: "Space Grotesk", sans-serif; @@ -101,6 +108,26 @@ code, font-size: 0.93em; } +/* The cover carries the mark large already; a second, small one above it would only repeat it. */ +@page :first { + @top-center { + content: none; + } +} + +/* The mark -- the logo and the company name, which the image carries together -- above every page, + ruled off from the content. */ +.page-header { + position: running(page-header); + padding-bottom: 1.6mm; + border-bottom: 0.4pt solid var(--border); +} + +.page-header img { + height: 5mm; + display: block; +} + /* Carried into the running footer on every page. Set once, on the cover. */ .footer-scan { string-set: scan-footer content(); diff --git a/report_generator/template/report.html.j2 b/report_generator/template/report.html.j2 index f6d3b74..1562099 100644 --- a/report_generator/template/report.html.j2 +++ b/report_generator/template/report.html.j2 @@ -98,6 +98,7 @@ {# ------------------------------------------------------------------------------------------ #} + Scan: {{ cover.scan_id }} {{ cover.footer_date }} -- 2.56.0 From 0ace2e18035e66b63dd4043abfc95516d2af4f60 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Fri, 25 Sep 2026 12:22:45 +0200 Subject: [PATCH 16/17] Let the caller state the project's version --version replaces the commit hash on the cover and the project page, for generate and from-folder alike. Without it the report still falls back to repository_version from the metadata, else the scanned commit. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++++-- report_generator/commands/generate.py | 15 ++++++++++++++- report_generator/context.py | 15 +++++++++------ report_generator/rendering.py | 4 +++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 65e0163..d4c618c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Two ways to say where the artifacts are. ``` report_generator generate --scan_id [--path ] --config config.json - [--project ] [--manufacturer ] + [--project ] [--manufacturer ] [--version ] ``` **Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported @@ -31,9 +31,12 @@ them: ``` report_generator from-folder --artifacts ./sample [--path ] - [--project ] [--manufacturer ] + [--project ] [--manufacturer ] [--version ] ``` +`--version` sets the version shown on the cover and the project page. Without it the report uses +`repository_version` from `metadata.json`, else the commit the scan was run on. + `--scan_id` is optional there: the pipeline writes its own id into the paths it records in `metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one whose metadata is gone. diff --git a/report_generator/commands/generate.py b/report_generator/commands/generate.py index 7c04422..c9a800f 100644 --- a/report_generator/commands/generate.py +++ b/report_generator/commands/generate.py @@ -34,6 +34,8 @@ class GenerateProperties(BaseModel): the renderer chose; leaving it out writes into ``reports/``. project: The name of the scanned project, as the report states it. manufacturer: Who makes the scanned product, as the report states it. + version: The version of the scanned project, as the report states it. + Replaces the commit hash the metadata names when given. storage_config: The store the scan's artifacts are read from. """ @@ -41,6 +43,9 @@ class GenerateProperties(BaseModel): path: Path | None = Field(default=None, description="Where to write the PDF report.") project: str | None = Field(default=None, description="The name of the scanned project.") manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.") + version: str | None = Field( + default=None, description="The version of the scanned project. Replaces the commit hash when given." + ) storage_config: StorageConfiguration @@ -54,6 +59,7 @@ class FolderProperties(BaseModel): path: Where to write the PDF, as above. project: The name of the scanned project, as above. manufacturer: Who makes the scanned product, as above. + version: The version of the scanned project, as above. """ artifacts: Path = Field(description="Folder of exported artifacts to build the report from.") @@ -63,6 +69,9 @@ class FolderProperties(BaseModel): path: Path | None = Field(default=None, description="Where to write the PDF report.") project: str | None = Field(default=None, description="The name of the scanned project.") manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.") + version: str | None = Field( + default=None, description="The version of the scanned project. Replaces the commit hash when given." + ) class GeneratedReport(BaseModel): @@ -102,6 +111,7 @@ def generate(properties: GenerateProperties) -> GeneratedReport: properties.path, project=properties.project, manufacturer=properties.manufacturer, + version=properties.version, ) @@ -125,6 +135,7 @@ def from_folder(properties: FolderProperties) -> GeneratedReport: properties.path, project=properties.project, manufacturer=properties.manufacturer, + version=properties.version, write_metrics=True, ) @@ -135,6 +146,7 @@ def _report( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, write_metrics: bool = False, ) -> GeneratedReport: """Render the collected artifacts and write the result. @@ -147,6 +159,7 @@ def _report( path: Where the caller asked for the PDF, if anywhere in particular. project: The project name the caller supplied, if any. manufacturer: The manufacturer the caller supplied, if any. + version: The version the caller supplied, if any. write_metrics: Whether to write ``metrics.json`` beside the PDF. Returns: @@ -156,7 +169,7 @@ def _report( PipelineError: The report cannot be built or the destination not written. """ metrics = build_metrics(sources) - name, content = render(sources, metrics, project=project, manufacturer=manufacturer) + name, content = render(sources, metrics, project=project, manufacturer=manufacturer, version=version) destination = _destination(path, name) try: diff --git a/report_generator/context.py b/report_generator/context.py index 8ff8768..eb35c2a 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -61,6 +61,7 @@ def build_context( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, ) -> dict[str, Any]: """Build the report context from a scan's parsed artifacts. @@ -70,6 +71,7 @@ def build_context( ``vulnerabilities`` -- each absent where the scan stored none. project: The project name the caller supplied. Wins over the one in the metadata. manufacturer: The manufacturer the caller supplied. The artifacts never name one. + version: The version the caller supplied. Wins over the one in the metadata. Returns: The context :func:`layout.render_pdf` @@ -78,11 +80,12 @@ def build_context( """ metadata = documents.get("metadata") or {} project = project or (metadata.get("configuration") or {}).get("repository_name") + version = version or _version(metadata) tools = _tools(documents.get("findings")) sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None context: dict[str, Any] = { - "cover": _cover(sources, metadata, tools, sca, project=project, manufacturer=manufacturer), + "cover": _cover(sources, tools, sca, project=project, manufacturer=manufacturer, version=version), "summary": _summary(tools, sca, documents.get("metrics")), "sections": ([_sca_section(sca)] if sca is not None else []) + [_tool_section(name, report) for name, report in tools], @@ -90,7 +93,7 @@ def build_context( # Without the pipeline's metadata or cloc's count this page would be nothing but gaps. if metadata or documents.get("cloc"): - context["project"] = _project(metadata, project, documents.get("cloc")) + context["project"] = _project(project, version, documents.get("cloc")) critical = _critical(tools, sca) @@ -181,12 +184,12 @@ def _stated(value: Any) -> str: # noqa: ANN401 def _cover( sources: ReportSources, - metadata: dict[str, Any], tools: list[tuple[str, SarifReport]], sca: VdrReport | None, *, project: str | None, manufacturer: str | None, + version: str | None, ) -> dict[str, Any]: """The first page.""" technologies = ([SCA] if sca is not None else []) + [name for name, _ in tools] @@ -200,7 +203,7 @@ def _cover( "identity": [ _fact("Manufacturer", manufacturer), _fact("Project", project), - _fact("Version", _version(metadata), kind="mono"), + _fact("Version", version, kind="mono"), _fact("Technology", ", ".join(technologies) or None, gap=True), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Scan Identifier", sources.scan_id, kind="mono"), @@ -248,14 +251,14 @@ def _version(metadata: dict[str, Any]) -> str | None: return commit if isinstance(commit, str) and _COMMIT.fullmatch(commit) else None -def _project(metadata: dict[str, Any], project: str | None, cloc: dict[str, Any] | None) -> dict[str, Any]: +def _project(project: str | None, version: str | None, cloc: dict[str, Any] | None) -> dict[str, Any]: """What was scanned, and how much of it there is, as cloc counted it.""" return { "id": "project", "title": "Project Information", "overview": [ _fact("Project", project), - _fact("Version", _version(metadata), kind="mono"), + _fact("Version", version, kind="mono"), _fact("Analysed lines of code", f"{cloc['code']:,} in {cloc['files']:,} files" if cloc else None), ], "languages": {"title": "Main languages", "rows": _languages(cloc)}, diff --git a/report_generator/rendering.py b/report_generator/rendering.py index e37d30f..44e116b 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -53,6 +53,7 @@ def render( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, ) -> tuple[str, bytes]: """Render the report of one scan. @@ -61,6 +62,7 @@ def render( metrics: The metrics document, if one was built. project: The project name the caller supplied; overrides the metadata's. manufacturer: The manufacturer the caller supplied. + version: The version the caller supplied; overrides the commit the metadata names. Returns: The file name the report should be saved under, and its bytes. @@ -83,7 +85,7 @@ def render( logging_context.logger.info(f"Rendering the report from {', '.join(found)}...") - context = build_context(sources, documents, project=project, manufacturer=manufacturer) + context = build_context(sources, documents, project=project, manufacturer=manufacturer, version=version) return NAME.format(scan=sources.scan_id), render_pdf(context) -- 2.56.0 From 864e2ff8242902aa061ff9a42d55d96080c76f83 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Fri, 25 Sep 2026 12:22:45 +0200 Subject: [PATCH 17/17] Add a summary report set from Markdown from-markdown takes a hand-written summary such as SUMMARY.md and sets it in the full report's dress: the same cover, header, footers, table of contents, headings and grids. The shared macros move to macros.html.j2 so both templates draw from them, and layout.render_pdf takes the template and document title to use. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 +++ pyproject.toml | 2 + report_generator/__main__.py | 7 + report_generator/commands/summary.py | 94 +++++++++ report_generator/layout.py | 13 +- report_generator/summary.py | 239 ++++++++++++++++++++++ report_generator/template/macros.html.j2 | 92 +++++++++ report_generator/template/report.css | 67 ++++++ report_generator/template/report.html.j2 | 88 +------- report_generator/template/summary.html.j2 | 36 ++++ 10 files changed, 573 insertions(+), 92 deletions(-) create mode 100644 report_generator/commands/summary.py create mode 100644 report_generator/summary.py create mode 100644 report_generator/template/macros.html.j2 create mode 100644 report_generator/template/summary.html.j2 diff --git a/README.md b/README.md index d4c618c..5aa3fb5 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,32 @@ by the one naming convention in `cc_common.config.env_var_name`, so it stays wha calls it. A `.env` beside the `pyproject.toml` is read too. `--config` is resolved relative to the working directory. +## The summary report + +A shorter document, written by hand in Markdown (like `SUMMARY.md` beside the scan's artifacts) and +set in the dress of the full report: the same cover, header, footers, table of contents, headings +and tables. + +``` +report_generator from-markdown --markdown SUMMARY.md [--path ] + [--project ] [--manufacturer ] [--scan_id ] +``` + +How the Markdown is read (`report_generator/summary.py`): + +- The first `#` heading is the title. Written as `: <subject>`, the subject is the project + unless `--project` is given. +- What comes before the first section is the introduction, and goes on the cover. +- Every further `#` or `##` heading is a section: it starts a new page, is listed in the table of + contents and keeps the anchor GitHub gives it, so links such as `[…](#1-static-analysis-sast)` + work in the PDF. `###` and below become the report's subheadings. +- A paragraph that is only bold text (`**Remaining findings**`) is set as a subheading. +- Tables are drawn as the report's grids. A number in a column headed *Severity*, *CVSS* or *Score* + carries its severity colour, and a row starting with `**Total**` is set as a total. + +The scan id is optional here; without it the cover leaves it out and the footer names the project. +The file is written as `summary_<scan-id>.pdf`, or `summary_<project>.pdf` without one. + ## How it is put together Four steps, each its own module, so that the one that will change is the one that is separate: @@ -95,6 +121,7 @@ Four steps, each its own module, so that the one that will change is the one tha | `report_generator/rendering.py` | Decides which stored file feeds which part of the report, and parses it. | | `report_generator/context.py` | Maps the parsed artifacts onto the page, and writes `<xxx>` where they say nothing. | | `report_generator/layout.py` | Draws `template/report.html.j2` through WeasyPrint. | +| `report_generator/summary.py` | Sets a Markdown summary in `template/summary.html.j2`, drawn the same way. | No parsing of its own. SARIF and the vulnerability report are read by the summarisers in `cipherverifier.core.services`, which are written to be free of Django and of any store; the CVSS diff --git a/pyproject.toml b/pyproject.toml index ec75bf6..633023d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ dependencies = [ # needs the native Pango and cairo libraries, which pip does not install -- see the README. "jinja2>=3.1.6", "weasyprint>=69.0", + # The summary report, which is written in Markdown and set in the same template. + "markdown-it-py>=4.0.0", ] [project.optional-dependencies] diff --git a/report_generator/__main__.py b/report_generator/__main__.py index 13f851c..e810fea 100644 --- a/report_generator/__main__.py +++ b/report_generator/__main__.py @@ -5,6 +5,7 @@ from .vendor.cc_common.config import load_environment from .vendor.persistent_storage.shared import inject_secrets from .commands.generate import FolderProperties, GenerateProperties, from_folder, generate +from .commands.summary import SummaryProperties, from_markdown def run() -> None: @@ -25,6 +26,12 @@ def run() -> None: FolderProperties, help="Builds the report from a folder of exported artifacts, without Postgres or the bucket.", ) + cli.register_command( + "from-markdown", + from_markdown, + SummaryProperties, + help="Builds a summary report from a Markdown file, in the style of the full report.", + ) cli.run() diff --git a/report_generator/commands/summary.py b/report_generator/commands/summary.py new file mode 100644 index 0000000..ddd079b --- /dev/null +++ b/report_generator/commands/summary.py @@ -0,0 +1,94 @@ +"""The summary report: a Markdown summary, set as a PDF in the style of the full report.""" + +from pathlib import Path +from uuid import UUID + +from pydantic import BaseModel, Field + +from ..vendor.cc_common.api import PipelineError, context +from ..vendor.cc_common.utils import print + +from ..summary import render_summary +from .generate import _destination + + +class SummaryProperties(BaseModel): + """Configuration properties for the from-markdown CLI command. + + Attributes: + markdown: The Markdown summary to set. + scan_id: The scan it is about. Named on the cover and in the footers + when given, and left out when not. + path: Where to write the PDF. A directory is written into under the name + the renderer chose; leaving it out writes into ``reports/``. + project: The name of the scanned project. Taken from the summary's title + when not given. + manufacturer: Who makes the scanned product, as the summary states it. + """ + + markdown: Path = Field(description="The Markdown summary to build the PDF from.") + scan_id: UUID | None = Field(default=None, description="The scan the summary is about.") + path: Path | None = Field(default=None, description="Where to write the PDF summary.") + project: str | None = Field( + default=None, description="The name of the scanned project. Read from the title when omitted." + ) + manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.") + + +class GeneratedSummary(BaseModel): + """The summary that was written. + + Attributes: + source: The Markdown it was set from. + path: The file written. + size: How large it came out, in bytes. + """ + + source: Path + path: Path + size: int + + def print_as_cc_common_result(self) -> None: + """Say what was written and where.""" + print(f"[bold]Summary from {self.source}[/bold]") + print(f" {self.size / 1024:.0f} kB written to {self.path}") + + +def from_markdown(properties: SummaryProperties) -> GeneratedSummary: + """Build the summary report from a Markdown file. + + Args: + properties: The Markdown file, what to say about it, and the destination. + + Returns: + The summary that was written. + + Raises: + PipelineError: The Markdown cannot be read, or the destination cannot be written. + """ + try: + markdown = properties.markdown.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + message = f"Cannot read the summary '{properties.markdown}': {error}" + + raise PipelineError(message) from error + + name, content = render_summary( + markdown, + scan_id=properties.scan_id, + project=properties.project, + manufacturer=properties.manufacturer, + ) + destination = _destination(properties.path, name) + + try: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + except OSError as error: + message = f"Cannot write the summary to '{destination}': {error}" + + raise PipelineError(message) from error + + context.logger.info(f"Summary written to {destination} ✔") + + return GeneratedSummary(source=properties.markdown, path=destination, size=len(content)) diff --git a/report_generator/layout.py b/report_generator/layout.py index 944370f..0aaa755 100644 --- a/report_generator/layout.py +++ b/report_generator/layout.py @@ -39,11 +39,12 @@ CONTEXT = { } -def render_html(context: dict[str, Any]) -> str: +def render_html(context: dict[str, Any], template: str = TEMPLATE_NAME) -> str: """Render the report as an HTML document. Args: context: The page-ready context; see :data:`CONTEXT`. + template: Which template in :data:`TEMPLATE_DIR` to draw it with. Returns: The rendered HTML. @@ -55,14 +56,16 @@ def render_html(context: dict[str, Any]) -> str: lstrip_blocks=True, ) - return environment.get_template(TEMPLATE_NAME).render(**context) + return environment.get_template(template).render(**context) -def render_pdf(context: dict[str, Any]) -> bytes: +def render_pdf(context: dict[str, Any], template: str = TEMPLATE_NAME, title: str = TITLE) -> bytes: """Render the report as a PDF. Args: context: The page-ready context; see :data:`CONTEXT`. + template: Which template in :data:`TEMPLATE_DIR` to draw it with. + title: What the document calls itself in its metadata. Returns: The PDF. @@ -78,12 +81,12 @@ def render_pdf(context: dict[str, Any]) -> bytes: # Relative URLs in the template and the stylesheet -- the mark, the font files -- resolve # against the template directory. - document = HTML(string=render_html(context), base_url=str(TEMPLATE_DIR)).render( + document = HTML(string=render_html(context, template), base_url=str(TEMPLATE_DIR)).render( stylesheets=[CSS(filename=str(STYLESHEET), font_config=fonts)], font_config=fonts, ) - document.metadata.title = f"{TITLE} - {context.get('cover', {}).get('subject', '')}".strip(" -") + document.metadata.title = f"{title} - {context.get('cover', {}).get('subject', '')}".strip(" -") document.metadata.authors = [AUTHOR] return bytes(document.write_pdf()) diff --git a/report_generator/summary.py b/report_generator/summary.py new file mode 100644 index 0000000..8bfec4d --- /dev/null +++ b/report_generator/summary.py @@ -0,0 +1,239 @@ +"""Turns a Markdown summary into the summary report. + +The summary is written by hand, beside the scan's artifacts, and reads as a document of its own: a +title, a few lines saying what it covers, then one section per source. This module sets it in the +dress of the full report -- the same cover, running header and footers, table of contents, headings +and tables -- so the two sit together as one series. + +No design of its own. The Markdown is rendered to HTML whose elements are the ones ``report.css`` +already styles, by moving them onto the full report's heading levels and classes: + +- The first level-one heading is the title. Written as ``<title>: <subject>``, the subject is the + project, unless one is given. +- What comes before the first section is the introduction, and goes on the cover. +- Every further level-one or level-two heading is a section: it starts a new page, as the full + report's sections do, is listed in the table of contents and keeps the anchor GitHub would give it, so the links a + summary makes to its own sections go on working in the PDF. +- A paragraph that is nothing but bold text is the subheading it is being used as. +- Tables are drawn as the full report's grids. A number in a severity or CVSS column carries its + severity colour, and a row that opens with a bold ``Total`` is set as a total. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from markdown_it import MarkdownIt +from markupsafe import Markup + +from .context import UNKNOWN, _fact, _stated +from .layout import render_pdf +from .severity import _score_band + +if TYPE_CHECKING: + from uuid import UUID + + from markdown_it.token import Token + +TEMPLATE_NAME = "summary.html.j2" + +# What the document calls itself when the Markdown does not say. +TITLE = "Security Scan Summary" + +# The shape the summary template expects. +CONTEXT = { + "cover": "subject, footer, footer_date, logo, title, identity[], intro?", + "contents": "[{id, title}]", + "body": "the rendered sections", +} + +# What the file is called: after the scan where there is one, else after the project. +NAME = "summary_{name}.pdf" + +# A column whose numbers are CVSS scores, to be coloured by the band each falls in. +_SCORED_COLUMN = re.compile(r"severity|cvss|score", re.IGNORECASE) + +_SCORE = re.compile(r"\d+(?:\.\d+)?") + +# Where Markdown's heading levels land on the full report's: a section is its ``h1``, and what is +# under it one and two steps down. +_LEVELS = {"h1": "h1", "h2": "h1", "h3": "h2", "h4": "h3", "h5": "h3", "h6": "h3"} + + +def render_summary( + markdown: str, + *, + scan_id: UUID | None = None, + project: str | None = None, + manufacturer: str | None = None, +) -> tuple[str, bytes]: + """Render a Markdown summary as a PDF. + + Args: + markdown: The summary. + scan_id: The scan it is about, if it is to say so. + project: The project name; overrides the subject of the title. + manufacturer: Who makes the scanned product. + + Returns: + The file name the summary should be saved under, and its bytes. + """ + context = build_summary_context(markdown, scan_id=scan_id, project=project, manufacturer=manufacturer) + name = str(scan_id) if scan_id else _slug(context["cover"]["subject"]) or "report" + + return NAME.format(name=name), render_pdf(context, TEMPLATE_NAME, context["cover"]["title"]) + + +def build_summary_context( + markdown: str, + *, + scan_id: UUID | None = None, + project: str | None = None, + manufacturer: str | None = None, +) -> dict[str, Any]: + """Map a Markdown summary onto the page. + + Args: + markdown: The summary. + scan_id: The scan it is about, if it is to say so. + project: The project name; overrides the subject of the title. + manufacturer: Who makes the scanned product. + + Returns: + The page-ready context; see :data:`CONTEXT`. + """ + parser = MarkdownIt("commonmark").enable(["table", "strikethrough"]) + tokens = parser.parse(markdown) + + heading = _title(tokens) + title, _, subject = heading.partition(":") if heading else (TITLE, "", "") + project = project or subject.strip() or None + + contents = _sections(tokens, parser) + _subheadings(tokens) + _tables(tokens) + + start = next((index for index, token in enumerate(tokens) if token.meta.get("section")), len(tokens)) + intro, body = tokens[:start], tokens[start:] + + render = parser.renderer.render + identity = [ + _fact("Manufacturer", manufacturer), + _fact("Project", project), + _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), + ] + if scan_id: + identity.append(_fact("Scan Identifier", scan_id, kind="mono")) + + return { + "cover": { + "subject": _stated(project), + "footer": f"Scan: {scan_id}" if scan_id else _stated(project), + "footer_date": datetime.now(UTC).strftime("%d %B %Y"), + "logo": "cc_logo.png", + "title": title.strip(), + "identity": identity, + "intro": Markup(render(intro, parser.options, {})) if intro else None, # noqa: S704 + }, + "contents": contents, + "body": Markup(render(body, parser.options, {})), # noqa: S704 + } + + +def _title(tokens: list[Token]) -> str | None: + """Take the first level-one heading out of the document, and say what it read.""" + for index, token in enumerate(tokens): + if token.type == "heading_open" and token.tag == "h1": + text = tokens[index + 1].content + del tokens[index : index + 3] + + return text + + return None + + +def _sections(tokens: list[Token], parser: MarkdownIt) -> list[dict[str, Any]]: + """Move every heading onto the full report's levels, and list the sections for the contents.""" + contents: list[dict[str, Any]] = [] + taken: set[str] = set() + + for index, token in enumerate(tokens): + if token.type not in ("heading_open", "heading_close"): + continue + + section = token.tag in ("h1", "h2") + token.tag = _LEVELS[token.tag] + if token.type == "heading_close" or not section: + continue + + inline = tokens[index + 1] + anchor = _unique(_github_anchor(inline.content), taken) + token.attrSet("id", anchor) + token.meta["section"] = True + # As in the full report, every section opens a page of its own. + token.attrSet("style", "break-before: page") + title = Markup(parser.renderer.render(inline.children or [], parser.options, {})) # noqa: S704 + contents.append({"id": anchor, "title": title}) + + return contents + + +def _subheadings(tokens: list[Token]) -> None: + """Set a paragraph that is nothing but bold text as the subheading it stands in for.""" + for index, token in enumerate(tokens): + if token.type != "paragraph_open" or token.hidden: + continue + + children = tokens[index + 1].children or [] + kinds = [child.type for child in children] + if kinds == ["strong_open", "text", "strong_close"]: + token.tag = tokens[index + 2].tag = "h2" + tokens[index + 1].children = [children[1]] + + +def _tables(tokens: list[Token]) -> None: + """Draw tables as the full report's grids: scores coloured, totals set as totals.""" + columns: list[str] = [] + column = 0 + + for index, token in enumerate(tokens): + if token.type == "table_open": + token.attrSet("class", "grid") + columns = [] + elif token.type == "th_open": + columns.append(tokens[index + 1].content) + elif token.type == "tr_open": + column = 0 + cell = tokens[index + 2] if tokens[index + 1].type == "td_open" else None + if cell is not None and cell.content.strip() == "**Total**": + token.attrSet("class", "total") + elif token.type == "td_open": + header = columns[column] if column < len(columns) else "" + content = tokens[index + 1].content.strip() + band = _score_band(content) if _SCORED_COLUMN.search(header) and _SCORE.fullmatch(content) else None + if band: + token.attrSet("class", f"sev sev-{band.lower()}") + column += 1 + + +def _github_anchor(text: str) -> str: + """The anchor GitHub gives a heading, so the links a summary makes to itself still land.""" + return re.sub(r"[^\w\- ]", "", text.strip().lower()).replace(" ", "-") + + +def _unique(anchor: str, taken: set[str]) -> str: + """The anchor, numbered as GitHub numbers a second heading of the same name.""" + candidate, number = anchor or "section", 0 + while candidate in taken: + number += 1 + candidate = f"{anchor}-{number}" + taken.add(candidate) + + return candidate + + +def _slug(text: str) -> str: + """A name fit for a file.""" + return "" if text == UNKNOWN else re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") diff --git a/report_generator/template/macros.html.j2 b/report_generator/template/macros.html.j2 new file mode 100644 index 0000000..e482ef6 --- /dev/null +++ b/report_generator/template/macros.html.j2 @@ -0,0 +1,92 @@ +{# + The pieces the report templates share: `report.html.j2`, the full report, and `summary.html.j2`, + the summary set from Markdown. Kept apart so that the two documents draw a label, a value or a + table the same way, and a change to one is a change to both. +#} +{# A value is drawn by what it is, not by where it sits: an identifier, a path or a CVSS vector is + set in the monospace face wherever it appears, and a severity carries its colour. #} +{% macro value(row) %} + {% if row.token %}<span class="token">{{ row.token }}</span> — {% endif %} + {% if row.kind == "severity" %} + <span class="sev sev-{{ row.value|lower }}">{{ row.value }}</span> + {% elif row.kind == "mono" %} + <span class="mono">{{ row.value }}</span> + {% else %} + {{ row.value }} + {% endif %} +{% endmacro %} + +{% macro facts(rows, wide=false) %} + <table class="facts{% if wide %} wide{% endif %}"> + {% for row in rows %} + <tr{% if row.gap %} class="gap"{% endif %}> + <td>{{ row.label }}:</td> + <td>{{ value(row) }}</td> + </tr> + {% endfor %} + </table> +{% endmacro %} + +{% macro card(title, rows, note="") %} + {# Wrapped, because break-inside on the table does not hold its caption: the title would stay on + one page while the rows it titles moved to the next. #} + <div class="block"> + <table class="card"> + <caption>{{ title }}</caption> + {% for row in rows %} + <tr{% if row.aside %} class="aside"{% endif %}> + <th>{{ row.label }}</th> + <td>{{ value(row) }}</td> + </tr> + {% endfor %} + </table> + {% if note %}<p class="note card-note">{{ note }}</p>{% endif %} + </div> +{% endmacro %} + +{# Scaled against the busiest row, not against the total: what is read off a row of these is which + one is the outlier, and a scale set by the sum flattens all of them. #} +{% macro distribution(rows, legend) %} + {# The bars and the legend that decodes them are one thing; a break between them leaves a reader + with coloured segments and no key. #} + <div class="block"> + <table class="bars"> + {% for row in rows %} + <tr> + <td{% if row.mono %} class="mono"{% endif %}>{{ row.label }}</td> + <td class="count">{{ row.count }}</td> + <td class="track"> + <div class="bar"> + {% for part in row.parts %} + <span class="seg-{{ part.severity|lower }}" style="width: {{ part.share }}%"></span> + {% endfor %} + </div> + </td> + </tr> + {% endfor %} + </table> + <div class="legend"> + {% for item in legend.entries %} + <span class="item"><span class="key seg-{{ item.severity|lower }}"></span>{{ item.label }}</span> + {% endfor %} + {% if legend.caveat %}<span class="caveat">— {{ legend.caveat }}</span>{% endif %} + </div> + </div> +{% endmacro %} + +{% macro grid(table) %} + <table class="grid"> + <thead> + <tr>{% for column in table.columns %}<th>{{ column }}</th>{% endfor %}</tr> + </thead> + <tbody> + {% for row in table.rows %} + <tr{% if row.total %} class="total"{% endif %}> + {% for cell in row.cells %} + <td>{% if cell is mapping %}{{ value(cell) }}{% else %}{{ cell }}{% endif %}</td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> +{% endmacro %} diff --git a/report_generator/template/report.css b/report_generator/template/report.css index c0e5585..b476c10 100644 --- a/report_generator/template/report.css +++ b/report_generator/template/report.css @@ -470,3 +470,70 @@ p.note { .toc a::after { content: leader(".") target-counter(attr(href), page); } + +/* --- Prose (the summary report) ----------------------------------------------------------- */ + +/* The summary is written in Markdown, so it brings what the full report never has: running text, + lists and links. Set to the full report's measure and rhythm, so the two read as one series. */ +.prose p, +.prose ul, +.prose ol, +.prose blockquote { + max-width: 165mm; +} + +/* Every section of the summary opens a page, so the space a heading keeps above it would only push + the page down. */ +.prose h1 { + margin-top: 0; +} + +.prose ul, +.prose ol { + margin: 0 0 3mm 0; + padding-left: 5mm; +} + +.prose li { + margin-bottom: 1.4mm; +} + +.prose li > p { + margin: 0; +} + +.prose a { + color: var(--ink); + text-decoration: underline; + text-decoration-color: var(--border); +} + +.prose blockquote { + margin: 0 0 3mm 0; + padding-left: 3mm; + border-left: 0.8pt solid var(--border); + color: var(--muted); +} + +.prose pre { + background: var(--label-fill); + border: 0.4pt solid var(--border); + padding: 2mm; + white-space: pre-wrap; + margin: 0 0 3mm 0; +} + +.prose hr { + border: none; + border-top: 0.4pt solid var(--border); + margin: 5mm 0; +} + +/* The severity colour, where it marks a score in a table, is on the cell itself. */ +.grid td.sev { + white-space: nowrap; +} + +.cover-intro { + margin-top: 9mm; +} diff --git a/report_generator/template/report.html.j2 b/report_generator/template/report.html.j2 index 1562099..ab763b7 100644 --- a/report_generator/template/report.html.j2 +++ b/report_generator/template/report.html.j2 @@ -8,93 +8,7 @@ The context this expects is described by `layout.CONTEXT`, and `sample.sample_context()` builds a complete one. #} -{# A value is drawn by what it is, not by where it sits: an identifier, a path or a CVSS vector is - set in the monospace face wherever it appears, and a severity carries its colour. #} -{% macro value(row) %} - {% if row.token %}<span class="token">{{ row.token }}</span> — {% endif %} - {% if row.kind == "severity" %} - <span class="sev sev-{{ row.value|lower }}">{{ row.value }}</span> - {% elif row.kind == "mono" %} - <span class="mono">{{ row.value }}</span> - {% else %} - {{ row.value }} - {% endif %} -{% endmacro %} - -{% macro facts(rows, wide=false) %} - <table class="facts{% if wide %} wide{% endif %}"> - {% for row in rows %} - <tr{% if row.gap %} class="gap"{% endif %}> - <td>{{ row.label }}:</td> - <td>{{ value(row) }}</td> - </tr> - {% endfor %} - </table> -{% endmacro %} - -{% macro card(title, rows, note="") %} - {# Wrapped, because break-inside on the table does not hold its caption: the title would stay on - one page while the rows it titles moved to the next. #} - <div class="block"> - <table class="card"> - <caption>{{ title }}</caption> - {% for row in rows %} - <tr{% if row.aside %} class="aside"{% endif %}> - <th>{{ row.label }}</th> - <td>{{ value(row) }}</td> - </tr> - {% endfor %} - </table> - {% if note %}<p class="note card-note">{{ note }}</p>{% endif %} - </div> -{% endmacro %} - -{# Scaled against the busiest row, not against the total: what is read off a row of these is which - one is the outlier, and a scale set by the sum flattens all of them. #} -{% macro distribution(rows, legend) %} - {# The bars and the legend that decodes them are one thing; a break between them leaves a reader - with coloured segments and no key. #} - <div class="block"> - <table class="bars"> - {% for row in rows %} - <tr> - <td{% if row.mono %} class="mono"{% endif %}>{{ row.label }}</td> - <td class="count">{{ row.count }}</td> - <td class="track"> - <div class="bar"> - {% for part in row.parts %} - <span class="seg-{{ part.severity|lower }}" style="width: {{ part.share }}%"></span> - {% endfor %} - </div> - </td> - </tr> - {% endfor %} - </table> - <div class="legend"> - {% for item in legend.entries %} - <span class="item"><span class="key seg-{{ item.severity|lower }}"></span>{{ item.label }}</span> - {% endfor %} - {% if legend.caveat %}<span class="caveat">— {{ legend.caveat }}</span>{% endif %} - </div> - </div> -{% endmacro %} - -{% macro grid(table) %} - <table class="grid"> - <thead> - <tr>{% for column in table.columns %}<th>{{ column }}</th>{% endfor %}</tr> - </thead> - <tbody> - {% for row in table.rows %} - <tr{% if row.total %} class="total"{% endif %}> - {% for cell in row.cells %} - <td>{% if cell is mapping %}{{ value(cell) }}{% else %}{{ cell }}{% endif %}</td> - {% endfor %} - </tr> - {% endfor %} - </tbody> - </table> -{% endmacro %} +{% from "macros.html.j2" import value, facts, card, distribution, grid %} {# ------------------------------------------------------------------------------------------ #} diff --git a/report_generator/template/summary.html.j2 b/report_generator/template/summary.html.j2 new file mode 100644 index 0000000..76d660d --- /dev/null +++ b/report_generator/template/summary.html.j2 @@ -0,0 +1,36 @@ +{# + The summary report, set from a Markdown file in the dress of the full report: the same cover, + running header and footers, table of contents and headings, with the body in between. + + The body arrives already rendered -- :mod:`summary` turns the Markdown into HTML whose elements + are the ones `report.css` already styles -- so this file only places it. The context this expects + is described by `summary.CONTEXT`. +#} +{% from "macros.html.j2" import facts %} + +<div class="page-header"><img src="{{ cover.logo }}" alt="CipherCell" /></div> +<span class="footer-scan">{{ cover.footer }}</span> +<span class="footer-date">{{ cover.footer_date }}</span> + +<div class="cover-mark"> + <img src="{{ cover.logo }}" alt="CipherCell" /> +</div> + +<div class="cover-title">{{ cover.title }}</div> + +{{ facts(cover.identity) }} + +{% if cover.intro %} + <div class="prose cover-intro">{{ cover.intro }}</div> +{% 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 %} + +<div class="prose">{{ body }}</div> -- 2.56.0