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:
1 parent
5959d9364b
commit
dc8ed38d8c
4 files changed
+69
-15
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user