Add toc and include all findings in the report
This commit is contained in:
1 parent
f775e1f1ed
commit
2e2ab63232
6 files changed
+265
-142
No files matched your search
@@ -36,6 +36,20 @@ report_generator from-folder --artifacts ./sample [--path <file-or-directory>]
|
|||||||
`metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one
|
`metadata.json`, so an export names its own run. Pass it for a folder assembled by hand, or one
|
||||||
whose metadata is gone.
|
whose metadata is gone.
|
||||||
|
|
||||||
|
The inputs are one SARIF file (`merged.sarif`, with one run per tool) and the vulnerability report
|
||||||
|
(`vdr.json`); either is enough on its own. `metadata.json` is optional, and without it the scan and
|
||||||
|
project pages are left out. The report has these parts:
|
||||||
|
|
||||||
|
1. the cover and a table of contents,
|
||||||
|
2. a summary and the most critical issues across all sources,
|
||||||
|
3. one section per SARIF run, named after its tool (e.g. CipherCell SAST, CipherCell Lynkeus),
|
||||||
|
4. the dependency vulnerabilities from the VDR,
|
||||||
|
5. at the end, *False Positives and Suppressed Findings*: every result Polygraph classified as a
|
||||||
|
false positive or that carries a SARIF suppression (such as the areas Lynkeus ruled out), with
|
||||||
|
the reason.
|
||||||
|
|
||||||
|
Every finding is listed; only the summary charts and the most-critical table are capped.
|
||||||
|
|
||||||
The two produce the same document. Everything after the artifacts are in hand is shared code, and
|
The two produce the same document. Everything after the artifacts are in hand is shared code, and
|
||||||
the sample export in `sample/` was checked against the report built from the store for the same
|
the sample export in `sample/` was checked against the report built from the store for the same
|
||||||
scan: 17 of 17 pages identical. The tests cover the folder path; comparing the two needs a store
|
scan: 17 of 17 pages identical. The tests cover the folder path; comparing the two needs a store
|
||||||
|
|||||||
+196
-125
@@ -17,6 +17,8 @@ inside the web application that happens to import them today.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -32,10 +34,8 @@ if TYPE_CHECKING:
|
|||||||
# What stands in for a field the stored artifacts do not answer.
|
# What stands in for a field the stored artifacts do not answer.
|
||||||
UNKNOWN = "<xxx>"
|
UNKNOWN = "<xxx>"
|
||||||
|
|
||||||
# How many of each list the report carries. A report is read, not audited; the full set is in the
|
# How many entries the summary lists carry. The sections themselves list every finding.
|
||||||
# artifacts it was built from.
|
|
||||||
MOST_CRITICAL = 6
|
MOST_CRITICAL = 6
|
||||||
CARDS_PER_GROUP = 8
|
|
||||||
BARS = 10
|
BARS = 10
|
||||||
|
|
||||||
# The severity a SARIF level is reported under. SARIF names four levels and the report bands five,
|
# The severity a SARIF level is reported under. SARIF names four levels and the report bands five,
|
||||||
@@ -65,31 +65,62 @@ 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 {}
|
||||||
sast = summarize_sarif(documents["findings"]) if documents.get("findings") else None
|
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, sast, sca),
|
"cover": _cover(sources, metadata, tools, sca),
|
||||||
"scan": _scan(sources, metadata),
|
"summary": _summary(tools, sca, documents.get("metrics")),
|
||||||
"project": _project(metadata),
|
"sections": [_tool_section(name, report) for name, report in tools],
|
||||||
"summary": _summary(sast, sca, documents.get("metrics")),
|
|
||||||
"sections": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
critical = _critical(sast, sca)
|
# Without the pipeline's metadata these two pages would be nothing but gaps.
|
||||||
|
if metadata:
|
||||||
|
context["scan"] = _scan(sources, metadata)
|
||||||
|
context["project"] = _project(metadata)
|
||||||
|
|
||||||
|
critical = _critical(tools, sca)
|
||||||
|
|
||||||
if critical["table"]["rows"]:
|
if critical["table"]["rows"]:
|
||||||
context["critical"] = critical
|
context["critical"] = critical
|
||||||
|
|
||||||
if sast is not None:
|
|
||||||
context["sections"].append(_sast_section(sast))
|
|
||||||
|
|
||||||
if sca is not None:
|
if sca is not None:
|
||||||
context["sections"].append(_sca_section(sca))
|
context["sections"].append(_sca_section(sca))
|
||||||
|
|
||||||
|
set_aside = _set_aside_section(tools)
|
||||||
|
|
||||||
|
if set_aside is not None:
|
||||||
|
context["sections"].append(set_aside)
|
||||||
|
|
||||||
|
context["contents"] = _contents(context)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _tools(sarif: Any) -> list[tuple[str, SarifReport]]: # noqa: ANN401
|
||||||
|
"""The SARIF document split by the tool that produced each run, in the order the runs appear."""
|
||||||
|
if not isinstance(sarif, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
runs = [run for run in sarif.get("runs") or [] if isinstance(run, dict)]
|
||||||
|
|
||||||
|
return [(_tool_name(run), summarize_sarif({"runs": [run]})) for run in runs]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_name(run: dict[str, Any]) -> str:
|
||||||
|
"""What the tool that produced a run calls itself."""
|
||||||
|
driver = (run.get("tool") or {}).get("driver") or {}
|
||||||
|
|
||||||
|
return str(driver.get("name") or driver.get("fullName") or UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
def _contents(context: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
"""The table of contents: every top-level part of the report that is actually in it."""
|
||||||
|
parts = [context[key] for key in ("scan", "project", "summary", "critical") if key in context]
|
||||||
|
|
||||||
|
return [{"id": part["id"], "title": part["title"]} for part in [*parts, *context["sections"]]]
|
||||||
|
|
||||||
|
|
||||||
# --- the page ------------------------------------------------------------------------------
|
# --- the page ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -118,12 +149,12 @@ def _stated(value: Any) -> str: # noqa: ANN401
|
|||||||
def _cover(
|
def _cover(
|
||||||
sources: ReportSources,
|
sources: ReportSources,
|
||||||
metadata: dict[str, Any],
|
metadata: dict[str, Any],
|
||||||
sast: SarifReport | None,
|
tools: list[tuple[str, SarifReport]],
|
||||||
sca: VdrReport | None,
|
sca: VdrReport | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""The first page."""
|
"""The first page."""
|
||||||
configuration = metadata.get("configuration") or {}
|
configuration = metadata.get("configuration") or {}
|
||||||
technologies = [name for name, present in (("SAST", sast is not None), ("SCA", sca is not None)) if present]
|
technologies = [name for name, _ in tools] + (["SCA"] if sca is not None else [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scan_id": str(sources.scan_id),
|
"scan_id": str(sources.scan_id),
|
||||||
@@ -143,19 +174,20 @@ def _cover(
|
|||||||
_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", None),
|
||||||
],
|
],
|
||||||
"severity_ledger": _severity_ledger(sast, sca),
|
"severity_ledger": _severity_ledger(tools, sca),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _severity_ledger(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any] | None:
|
def _severity_ledger(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any] | None:
|
||||||
"""How many issues of each severity survived filtering, over both halves of the pipeline."""
|
"""How many issues of each severity survived filtering, over every tool and the dependencies."""
|
||||||
counts = dict.fromkeys(SEVERITY_ORDER, 0)
|
counts = dict.fromkeys(SEVERITY_ORDER, 0)
|
||||||
|
|
||||||
for finding in _true_findings(sast):
|
for _, report in tools:
|
||||||
counts[SEVERITY_OF_LEVEL.get(finding.level, "Low")] += 1
|
for finding in _kept(report):
|
||||||
|
counts[_finding_severity(finding)] += 1
|
||||||
|
|
||||||
for vulnerability in sca.vulnerabilities if sca else []:
|
for vulnerability in sca.vulnerabilities if sca else []:
|
||||||
counts[_vdr_severity(vulnerability)] = counts.get(_vdr_severity(vulnerability), 0) + 1
|
counts[_vdr_severity(vulnerability)] += 1
|
||||||
|
|
||||||
if not any(counts.values()):
|
if not any(counts.values()):
|
||||||
return None
|
return None
|
||||||
@@ -174,6 +206,7 @@ def _scan(sources: ReportSources, metadata: dict[str, Any]) -> dict[str, Any]:
|
|||||||
failed = [stage["label"] for stage in stages if stage.get("status") not in ("success", None)]
|
failed = [stage["label"] for stage in stages if stage.get("status") not in ("success", None)]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"id": "scan",
|
||||||
"title": "CipherCell Scan Information",
|
"title": "CipherCell Scan Information",
|
||||||
"overview": [
|
"overview": [
|
||||||
_fact("Scan started at", _moment(metadata.get("pipeline_start_iso"))),
|
_fact("Scan started at", _moment(metadata.get("pipeline_start_iso"))),
|
||||||
@@ -212,6 +245,7 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
|
|||||||
configuration = metadata.get("configuration") or {}
|
configuration = metadata.get("configuration") or {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"id": "project",
|
||||||
"title": "Project Information",
|
"title": "Project Information",
|
||||||
"overview": [
|
"overview": [
|
||||||
_fact("Project", configuration.get("repository_name")),
|
_fact("Project", configuration.get("repository_name")),
|
||||||
@@ -226,26 +260,29 @@ def _project(metadata: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _summary(sast: SarifReport | None, sca: VdrReport | None, metrics: dict[str, Any] | None) -> dict[str, Any]:
|
def _summary(
|
||||||
|
tools: list[tuple[str, SarifReport]], sca: VdrReport | None, metrics: dict[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""The counts, and where they concentrate."""
|
"""The counts, and where they concentrate."""
|
||||||
true_sast = _true_findings(sast)
|
|
||||||
sca_total = sca.total if sca else 0
|
sca_total = sca.total if sca else 0
|
||||||
raw = (sast.total if sast else 0) + sca_total
|
raw = sum(report.total for _, report in tools) + sca_total
|
||||||
filtered = sast.false_positive_count if sast else 0
|
true_issues = [f"{len(_kept(report))} {name}" for name, report in tools] + ([f"{sca_total} SCA"] if sca else [])
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
|
"id": "summary",
|
||||||
"title": "Summary",
|
"title": "Summary",
|
||||||
"overview": [
|
"overview": [
|
||||||
_fact("Raw findings", raw or None),
|
_fact("Raw findings", raw or None),
|
||||||
# Only SAST findings carry a filtering verdict. A vulnerability suppressed in
|
# Only SARIF findings can be set aside. A vulnerability suppressed in Dependency-Track
|
||||||
# Dependency-Track never reaches the report at all, so the two cannot be added up.
|
# never reaches the report at all, so the two cannot be added up.
|
||||||
_fact("Filtered", f"{filtered} (SAST)" if sast else None),
|
_fact(
|
||||||
_fact("True issues", f"{len(true_sast)} SAST, {sca_total} SCA" if sast or sca else None),
|
"False positives / suppressed",
|
||||||
# Each half stated only where its artifact was there. A scan that ran no SCA stage
|
", ".join(f"{len(_set_aside(report))} {name}" for name, report in tools) or None,
|
||||||
# reports its SAST rules and says nothing about components, rather than nothing at all.
|
),
|
||||||
_fact("Distinct issue types (true)", ", ".join(_issue_types(sast, sca)) or None),
|
_fact("True issues", ", ".join(true_issues) or None),
|
||||||
|
_fact("Distinct issue types (true)", ", ".join(_issue_types(tools, sca)) or None),
|
||||||
],
|
],
|
||||||
"distributions": _distributions(true_sast, sca),
|
"distributions": _distributions(tools, sca),
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies = metrics.get("dependencies") if isinstance(metrics, dict) else None
|
dependencies = metrics.get("dependencies") if isinstance(metrics, dict) else None
|
||||||
@@ -340,12 +377,9 @@ def _remedy(value: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
|
def _issue_types(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[str]:
|
||||||
"""How many distinct kinds of issue each half of the pipeline found."""
|
"""How many distinct kinds of true issue each tool, and the dependency check, found."""
|
||||||
counted = []
|
counted = [f"{len({one.rule_id for one in _kept(report)})} {name} rules" for name, report in tools]
|
||||||
|
|
||||||
if sast is not None:
|
|
||||||
counted.append(f"{sast.rule_count} SAST rules")
|
|
||||||
|
|
||||||
if sca is not None:
|
if sca is not None:
|
||||||
counted.append(f"{sca.component_count} affected components")
|
counted.append(f"{sca.component_count} affected components")
|
||||||
@@ -353,14 +387,17 @@ def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
|
|||||||
return counted
|
return counted
|
||||||
|
|
||||||
|
|
||||||
def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[str, Any]]:
|
def _distributions(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[dict[str, Any]]:
|
||||||
"""Where the issues concentrate: by rule for SAST, by package for SCA."""
|
"""Where the issues concentrate: by rule for each SARIF tool, by package for SCA."""
|
||||||
blocks = []
|
blocks = []
|
||||||
|
|
||||||
|
for name, report in tools:
|
||||||
|
findings = _kept(report)
|
||||||
|
|
||||||
if findings:
|
if findings:
|
||||||
blocks.append(
|
blocks.append(
|
||||||
{
|
{
|
||||||
"title": f"True SAST issues by rule ({len(findings)})",
|
"title": f"True {name} issues by rule ({len(findings)})",
|
||||||
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
|
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -450,139 +487,152 @@ def _caveat(shown: list[Any], grouped: list[Any], largest: int) -> str:
|
|||||||
return f"{len(shown)} of {len(grouped)} shown; {scale}"
|
return f"{len(shown)} of {len(grouped)} shown; {scale}"
|
||||||
|
|
||||||
|
|
||||||
def _critical(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any]:
|
def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any]:
|
||||||
"""The handful worth reading first, whichever half of the pipeline found them."""
|
"""The handful worth reading first, whichever tool found them."""
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
for finding in sorted(_true_findings(sast), key=lambda one: SEVERITY_ORDER.index(_finding_severity(one))):
|
for name, report in tools:
|
||||||
|
for finding in _kept(report):
|
||||||
|
severity = _finding_severity(finding)
|
||||||
rows.append(
|
rows.append(
|
||||||
|
(
|
||||||
|
severity,
|
||||||
{
|
{
|
||||||
"cells": [
|
"cells": [
|
||||||
"SAST",
|
name,
|
||||||
{"value": _finding_severity(finding), "kind": "severity"},
|
{"value": severity, "kind": "severity"},
|
||||||
_stated(finding.rule_id),
|
_stated(finding.rule_id),
|
||||||
{"value": _stated(_where(finding)), "kind": "mono"},
|
{"value": _stated(_where(finding)), "kind": "mono"},
|
||||||
_stated(finding.message),
|
_stated(_text(finding.message)),
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for vulnerability in sorted(
|
for vulnerability in sca.vulnerabilities if sca else []:
|
||||||
sca.vulnerabilities if sca else [],
|
severity = _vdr_severity(vulnerability)
|
||||||
key=lambda one: SEVERITY_ORDER.index(_vdr_severity(one)),
|
|
||||||
):
|
|
||||||
rows.append(
|
rows.append(
|
||||||
|
(
|
||||||
|
severity,
|
||||||
{
|
{
|
||||||
"cells": [
|
"cells": [
|
||||||
"SCA",
|
"SCA",
|
||||||
{"value": _vdr_severity(vulnerability), "kind": "severity"},
|
{"value": severity, "kind": "severity"},
|
||||||
_stated(vulnerability.id),
|
_stated(vulnerability.id),
|
||||||
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
|
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
|
||||||
_stated(vulnerability.description),
|
_stated(vulnerability.description),
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sorted is stable, so within a severity each tool keeps its own order -- the rank, where it has one.
|
||||||
|
rows.sort(key=lambda pair: SEVERITY_ORDER.index(pair[0]))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"id": "critical",
|
||||||
"title": "Most Critical Vulnerabilities",
|
"title": "Most Critical Vulnerabilities",
|
||||||
"table": {
|
"table": {
|
||||||
"columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
|
"columns": ["Source", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
|
||||||
"rows": rows[:MOST_CRITICAL],
|
"rows": [row for _, row in rows[:MOST_CRITICAL]],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _sast_section(sast: SarifReport) -> dict[str, Any]:
|
def _tool_section(name: str, report: SarifReport) -> dict[str, Any]:
|
||||||
"""The SAST findings, split into what was kept and what was set aside."""
|
"""Every finding of one tool that was not set aside."""
|
||||||
true_findings = _true_findings(sast)
|
kept = _kept(report)
|
||||||
filtered = [one for one in sast.findings if one.false_positive]
|
group: dict[str, Any] = {"title": f"True Issues ({len(kept)})", "cards": [_finding_card(one) for one in kept]}
|
||||||
groups: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
if true_findings:
|
if not kept:
|
||||||
groups.append(
|
group["note"] = f"Every finding {name} reported is listed under False Positives and Suppressed Findings."
|
||||||
{
|
|
||||||
"title": f"True Issues ({len(true_findings)})",
|
|
||||||
"cards": [_finding_card(one) for one in true_findings[:CARDS_PER_GROUP]],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if filtered:
|
return {"id": _anchor(name), "title": name, "new_page": True, "groups": [group]}
|
||||||
groups.append(
|
|
||||||
|
|
||||||
|
def _set_aside_section(tools: list[tuple[str, SarifReport]]) -> dict[str, Any] | None:
|
||||||
|
"""The false positives and the suppressed findings of every tool, at the end of the report."""
|
||||||
|
grouped = [(name, _set_aside(report)) for name, report in tools]
|
||||||
|
grouped = [(name, findings) for name, findings in grouped if findings]
|
||||||
|
|
||||||
|
if not grouped:
|
||||||
|
return None
|
||||||
|
|
||||||
|
groups: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"title": f"Filtered Issues ({len(filtered)})",
|
"title": "Overview",
|
||||||
"note": (
|
"note": (
|
||||||
"The following findings were reported by the scanners but assessed as not exploitable in "
|
"The following findings were reported but set aside: classified as false positives, or "
|
||||||
"this artifact. Each entry carries the verdict that filtered it."
|
"suppressed by the tool that reported them. Each entry carries the reason it was set aside."
|
||||||
),
|
),
|
||||||
"cards": [_finding_card(one, filtered_out=True) for one in filtered[:CARDS_PER_GROUP]],
|
"table": _set_aside_table(grouped),
|
||||||
"table": _filtered_table(filtered),
|
|
||||||
}
|
}
|
||||||
)
|
]
|
||||||
|
groups += [
|
||||||
|
{"title": f"{name} ({len(findings)})", "cards": [_finding_card(one, set_aside=True) for one in findings]}
|
||||||
|
for name, findings in grouped
|
||||||
|
]
|
||||||
|
|
||||||
return {"title": "SAST Vulnerabilities", "groups": groups}
|
return {
|
||||||
|
"id": "set-aside",
|
||||||
|
"title": "False Positives and Suppressed Findings",
|
||||||
|
"new_page": True,
|
||||||
|
"groups": groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _finding_card(finding: Finding, *, filtered_out: bool = False) -> dict[str, Any]:
|
def _finding_card(finding: Finding, *, set_aside: bool = False) -> dict[str, Any]:
|
||||||
"""One SARIF finding."""
|
"""One SARIF finding."""
|
||||||
rows = [
|
rows = []
|
||||||
_row("Severity", _finding_severity(finding), kind="severity"),
|
|
||||||
_row("Location", _where(finding), kind="mono"),
|
# A finding a tool ruled out carries no severity worth stating.
|
||||||
_row("Description", finding.message),
|
if finding.level != "none":
|
||||||
|
rows.append(_row("Severity", _finding_severity(finding), kind="severity"))
|
||||||
|
|
||||||
|
# Not every finding points at a line: an area an agent ruled out names it in its message instead.
|
||||||
|
if finding.location is not None:
|
||||||
|
rows.append(_row("Location", _where(finding), kind="mono"))
|
||||||
|
|
||||||
|
rows += [
|
||||||
|
_row("Description", _text(finding.message)),
|
||||||
_row("Reported By", ", ".join(finding.tools) or None),
|
_row("Reported By", ", ".join(finding.tools) or None),
|
||||||
]
|
]
|
||||||
|
|
||||||
if filtered_out:
|
if set_aside:
|
||||||
rows.append(
|
reason = finding.polygraph_reasoning or "; ".join(
|
||||||
_row(
|
one.justification for one in finding.suppressions if one.justification
|
||||||
"Filtering Reason",
|
|
||||||
finding.polygraph_reasoning,
|
|
||||||
aside=True,
|
|
||||||
token=finding.polygraph_verdict,
|
|
||||||
)
|
)
|
||||||
)
|
rows.append(_row("Verdict", finding.verdict_label or "Suppressed"))
|
||||||
else:
|
rows.append(_row("Reason", reason, aside=True))
|
||||||
# Polygraph's verdict where it looked at this one, and nothing where it did not: silence is
|
elif finding.verdict_label:
|
||||||
# not a clean bill of health, and must not be written as one.
|
rows.append(_row("Evidence", finding.verdict_label))
|
||||||
rows.append(_row("Evidence", finding.verdict_label or None))
|
|
||||||
|
|
||||||
return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows}
|
return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
def _filtered_table(findings: list[Finding]) -> dict[str, Any]:
|
def _set_aside_table(grouped: list[tuple[str, list[Finding]]]) -> dict[str, Any]:
|
||||||
"""How many of each rule were filtered, and on what grounds."""
|
"""How many findings of each tool and rule were set aside."""
|
||||||
grouped: dict[str, list[Finding]] = {}
|
rows: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for finding in findings:
|
for name, findings in grouped:
|
||||||
grouped.setdefault(finding.rule_id or UNKNOWN, []).append(finding)
|
counts = Counter(one.rule_id or UNKNOWN for one in findings)
|
||||||
|
rows += [{"cells": [name, rule, count]} for rule, count in counts.most_common()]
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = [
|
rows.append({"cells": ["Total", "", sum(len(findings) for _, findings in grouped)], "total": True})
|
||||||
{
|
|
||||||
"cells": [
|
|
||||||
rule,
|
|
||||||
len(group),
|
|
||||||
{"value": _stated(group[0].polygraph_verdict), "kind": "mono"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
for rule, group in sorted(grouped.items(), key=lambda pair: -len(pair[1]))
|
|
||||||
]
|
|
||||||
rows.append({"cells": ["Total", len(findings), ""], "total": True})
|
|
||||||
|
|
||||||
return {
|
return {"columns": ["Tool", "Rule", "Set aside"], "rows": rows}
|
||||||
"title": f"Filtered SAST issues by rule ({len(findings)})",
|
|
||||||
"columns": ["Rule", "Filtered", "Dominant verdict"],
|
|
||||||
"rows": rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sca_section(sca: VdrReport) -> dict[str, Any]:
|
def _sca_section(sca: VdrReport) -> dict[str, Any]:
|
||||||
"""The dependency findings."""
|
"""The dependency findings."""
|
||||||
return {
|
return {
|
||||||
"title": "SBOM / SCA Vulnerabilities",
|
"id": "sca",
|
||||||
|
"title": "Dependency Vulnerabilities (SBOM / SCA)",
|
||||||
"new_page": True,
|
"new_page": True,
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"title": f"True Issues by CVEs ({sca.total})",
|
"title": f"True Issues by CVEs ({sca.total})",
|
||||||
"cards": [_vulnerability_card(one) for one in sca.vulnerabilities[:CARDS_PER_GROUP]],
|
"cards": [_vulnerability_card(one) for one in sca.vulnerabilities],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -630,9 +680,29 @@ def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]:
|
|||||||
# --- readings ------------------------------------------------------------------------------
|
# --- readings ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _true_findings(sast: SarifReport | None) -> list[Finding]:
|
def _is_set_aside(finding: Finding) -> bool:
|
||||||
|
"""Whether a finding was classified as a false positive or suppressed by the tool."""
|
||||||
|
return finding.false_positive or bool(finding.suppressions)
|
||||||
|
|
||||||
|
|
||||||
|
def _kept(report: SarifReport) -> list[Finding]:
|
||||||
"""The findings that survived filtering."""
|
"""The findings that survived filtering."""
|
||||||
return [one for one in sast.findings if not one.false_positive] if sast else []
|
return [one for one in report.findings if not _is_set_aside(one)]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_aside(report: SarifReport) -> list[Finding]:
|
||||||
|
"""The findings that were filtered out or suppressed."""
|
||||||
|
return [one for one in report.findings if _is_set_aside(one)]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(message: str) -> str:
|
||||||
|
"""A message on one line. Paragraph breaks -- a title above its description -- would otherwise run together."""
|
||||||
|
return re.sub(r"\s*\n\s*\n\s*", " — ", message.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor(title: str) -> str:
|
||||||
|
"""An id the table of contents can link to."""
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "section"
|
||||||
|
|
||||||
|
|
||||||
def _finding_severity(finding: Finding) -> str:
|
def _finding_severity(finding: Finding) -> str:
|
||||||
@@ -648,13 +718,14 @@ def _vdr_severity(vulnerability: Vulnerability) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _where(finding: Finding) -> str | None:
|
def _where(finding: Finding) -> str | None:
|
||||||
"""Where a finding points, as ``file:line``."""
|
"""Where a finding points, as ``file:line``, every location of it."""
|
||||||
if finding.location is None:
|
if finding.location is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
line = finding.location.start_line
|
return ", ".join(
|
||||||
|
f"{one.file}:{one.start_line}" if one.start_line else one.file
|
||||||
return f"{finding.location.file}:{line}" if line else finding.location.file
|
for one in [finding.location, *finding.extra_locations]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _percent(share: float | None) -> str | None:
|
def _percent(share: float | None) -> str | None:
|
||||||
|
|||||||
@@ -27,13 +27,19 @@ if TYPE_CHECKING:
|
|||||||
# which stage produced it; the first one a scan carries wins.
|
# which stage produced it; the first one a scan carries wins.
|
||||||
INPUTS = {
|
INPUTS = {
|
||||||
"metadata": ("metadata.json", "meta.json"),
|
"metadata": ("metadata.json", "meta.json"),
|
||||||
"findings": ("sast_scanner_ranked.sarif", "polygraph.sarif", "sast_scanner_combined.sarif", "prism.sarif"),
|
"findings": (
|
||||||
|
"merged.sarif",
|
||||||
|
"sast_scanner_ranked.sarif",
|
||||||
|
"polygraph.sarif",
|
||||||
|
"sast_scanner_combined.sarif",
|
||||||
|
"prism.sarif",
|
||||||
|
),
|
||||||
"vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"),
|
"vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# What a report cannot be built without. The rest is reported as far as it was found: a scan that
|
# A report needs at least one of these to have anything to say. The rest is reported as far as it
|
||||||
# ran only half the pipeline is a shorter report, not a refusal.
|
# was found: a scan that ran only half the pipeline is a shorter report, not a refusal.
|
||||||
REQUIRED = ("metadata",)
|
REQUIRED_ANY = ("findings", "vulnerabilities")
|
||||||
|
|
||||||
# What the file is called. The scan it is about is in the name, because a directory of these is
|
# What the file is called. The scan it is about is in the name, because a directory of these is
|
||||||
# otherwise unreadable.
|
# otherwise unreadable.
|
||||||
@@ -50,17 +56,15 @@ def render(sources: ReportSources, metrics: dict[str, Any] | None = None) -> tup
|
|||||||
The file name the report should be saved under, and its bytes.
|
The file name the report should be saved under, and its bytes.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PipelineError: The scan is missing an input the report cannot be built
|
PipelineError: The scan carries neither findings nor vulnerabilities, or
|
||||||
without, or one of its documents cannot be parsed. Both are reported
|
one of its documents cannot be parsed. Both are reported by name: "no
|
||||||
by name: "no PDF was produced" leaves whoever ran it guessing which
|
PDF was produced" leaves whoever ran it guessing which stage of their
|
||||||
stage of their pipeline did not finish.
|
pipeline did not finish.
|
||||||
"""
|
"""
|
||||||
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
|
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
|
||||||
documents["metrics"] = metrics
|
documents["metrics"] = metrics
|
||||||
missing = [name for name in REQUIRED if documents.get(name) is None]
|
if all(documents.get(name) is None for name in REQUIRED_ANY):
|
||||||
|
message = f"Scan {sources.scan_id} carries no {' and no '.join(REQUIRED_ANY)}, so no report can be built."
|
||||||
if missing:
|
|
||||||
message = f"Scan {sources.scan_id} carries no {' and no '.join(missing)}, so no report can be built."
|
|
||||||
|
|
||||||
raise PipelineError(message)
|
raise PipelineError(message)
|
||||||
|
|
||||||
|
|||||||
@@ -420,3 +420,26 @@ p.note {
|
|||||||
.legend .caveat {
|
.legend .caveat {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Table of contents -------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Page numbers are read off where each link lands, so they stay right however long a section runs. */
|
||||||
|
.toc {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc li {
|
||||||
|
padding: 1.4mm 0;
|
||||||
|
border-bottom: 0.4pt solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc a {
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc a::after {
|
||||||
|
content: leader(".") target-counter(attr(href), page);
|
||||||
|
}
|
||||||
@@ -121,8 +121,17 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contents %}
|
||||||
|
<h1 style="break-before: page">Table of Contents</h1>
|
||||||
|
<ol class="toc">
|
||||||
|
{% for entry in contents %}
|
||||||
|
<li><a href="#{{ entry.id }}">{{ entry.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if scan %}
|
{% if scan %}
|
||||||
<h1 style="break-before: page">{{ scan.title }}</h1>
|
<h1 id="{{ scan.id }}" style="break-before: page">{{ scan.title }}</h1>
|
||||||
{{ facts(scan.overview, wide=true) }}
|
{{ facts(scan.overview, wide=true) }}
|
||||||
|
|
||||||
{% for stage in scan.stages %}
|
{% for stage in scan.stages %}
|
||||||
@@ -133,7 +142,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if project %}
|
{% if project %}
|
||||||
<h1 style="break-before: page">{{ project.title }}</h1>
|
<h1 id="{{ project.id }}" style="break-before: page">{{ project.title }}</h1>
|
||||||
{{ facts(project.overview, wide=true) }}
|
{{ facts(project.overview, wide=true) }}
|
||||||
|
|
||||||
{% if project.languages %}
|
{% if project.languages %}
|
||||||
@@ -150,7 +159,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
<h1>{{ summary.title }}</h1>
|
<h1 id="{{ summary.id }}"{% if not project %} style="break-before: page"{% endif %}>{{ summary.title }}</h1>
|
||||||
{{ facts(summary.overview, wide=true) }}
|
{{ facts(summary.overview, wide=true) }}
|
||||||
|
|
||||||
{% for block in summary.distributions %}
|
{% for block in summary.distributions %}
|
||||||
@@ -164,12 +173,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if critical %}
|
{% if critical %}
|
||||||
<h2 style="break-before: page">{{ critical.title }}</h2>
|
<h2 id="{{ critical.id }}" style="break-before: page">{{ critical.title }}</h2>
|
||||||
{{ grid(critical.table) }}
|
{{ grid(critical.table) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<h1{% if section.new_page %} style="break-before: page"{% endif %}>{{ section.title }}</h1>
|
<h1{% if section.id %} id="{{ section.id }}"{% endif %}{% if section.new_page %} style="break-before: page"{% endif %}>{{ section.title }}</h1>
|
||||||
|
|
||||||
{% for group in section.groups %}
|
{% for group in section.groups %}
|
||||||
<h2>{{ group.title }}</h2>
|
<h2>{{ group.title }}</h2>
|
||||||
|
|||||||
Reference in new issue
Block a user