From 0ace2e18035e66b63dd4043abfc95516d2af4f60 Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Fri, 25 Sep 2026 12:22:45 +0200 Subject: [PATCH] Let the caller state the project's version --version replaces the commit hash on the cover and the project page, for generate and from-folder alike. Without it the report still falls back to repository_version from the metadata, else the scanned commit. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++++-- report_generator/commands/generate.py | 15 ++++++++++++++- report_generator/context.py | 15 +++++++++------ report_generator/rendering.py | 4 +++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 65e0163..d4c618c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Two ways to say where the artifacts are. ``` report_generator generate --scan_id [--path ] --config config.json - [--project ] [--manufacturer ] + [--project ] [--manufacturer ] [--version ] ``` **Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported @@ -31,9 +31,12 @@ them: ``` report_generator from-folder --artifacts ./sample [--path ] - [--project ] [--manufacturer ] + [--project ] [--manufacturer ] [--version ] ``` +`--version` sets the version shown on the cover and the project page. Without it the report uses +`repository_version` from `metadata.json`, else the commit the scan was run on. + `--scan_id` is optional there: the pipeline writes its own id into the paths it records in `metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one whose metadata is gone. diff --git a/report_generator/commands/generate.py b/report_generator/commands/generate.py index 7c04422..c9a800f 100644 --- a/report_generator/commands/generate.py +++ b/report_generator/commands/generate.py @@ -34,6 +34,8 @@ class GenerateProperties(BaseModel): 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. + version: The version of the scanned project, as the report states it. + Replaces the commit hash the metadata names when given. storage_config: The store the scan's artifacts are read from. """ @@ -41,6 +43,9 @@ class GenerateProperties(BaseModel): 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.") + version: str | None = Field( + default=None, description="The version of the scanned project. Replaces the commit hash when given." + ) storage_config: StorageConfiguration @@ -54,6 +59,7 @@ class FolderProperties(BaseModel): 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. + version: The version of the scanned project, as above. """ artifacts: Path = Field(description="Folder of exported artifacts to build the report from.") @@ -63,6 +69,9 @@ class FolderProperties(BaseModel): 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.") + version: str | None = Field( + default=None, description="The version of the scanned project. Replaces the commit hash when given." + ) class GeneratedReport(BaseModel): @@ -102,6 +111,7 @@ def generate(properties: GenerateProperties) -> GeneratedReport: properties.path, project=properties.project, manufacturer=properties.manufacturer, + version=properties.version, ) @@ -125,6 +135,7 @@ def from_folder(properties: FolderProperties) -> GeneratedReport: properties.path, project=properties.project, manufacturer=properties.manufacturer, + version=properties.version, write_metrics=True, ) @@ -135,6 +146,7 @@ def _report( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, write_metrics: bool = False, ) -> GeneratedReport: """Render the collected artifacts and write the result. @@ -147,6 +159,7 @@ def _report( 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. + version: The version the caller supplied, if any. write_metrics: Whether to write ``metrics.json`` beside the PDF. Returns: @@ -156,7 +169,7 @@ def _report( PipelineError: The report cannot be built or the destination not written. """ metrics = build_metrics(sources) - name, content = render(sources, metrics, project=project, manufacturer=manufacturer) + name, content = render(sources, metrics, project=project, manufacturer=manufacturer, version=version) destination = _destination(path, name) try: diff --git a/report_generator/context.py b/report_generator/context.py index 8ff8768..eb35c2a 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -61,6 +61,7 @@ def build_context( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, ) -> dict[str, Any]: """Build the report context from a scan's parsed artifacts. @@ -70,6 +71,7 @@ def build_context( ``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. + version: The version the caller supplied. Wins over the one in the metadata. Returns: The context :func:`layout.render_pdf` @@ -78,11 +80,12 @@ def build_context( """ metadata = documents.get("metadata") or {} project = project or (metadata.get("configuration") or {}).get("repository_name") + version = version or _version(metadata) 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, project=project, manufacturer=manufacturer), + "cover": _cover(sources, tools, sca, project=project, manufacturer=manufacturer, version=version), "summary": _summary(tools, sca, documents.get("metrics")), "sections": ([_sca_section(sca)] if sca is not None else []) + [_tool_section(name, report) for name, report in tools], @@ -90,7 +93,7 @@ def build_context( # Without the pipeline's metadata or cloc's count this page would be nothing but gaps. if metadata or documents.get("cloc"): - context["project"] = _project(metadata, project, documents.get("cloc")) + context["project"] = _project(project, version, documents.get("cloc")) critical = _critical(tools, sca) @@ -181,12 +184,12 @@ def _stated(value: Any) -> str: # noqa: ANN401 def _cover( sources: ReportSources, - metadata: dict[str, Any], tools: list[tuple[str, SarifReport]], sca: VdrReport | None, *, project: str | None, manufacturer: str | None, + version: str | None, ) -> dict[str, Any]: """The first page.""" technologies = ([SCA] if sca is not None else []) + [name for name, _ in tools] @@ -200,7 +203,7 @@ def _cover( "identity": [ _fact("Manufacturer", manufacturer), _fact("Project", project), - _fact("Version", _version(metadata), kind="mono"), + _fact("Version", version, kind="mono"), _fact("Technology", ", ".join(technologies) or None, gap=True), _fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), _fact("Scan Identifier", sources.scan_id, kind="mono"), @@ -248,14 +251,14 @@ def _version(metadata: dict[str, Any]) -> str | None: return commit if isinstance(commit, str) and _COMMIT.fullmatch(commit) else None -def _project(metadata: dict[str, Any], project: str | None, cloc: dict[str, Any] | None) -> dict[str, Any]: +def _project(project: str | None, version: str | None, cloc: dict[str, Any] | None) -> dict[str, Any]: """What was scanned, and how much of it there is, as cloc counted it.""" return { "id": "project", "title": "Project Information", "overview": [ _fact("Project", project), - _fact("Version", _version(metadata), kind="mono"), + _fact("Version", version, kind="mono"), _fact("Analysed lines of code", f"{cloc['code']:,} in {cloc['files']:,} files" if cloc else None), ], "languages": {"title": "Main languages", "rows": _languages(cloc)}, diff --git a/report_generator/rendering.py b/report_generator/rendering.py index e37d30f..44e116b 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -53,6 +53,7 @@ def render( *, project: str | None = None, manufacturer: str | None = None, + version: str | None = None, ) -> tuple[str, bytes]: """Render the report of one scan. @@ -61,6 +62,7 @@ def render( 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. + version: The version the caller supplied; overrides the commit the metadata names. Returns: The file name the report should be saved under, and its bytes. @@ -83,7 +85,7 @@ def render( logging_context.logger.info(f"Rendering the report from {', '.join(found)}...") - context = build_context(sources, documents, project=project, manufacturer=manufacturer) + context = build_context(sources, documents, project=project, manufacturer=manufacturer, version=version) return NAME.format(scan=sources.scan_id), render_pdf(context)