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

+3 -1
View File
@@ -23,6 +23,7 @@ Two ways to say where the artifacts are.
``` ```
report_generator generate --scan_id <uuid> [--path <file-or-directory>] --config config.json report_generator generate --scan_id <uuid> [--path <file-or-directory>] --config config.json
[--project <name>] [--manufacturer <name>]
``` ```
**Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported **Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported
@@ -30,6 +31,7 @@ them:
``` ```
report_generator from-folder --artifacts ./sample [--path <file-or-directory>] report_generator from-folder --artifacts ./sample [--path <file-or-directory>]
[--project <name>] [--manufacturer <name>]
``` ```
`--scan_id` is optional there: the pipeline writes its own id into the paths it records in `--scan_id` is optional there: the pipeline writes its own id into the paths it records in
@@ -96,7 +98,7 @@ vectors by `persistent_storage.utils.cvss_utils`.
### What it cannot fill in ### What it cannot fill in
A field the artifacts do not answer is written as `<xxx>`, never guessed or quietly dropped. Today A field the artifacts do not answer is written as `<xxx>`, never guessed or quietly dropped. Today
that is the manufacturer, the analysed lines of code, the language that is the manufacturer (unless given with `--manufacturer`), the analysed lines of code, the language
breakdown, and the tools and profile of each pipeline stage — the metadata records that a stage ran breakdown, and the tools and profile of each pipeline stage — the metadata records that a stage ran
and how long it took, not what it ran. and how long it took, not what it ran.
+33 -4
View File
@@ -32,11 +32,15 @@ class GenerateProperties(BaseModel):
scan_id: The scan to report on. The store is keyed by it. 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 path: Where to write the PDF. A directory is written into under the name
the renderer chose; leaving it out writes into ``reports/``. 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. storage_config: The store the scan's artifacts are read from.
""" """
scan_id: UUID = Field(description="The scan the report should be generated for.") 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.") 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 storage_config: StorageConfiguration
@@ -48,6 +52,8 @@ class FolderProperties(BaseModel):
scan_id: The scan to report under. Read out of the artifacts when not scan_id: The scan to report under. Read out of the artifacts when not
given -- an export names its own run. given -- an export names its own run.
path: Where to write the PDF, as above. 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.") 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." 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.") 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): class GeneratedReport(BaseModel):
@@ -89,7 +97,12 @@ def generate(properties: GenerateProperties) -> GeneratedReport:
PipelineError: The scan cannot be read, is missing an input the report PipelineError: The scan cannot be read, is missing an input the report
needs, or the destination cannot be written. 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: 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 PipelineError: The folder cannot be read, names no scan, is missing an
input the report needs, or the destination cannot be written. 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. """Render the collected artifacts and write the result.
Everything after the artifacts are in hand, which is the same whichever of the two collected 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: Args:
sources: The scan and its files. sources: The scan and its files.
path: Where the caller asked for the PDF, if anywhere in particular. 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: Returns:
The report that was written. 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. PipelineError: The report cannot be built or the destination not written.
""" """
metrics = build_metrics(sources) metrics = build_metrics(sources)
name, content = render(sources, metrics) name, content = render(sources, metrics, project=project, manufacturer=manufacturer)
destination = _destination(path, name) destination = _destination(path, name)
try: try:
+20 -8
View File
@@ -53,13 +53,21 @@ SEVERITY_OF_LEVEL = {
SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") 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. """Build the report context from a scan's parsed artifacts.
Args: Args:
sources: The scan and its stored files. sources: The scan and its stored files.
documents: The parsed artifacts by role -- ``metadata``, ``findings``, documents: The parsed artifacts by role -- ``metadata``, ``findings``,
``vulnerabilities`` -- each absent where the scan stored none. ``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: Returns:
The context :func:`layout.render_pdf` 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. the template renders as a shorter report.
""" """
metadata = documents.get("metadata") or {} metadata = documents.get("metadata") or {}
project = project or (metadata.get("configuration") or {}).get("repository_name")
tools = _tools(documents.get("findings")) tools = _tools(documents.get("findings"))
sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None sca = summarize_vdr(documents["vulnerabilities"]) if documents.get("vulnerabilities") else None
context: dict[str, Any] = { 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")), "summary": _summary(tools, sca, documents.get("metrics")),
"sections": [_tool_section(name, report) for name, report in tools], "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. # Without the pipeline's metadata these two pages would be nothing but gaps.
if metadata: if metadata:
context["scan"] = _scan(sources, metadata) context["scan"] = _scan(sources, metadata)
context["project"] = _project(metadata) context["project"] = _project(metadata, project)
critical = _critical(tools, sca) critical = _critical(tools, sca)
@@ -153,6 +162,9 @@ def _cover(
metadata: dict[str, Any], metadata: dict[str, Any],
tools: list[tuple[str, SarifReport]], tools: list[tuple[str, SarifReport]],
sca: VdrReport | None, sca: VdrReport | None,
*,
project: str | None,
manufacturer: str | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""The first page.""" """The first page."""
configuration = metadata.get("configuration") or {} configuration = metadata.get("configuration") or {}
@@ -160,17 +172,17 @@ def _cover(
return { return {
"scan_id": str(sources.scan_id), "scan_id": str(sources.scan_id),
"subject": _stated(configuration.get("repository_name")), "subject": _stated(project),
"footer_date": datetime.now(UTC).strftime("%d %B %Y"), "footer_date": datetime.now(UTC).strftime("%d %B %Y"),
"logo": "cc_logo.png", "logo": "cc_logo.png",
"title": TITLE, "title": TITLE,
"identity": [ "identity": [
_fact("Scan Identifier", sources.scan_id, kind="mono"), _fact("Scan Identifier", sources.scan_id, kind="mono"),
_fact("Technology", ", ".join(technologies) or None), _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("Version", configuration.get("repository_version")),
_fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")),
_fact("Manufacturer", None), _fact("Manufacturer", manufacturer),
], ],
"severity_ledger": _severity_ledger(tools, sca), "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.""" """What was scanned."""
configuration = metadata.get("configuration") or {} configuration = metadata.get("configuration") or {}
@@ -246,7 +258,7 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
"id": "project", "id": "project",
"title": "Project Information", "title": "Project Information",
"overview": [ "overview": [
_fact("Project", configuration.get("repository_name")), _fact("Project", project),
_fact("Version", configuration.get("repository_version")), _fact("Version", configuration.get("repository_version")),
# Nothing in the pipeline counts lines, so nothing here can report them. # Nothing in the pipeline counts lines, so nothing here can report them.
_fact("Analysed lines of code", None), _fact("Analysed lines of code", None),
+13 -2
View File
@@ -46,11 +46,20 @@ REQUIRED_ANY = ("findings", "vulnerabilities")
NAME = "report_{scan}.pdf" 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. """Render the report of one scan.
Args: Args:
sources: What :func:`sources.collect` found. 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: Returns:
The file name the report should be saved under, and its bytes. 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)}...") 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 def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401