Neither is recorded in the artifacts: the metadata's repository_name is empty and nothing names a manufacturer. --project wins over the metadata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.1 KiB
Python
117 lines
4.1 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": (
|
|
"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,
|
|
*,
|
|
project: str | None = None,
|
|
manufacturer: str | None = None,
|
|
) -> tuple[str, bytes]:
|
|
"""Render the report of one scan.
|
|
|
|
Args:
|
|
sources: What :func:`sources.collect` found.
|
|
metrics: The metrics document, if one was built.
|
|
project: The project name the caller supplied; overrides the metadata's.
|
|
manufacturer: The manufacturer the caller supplied.
|
|
|
|
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)}...")
|
|
|
|
context = build_context(sources, documents, project=project, manufacturer=manufacturer)
|
|
|
|
return NAME.format(scan=sources.scan_id), render_pdf(context)
|
|
|
|
|
|
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
|