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 <noreply@anthropic.com>
This commit is contained in:
alexanderandClaude Opus 5.5 committed 2026-09-25 12:22:45 +02:00
1 parent 0ace2e1803
commit 864e2ff824
10 files changed
+573 -92

No files matched your search

+239
View File
@@ -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("-")