The project page takes the size from cloc's sum and names the three to five largest languages, leaving out prose and data files such as Markdown, text, CSV and JSON, and anything under 1% of the code beyond the third. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.2 KiB
Python
119 lines
4.2 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 .cloc import parse_cloc
|
|
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
|
|
documents["cloc"] = parse_cloc(sources.first("cloc.txt"))
|
|
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
|