"""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 ``: <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("-")