Let the project name and the manufacturer be supplied on the command line

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>
This commit is contained in:
alexanderandClaude Opus 5.5 committed 2026-09-24 20:41:56 +02:00
1 parent 5959d9364b
commit dc8ed38d8c
4 files changed
+69 -15

No files matched your search

+33 -4
View File
@@ -32,11 +32,15 @@ class GenerateProperties(BaseModel):
scan_id: The scan to report on. The store is keyed by it.
path: Where to write the PDF. A directory is written into under the name
the renderer chose; leaving it out writes into ``reports/``.
project: The name of the scanned project, as the report states it.
manufacturer: Who makes the scanned product, as the report states it.
storage_config: The store the scan's artifacts are read from.
"""
scan_id: UUID = Field(description="The scan the report should be generated for.")
path: Path | None = Field(default=None, description="Where to write the PDF report.")
project: str | None = Field(default=None, description="The name of the scanned project.")
manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.")
storage_config: StorageConfiguration
@@ -48,6 +52,8 @@ class FolderProperties(BaseModel):
scan_id: The scan to report under. Read out of the artifacts when not
given -- an export names its own run.
path: Where to write the PDF, as above.
project: The name of the scanned project, as above.
manufacturer: Who makes the scanned product, as above.
"""
artifacts: Path = Field(description="Folder of exported artifacts to build the report from.")
@@ -55,6 +61,8 @@ class FolderProperties(BaseModel):
default=None, description="The scan to report under. Read from the artifacts when omitted."
)
path: Path | None = Field(default=None, description="Where to write the PDF report.")
project: str | None = Field(default=None, description="The name of the scanned project.")
manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.")
class GeneratedReport(BaseModel):
@@ -89,7 +97,12 @@ def generate(properties: GenerateProperties) -> GeneratedReport:
PipelineError: The scan cannot be read, is missing an input the report
needs, or the destination cannot be written.
"""
return _report(collect(properties.storage_config, properties.scan_id), properties.path)
return _report(
collect(properties.storage_config, properties.scan_id),
properties.path,
project=properties.project,
manufacturer=properties.manufacturer,
)
def from_folder(properties: FolderProperties) -> GeneratedReport:
@@ -107,10 +120,23 @@ def from_folder(properties: FolderProperties) -> GeneratedReport:
PipelineError: The folder cannot be read, names no scan, is missing an
input the report needs, or the destination cannot be written.
"""
return _report(collect_from_directory(properties.artifacts, properties.scan_id), properties.path, write_metrics=True)
return _report(
collect_from_directory(properties.artifacts, properties.scan_id),
properties.path,
project=properties.project,
manufacturer=properties.manufacturer,
write_metrics=True,
)
def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool = False) -> GeneratedReport:
def _report(
sources: ReportSources,
path: Path | None,
*,
project: str | None = None,
manufacturer: str | None = None,
write_metrics: bool = False,
) -> GeneratedReport:
"""Render the collected artifacts and write the result.
Everything after the artifacts are in hand, which is the same whichever of the two collected
@@ -119,6 +145,9 @@ def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool =
Args:
sources: The scan and its files.
path: Where the caller asked for the PDF, if anywhere in particular.
project: The project name the caller supplied, if any.
manufacturer: The manufacturer the caller supplied, if any.
write_metrics: Whether to write ``metrics.json`` beside the PDF.
Returns:
The report that was written.
@@ -127,7 +156,7 @@ def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool =
PipelineError: The report cannot be built or the destination not written.
"""
metrics = build_metrics(sources)
name, content = render(sources, metrics)
name, content = render(sources, metrics, project=project, manufacturer=manufacturer)
destination = _destination(path, name)
try:
+20 -8
View File
@@ -53,13 +53,21 @@ SEVERITY_OF_LEVEL = {
SEVERITY_ORDER = ("Critical", "High", "Medium", "Low")
def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str, Any]:
def build_context(
sources: ReportSources,
documents: dict[str, Any],
*,
project: str | None = None,
manufacturer: str | None = None,
) -> dict[str, Any]:
"""Build the report context from a scan's parsed artifacts.
Args:
sources: The scan and its stored files.
documents: The parsed artifacts by role -- ``metadata``, ``findings``,
``vulnerabilities`` -- each absent where the scan stored none.
project: The project name the caller supplied. Wins over the one in the metadata.
manufacturer: The manufacturer the caller supplied. The artifacts never name one.
Returns:
The context :func:`layout.render_pdf`
@@ -67,11 +75,12 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str
the template renders as a shorter report.
"""
metadata = documents.get("metadata") or {}
project = project or (metadata.get("configuration") or {}).get("repository_name")
tools = _tools(documents.get("findings"))
sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None
context: dict[str, Any] = {
"cover": _cover(sources, metadata, tools, sca),
"cover": _cover(sources, metadata, tools, sca, project=project, manufacturer=manufacturer),
"summary": _summary(tools, sca, documents.get("metrics")),
"sections": [_tool_section(name, report) for name, report in tools],
}
@@ -79,7 +88,7 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str
# Without the pipeline's metadata these two pages would be nothing but gaps.
if metadata:
context["scan"] = _scan(sources, metadata)
context["project"] = _project(metadata)
context["project"] = _project(metadata, project)
critical = _critical(tools, sca)
@@ -153,6 +162,9 @@ def _cover(
metadata: dict[str, Any],
tools: list[tuple[str, SarifReport]],
sca: VdrReport | None,
*,
project: str | None,
manufacturer: str | None,
) -> dict[str, Any]:
"""The first page."""
configuration = metadata.get("configuration") or {}
@@ -160,17 +172,17 @@ def _cover(
return {
"scan_id": str(sources.scan_id),
"subject": _stated(configuration.get("repository_name")),
"subject": _stated(project),
"footer_date": datetime.now(UTC).strftime("%d %B %Y"),
"logo": "cc_logo.png",
"title": TITLE,
"identity": [
_fact("Scan Identifier", sources.scan_id, kind="mono"),
_fact("Technology", ", ".join(technologies) or None),
_fact("Project", configuration.get("repository_name"), gap=True),
_fact("Project", project, gap=True),
_fact("Version", configuration.get("repository_version")),
_fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")),
_fact("Manufacturer", None),
_fact("Manufacturer", manufacturer),
],
"severity_ledger": _severity_ledger(tools, sca),
}
@@ -238,7 +250,7 @@ def _stage(stage: dict[str, Any], sources: ReportSources) -> dict[str, Any]:
}
def _project(metadata: dict[str, Any]) -> dict[str, Any]:
def _project(metadata: dict[str, Any], project: str | None) -> dict[str, Any]:
"""What was scanned."""
configuration = metadata.get("configuration") or {}
@@ -246,7 +258,7 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
"id": "project",
"title": "Project Information",
"overview": [
_fact("Project", configuration.get("repository_name")),
_fact("Project", project),
_fact("Version", configuration.get("repository_version")),
# Nothing in the pipeline counts lines, so nothing here can report them.
_fact("Analysed lines of code", None),
+13 -2
View File
@@ -46,11 +46,20 @@ REQUIRED_ANY = ("findings", "vulnerabilities")
NAME = "report_{scan}.pdf"
def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tuple[str, bytes]:
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.
@@ -72,7 +81,9 @@ def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tup
logging_context.logger.info(f"Rendering the report from {', '.join(found)}...")
return NAME.format(scan=sources.scan_id), render_pdf(build_context(sources, documents))
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