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>
240 lines
8.8 KiB
Python
240 lines
8.8 KiB
Python
"""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("-")
|