"""Renders the SAST/SCA report template. The template and its stylesheet are the design; this module is only what turns them into a document. It takes a context that is already shaped for the page -- every number formatted, every bar already a percentage -- so that the template places strings and never computes. Nothing here reads an artifact. Filling a context from a scan is :mod:`context`, which works from what :mod:`sources` collected; :func:`sample.sample_context` builds one by hand, which is what the template tests render. """ from __future__ import annotations from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader, select_autoescape TEMPLATE_DIR = Path(__file__).resolve().parent / "template" 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 = "Complete Security Findings Report" AUTHOR = "CipherCell" # The shape :func:`render_html` expects. Written down because the template guards on it: a key that # is absent leaves its section out, which is how a scan that ran half the pipeline produces a # 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[]}", "sections": "[{title, new_page?, groups[{title, note?, cards[], table?}]}]", } def render_html(context: dict[str, Any]) -> str: """Render the report as an HTML document. Args: context: The page-ready context; see :data:`CONTEXT`. Returns: The rendered HTML. """ environment = Environment( loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape(default_for_string=True, default=True), trim_blocks=True, lstrip_blocks=True, ) return environment.get_template(TEMPLATE_NAME).render(**context) def render_pdf(context: dict[str, Any]) -> bytes: """Render the report as a PDF. Args: context: The page-ready context; see :data:`CONTEXT`. Returns: The PDF. """ # WeasyPrint pulls in native cairo and pango bindings. Imported here so that importing this # module -- to render the HTML, or merely to read CONTEXT -- does not pay for them. from weasyprint import CSS, HTML from weasyprint.text.fonts import FontConfiguration # Without this the @font-face rules are ignored and Space Grotesk is silently replaced by # whatever the host happens to have, which is how the design sample came to be set in Carlito. fonts = FontConfiguration() # 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( stylesheets=[CSS(filename=str(STYLESHEET), font_config=fonts)], font_config=fonts, ) document.metadata.title = f"{TITLE} - {context.get('cover', {}).get('subject', '')}".strip(" -") document.metadata.authors = [AUTHOR] return bytes(document.write_pdf())