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:
1 parent
0ace2e1803
commit
864e2ff824
10 files changed
+573
-92
No files matched your search
@@ -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("-")
|
||||
Reference in new issue
Block a user