Files

102 lines
3.7 KiB
Python

"""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": ("sast_scanner_ranked.sarif", "polygraph.sarif", "sast_scanner_combined.sarif", "prism.sarif"),
"vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"),
}
# What a report cannot be built without. 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 = ("metadata",)
# 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 is missing an input the report cannot be built
without, 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
missing = [name for name in REQUIRED if documents.get(name) is None]
if missing:
message = f"Scan {sources.scan_id} carries no {' and no '.join(missing)}, 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