diff --git a/README.md b/README.md index a6807b1..0bdb35a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Two ways to say where the artifacts are. ``` report_generator generate --scan_id [--path ] --config config.json + [--project ] [--manufacturer ] ``` **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 ] + [--project ] [--manufacturer ] ``` `--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 A field the artifacts do not answer is written as ``, 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 and how long it took, not what it ran. diff --git a/report_generator/commands/generate.py b/report_generator/commands/generate.py index bde6359..7c04422 100644 --- a/report_generator/commands/generate.py +++ b/report_generator/commands/generate.py @@ -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: diff --git a/report_generator/context.py b/report_generator/context.py index 4ac3e29..b7602dd 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -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), diff --git a/report_generator/rendering.py b/report_generator/rendering.py index 9cd6fb5..c3c1cd8 100644 --- a/report_generator/rendering.py +++ b/report_generator/rendering.py @@ -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