"""Turns collected sources into a PDF. Three steps, each somewhere else: this module decides which stored file feeds which part of the report, :mod:`context` maps the parsed artifacts onto the page, and :mod:`layout` draws it. Adding a section to the report is an entry in :data:`INPUTS` and a block in the template -- not another renderer. """ from __future__ import annotations import json from typing import TYPE_CHECKING, Any from .vendor.cc_common.api import PipelineError from .vendor.cc_common.api import context as logging_context from .context import build_context from .layout import render_pdf if TYPE_CHECKING: from .sources import ReportSources # Which stored file each input of the report is taken from, under the names the pipeline stages # write. Several per input because the same document travels under more than one name depending on # which stage produced it; the first one a scan carries wins. INPUTS = { "metadata": ("metadata.json", "meta.json"), "findings": ( "merged.sarif", "sast_scanner_ranked.sarif", "polygraph.sarif", "sast_scanner_combined.sarif", "prism.sarif", ), "vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"), } # A report needs at least one of these to have anything to say. The rest is reported as far as it # was found: a scan that ran only half the pipeline is a shorter report, not a refusal. REQUIRED_ANY = ("findings", "vulnerabilities") # What the file is called. The scan it is about is in the name, because a directory of these is # otherwise unreadable. NAME = "report_{scan}.pdf" def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tuple[str, bytes]: """Render the report of one scan. Args: sources: What :func:`sources.collect` found. Returns: The file name the report should be saved under, and its bytes. Raises: PipelineError: The scan carries neither findings nor vulnerabilities, or one of its documents cannot be parsed. Both are reported by name: "no PDF was produced" leaves whoever ran it guessing which stage of their pipeline did not finish. """ documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()} documents["metrics"] = metrics if all(documents.get(name) is None for name in REQUIRED_ANY): message = f"Scan {sources.scan_id} carries no {' and no '.join(REQUIRED_ANY)}, so no report can be built." raise PipelineError(message) found = [name for name, document in documents.items() if document is not None] logging_context.logger.info(f"Rendering the report from {', '.join(found)}...") return NAME.format(scan=sources.scan_id), render_pdf(build_context(sources, documents)) def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401 """Read one of the scan's documents, or nothing where it stored none. Args: sources: The scan and its files. name: What the document is called in :data:`INPUTS`. candidates: The file names it may have been stored under. Returns: The parsed JSON, or ``None`` when the scan carries none. Raises: PipelineError: The file is there and is not readable. Kept apart from an absent one on purpose: one is a pipeline that did not run a stage, the other is a stage that produced something broken, and a report that treated them alike would hide the second. """ content = sources.first(*candidates) if content is None: return None try: return json.loads(content) except ValueError as error: message = f"The {name} of scan {sources.scan_id} is not readable JSON: {error}" raise PipelineError(message) from error