Updates to new format #1
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
|
||||
[--project <name>] [--manufacturer <name>] [--version <version>]
|
||||
```
|
||||
|
||||
**Out of a folder** — neither Postgres nor the bucket, just the artifacts as the pipeline exported
|
||||
@@ -30,12 +31,32 @@ them:
|
||||
|
||||
```
|
||||
report_generator from-folder --artifacts ./sample [--path <file-or-directory>]
|
||||
[--project <name>] [--manufacturer <name>] [--version <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.
|
||||
|
||||
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` and `cloc.txt` (cloc's plain
|
||||
table, for the lines of code and the three to five main languages) are optional; without either the
|
||||
project page is 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. the dependency vulnerabilities from the VDR, as CipherCell SCA,
|
||||
4. one section per SARIF run, named after its tool: CipherCell Lynkeus, then CipherCell SAST, then
|
||||
any other tool in the order its run appears,
|
||||
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 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
|
||||
@@ -64,6 +85,32 @@ by the one naming convention in `cc_common.config.env_var_name`, so it stays wha
|
||||
calls it. A `.env` beside the `pyproject.toml` is read too. `--config` is resolved relative to the
|
||||
working directory.
|
||||
|
||||
## The summary report
|
||||
|
||||
A shorter document, written by hand in Markdown (like `SUMMARY.md` beside the scan's artifacts) and
|
||||
set in the dress of the full report: the same cover, header, footers, table of contents, headings
|
||||
and tables.
|
||||
|
||||
```
|
||||
report_generator from-markdown --markdown SUMMARY.md [--path <file-or-directory>]
|
||||
[--project <name>] [--manufacturer <name>] [--scan_id <uuid>]
|
||||
```
|
||||
|
||||
How the Markdown is read (`report_generator/summary.py`):
|
||||
|
||||
- The first `#` heading is the title. Written as `<title>: <subject>`, the subject is the project
|
||||
unless `--project` is given.
|
||||
- What comes before the first section is the introduction, and goes on the cover.
|
||||
- Every further `#` or `##` heading is a section: it starts a new page, is listed in the table of
|
||||
contents and keeps the anchor GitHub gives it, so links such as `[…](#1-static-analysis-sast)`
|
||||
work in the PDF. `###` and below become the report's subheadings.
|
||||
- A paragraph that is only bold text (`**Remaining findings**`) is set as a subheading.
|
||||
- Tables are drawn as the report's grids. A number in a column headed *Severity*, *CVSS* or *Score*
|
||||
carries its severity colour, and a row starting with `**Total**` is set as a total.
|
||||
|
||||
The scan id is optional here; without it the cover leaves it out and the footer names the project.
|
||||
The file is written as `summary_<scan-id>.pdf`, or `summary_<project>.pdf` without one.
|
||||
|
||||
## How it is put together
|
||||
|
||||
Four steps, each its own module, so that the one that will change is the one that is separate:
|
||||
@@ -74,6 +121,7 @@ Four steps, each its own module, so that the one that will change is the one tha
|
||||
| `report_generator/rendering.py` | Decides which stored file feeds which part of the report, and parses it. |
|
||||
| `report_generator/context.py` | Maps the parsed artifacts onto the page, and writes `<xxx>` where they say nothing. |
|
||||
| `report_generator/layout.py` | Draws `template/report.html.j2` through WeasyPrint. |
|
||||
| `report_generator/summary.py` | Sets a Markdown summary in `template/summary.html.j2`, drawn the same way. |
|
||||
|
||||
No parsing of its own. SARIF and the vulnerability report are read by the summarisers in
|
||||
`cipherverifier.core.services`, which are written to be free of Django and of any store; the CVSS
|
||||
@@ -82,9 +130,8 @@ vectors by `persistent_storage.utils.cvss_utils`.
|
||||
### What it cannot fill in
|
||||
|
||||
A field the artifacts do not answer is written as `<xxx>`, never guessed or quietly dropped. Today
|
||||
that is the report and artifact ids, the 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.
|
||||
that is the manufacturer (unless given with `--manufacturer`), and, when there is no `cloc.txt`, the
|
||||
analysed lines of code and the language breakdown.
|
||||
|
||||
A measured zero survives: `0 filtered` is a reading, and a blank is a gap.
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ dependencies = [
|
||||
# needs the native Pango and cairo libraries, which pip does not install -- see the README.
|
||||
"jinja2>=3.1.6",
|
||||
"weasyprint>=69.0",
|
||||
# The summary report, which is written in Markdown and set in the same template.
|
||||
"markdown-it-py>=4.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -5,6 +5,7 @@ from .vendor.cc_common.config import load_environment
|
||||
from .vendor.persistent_storage.shared import inject_secrets
|
||||
|
||||
from .commands.generate import FolderProperties, GenerateProperties, from_folder, generate
|
||||
from .commands.summary import SummaryProperties, from_markdown
|
||||
|
||||
|
||||
def run() -> None:
|
||||
@@ -25,6 +26,12 @@ def run() -> None:
|
||||
FolderProperties,
|
||||
help="Builds the report from a folder of exported artifacts, without Postgres or the bucket.",
|
||||
)
|
||||
cli.register_command(
|
||||
"from-markdown",
|
||||
from_markdown,
|
||||
SummaryProperties,
|
||||
help="Builds a summary report from a Markdown file, in the style of the full report.",
|
||||
)
|
||||
cli.run()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Reads the project's size and languages out of the table cloc prints.
|
||||
|
||||
cloc writes one row per language -- files, blank, comment and code lines -- and a ``SUM:`` row under
|
||||
them. Only the code lines are reported: that is what "lines of code" means to a reader, and blanks
|
||||
and comments say nothing about how much there is to analyse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# How many languages the report names at most, and how many it names at least where there are that
|
||||
# many. Between the two, a language has to carry :data:`MINIMUM_SHARE` of the code to be named.
|
||||
MOST_LANGUAGES = 5
|
||||
FEWEST_LANGUAGES = 3
|
||||
MINIMUM_SHARE = 0.01
|
||||
|
||||
# What cloc counts that is not code anyone analyses: prose, and data files. Configuration and build
|
||||
# files -- YAML, Dockerfiles, TOML -- are scanned like code and so stay in.
|
||||
NOT_CODE = frozenset(
|
||||
{
|
||||
"AsciiDoc",
|
||||
"CSV",
|
||||
"JSON",
|
||||
"JSON5",
|
||||
"Markdown",
|
||||
"reStructuredText",
|
||||
"SVG",
|
||||
"Text",
|
||||
"TSV",
|
||||
}
|
||||
)
|
||||
|
||||
# A row of the table: a name, which may contain spaces, then the four counts.
|
||||
_ROW = re.compile(r"^(?P<name>\S.*?)\s+(?P<files>\d+)\s+\d+\s+\d+\s+(?P<code>\d+)\s*$")
|
||||
|
||||
|
||||
def parse_cloc(content: bytes | None) -> dict[str, Any] | None:
|
||||
"""Read cloc's table.
|
||||
|
||||
Args:
|
||||
content: The table as cloc printed it, or nothing where the scan stored none.
|
||||
|
||||
Returns:
|
||||
The ``files`` and ``code`` lines of the whole project, and its ``languages``
|
||||
with the same two counts each, largest first; ``None`` where there is no
|
||||
table to read.
|
||||
"""
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
languages = []
|
||||
total = None
|
||||
|
||||
for line in content.decode("utf-8", errors="replace").splitlines():
|
||||
row = _ROW.match(line.strip())
|
||||
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
counts = {"files": int(row["files"]), "code": int(row["code"])}
|
||||
|
||||
if row["name"] == "SUM:":
|
||||
total = counts
|
||||
else:
|
||||
languages.append({"name": row["name"], **counts})
|
||||
|
||||
if not languages:
|
||||
return None
|
||||
|
||||
# cloc leaves the sum out when it counted a single language.
|
||||
total = total or {
|
||||
"files": sum(one["files"] for one in languages),
|
||||
"code": sum(one["code"] for one in languages),
|
||||
}
|
||||
|
||||
return {**total, "languages": sorted(languages, key=lambda one: -one["code"])}
|
||||
|
||||
|
||||
def main_languages(cloc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The languages worth naming: the largest few that are code, leaving out the negligible ones."""
|
||||
code = [one for one in cloc["languages"] if one["name"] not in NOT_CODE]
|
||||
counted = sum(one["code"] for one in code) or 1
|
||||
|
||||
return [
|
||||
one
|
||||
for rank, one in enumerate(code[:MOST_LANGUAGES])
|
||||
if rank < FEWEST_LANGUAGES or one["code"] / counted >= MINIMUM_SHARE
|
||||
]
|
||||
@@ -32,11 +32,20 @@ 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.
|
||||
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.
|
||||
"""
|
||||
|
||||
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.")
|
||||
version: str | None = Field(
|
||||
default=None, description="The version of the scanned project. Replaces the commit hash when given."
|
||||
)
|
||||
storage_config: StorageConfiguration
|
||||
|
||||
|
||||
@@ -48,6 +57,9 @@ 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.
|
||||
version: The version of the scanned project, as above.
|
||||
"""
|
||||
|
||||
artifacts: Path = Field(description="Folder of exported artifacts to build the report from.")
|
||||
@@ -55,6 +67,11 @@ 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.")
|
||||
version: str | None = Field(
|
||||
default=None, description="The version of the scanned project. Replaces the commit hash when given."
|
||||
)
|
||||
|
||||
|
||||
class GeneratedReport(BaseModel):
|
||||
@@ -89,7 +106,13 @@ 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,
|
||||
version=properties.version,
|
||||
)
|
||||
|
||||
|
||||
def from_folder(properties: FolderProperties) -> GeneratedReport:
|
||||
@@ -107,10 +130,25 @@ 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,
|
||||
version=properties.version,
|
||||
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,
|
||||
version: 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 +157,10 @@ 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.
|
||||
version: The version the caller supplied, if any.
|
||||
write_metrics: Whether to write ``metrics.json`` beside the PDF.
|
||||
|
||||
Returns:
|
||||
The report that was written.
|
||||
@@ -127,7 +169,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, version=version)
|
||||
destination = _destination(path, name)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""The summary report: a Markdown summary, set as a PDF in the style of the full report."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..vendor.cc_common.api import PipelineError, context
|
||||
from ..vendor.cc_common.utils import print
|
||||
|
||||
from ..summary import render_summary
|
||||
from .generate import _destination
|
||||
|
||||
|
||||
class SummaryProperties(BaseModel):
|
||||
"""Configuration properties for the from-markdown CLI command.
|
||||
|
||||
Attributes:
|
||||
markdown: The Markdown summary to set.
|
||||
scan_id: The scan it is about. Named on the cover and in the footers
|
||||
when given, and left out when not.
|
||||
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. Taken from the summary's title
|
||||
when not given.
|
||||
manufacturer: Who makes the scanned product, as the summary states it.
|
||||
"""
|
||||
|
||||
markdown: Path = Field(description="The Markdown summary to build the PDF from.")
|
||||
scan_id: UUID | None = Field(default=None, description="The scan the summary is about.")
|
||||
path: Path | None = Field(default=None, description="Where to write the PDF summary.")
|
||||
project: str | None = Field(
|
||||
default=None, description="The name of the scanned project. Read from the title when omitted."
|
||||
)
|
||||
manufacturer: str | None = Field(default=None, description="The manufacturer of the scanned product.")
|
||||
|
||||
|
||||
class GeneratedSummary(BaseModel):
|
||||
"""The summary that was written.
|
||||
|
||||
Attributes:
|
||||
source: The Markdown it was set from.
|
||||
path: The file written.
|
||||
size: How large it came out, in bytes.
|
||||
"""
|
||||
|
||||
source: Path
|
||||
path: Path
|
||||
size: int
|
||||
|
||||
def print_as_cc_common_result(self) -> None:
|
||||
"""Say what was written and where."""
|
||||
print(f"[bold]Summary from {self.source}[/bold]")
|
||||
print(f" {self.size / 1024:.0f} kB written to {self.path}")
|
||||
|
||||
|
||||
def from_markdown(properties: SummaryProperties) -> GeneratedSummary:
|
||||
"""Build the summary report from a Markdown file.
|
||||
|
||||
Args:
|
||||
properties: The Markdown file, what to say about it, and the destination.
|
||||
|
||||
Returns:
|
||||
The summary that was written.
|
||||
|
||||
Raises:
|
||||
PipelineError: The Markdown cannot be read, or the destination cannot be written.
|
||||
"""
|
||||
try:
|
||||
markdown = properties.markdown.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
message = f"Cannot read the summary '{properties.markdown}': {error}"
|
||||
|
||||
raise PipelineError(message) from error
|
||||
|
||||
name, content = render_summary(
|
||||
markdown,
|
||||
scan_id=properties.scan_id,
|
||||
project=properties.project,
|
||||
manufacturer=properties.manufacturer,
|
||||
)
|
||||
destination = _destination(properties.path, name)
|
||||
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content)
|
||||
except OSError as error:
|
||||
message = f"Cannot write the summary to '{destination}': {error}"
|
||||
|
||||
raise PipelineError(message) from error
|
||||
|
||||
context.logger.info(f"Summary written to {destination} ✔")
|
||||
|
||||
return GeneratedSummary(source=properties.markdown, path=destination, size=len(content))
|
||||
+312
-248
@@ -17,12 +17,18 @@ inside the web application that happens to import them today.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .vendor.cipherverifier.core.services.sarif import summarize_sarif
|
||||
from .vendor.cipherverifier.core.services.sarif import _build_finding, _build_report, _rules_by_id
|
||||
from .vendor.cipherverifier.core.services.vdr import summarize_vdr
|
||||
|
||||
from .cloc import main_languages
|
||||
from .layout import TITLE
|
||||
from .severity import finding_severity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .vendor.cipherverifier.core.services.sarif import Finding, SarifReport
|
||||
from .vendor.cipherverifier.core.services.vdr import VdrReport, Vulnerability
|
||||
@@ -32,32 +38,40 @@ if TYPE_CHECKING:
|
||||
# What stands in for a field the stored artifacts do not answer.
|
||||
UNKNOWN = "<xxx>"
|
||||
|
||||
# How many of each list the report carries. A report is read, not audited; the full set is in the
|
||||
# artifacts it was built from.
|
||||
# How many entries the summary lists carry. The sections themselves list every finding.
|
||||
MOST_CRITICAL = 6
|
||||
CARDS_PER_GROUP = 8
|
||||
BARS = 10
|
||||
|
||||
# The severity a SARIF level is reported under. SARIF names four levels and the report bands five,
|
||||
# so the mapping is written down rather than inferred from the spelling.
|
||||
SEVERITY_OF_LEVEL = {
|
||||
"error": "Critical",
|
||||
"warning": "High",
|
||||
"note": "Medium",
|
||||
"none": "Low",
|
||||
"info": "Low",
|
||||
}
|
||||
|
||||
SEVERITY_ORDER = ("Critical", "High", "Medium", "Low")
|
||||
|
||||
# What the dependency check is called wherever the report names its source.
|
||||
SCA = "CipherCell SCA"
|
||||
|
||||
def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str, Any]:
|
||||
# The order the sources are reported in, everywhere: the dependency check first, then the SARIF
|
||||
# tools in this order. A tool not named here follows them, in the order its run appears.
|
||||
TOOL_ORDER = ("CipherCell Lynkeus", "CipherCell SAST")
|
||||
|
||||
# A full git commit SHA.
|
||||
_COMMIT = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
def build_context(
|
||||
sources: ReportSources,
|
||||
documents: dict[str, Any],
|
||||
*,
|
||||
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.
|
||||
|
||||
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.
|
||||
version: The version the caller supplied. Wins over the one in the metadata.
|
||||
|
||||
Returns:
|
||||
The context :func:`layout.render_pdf`
|
||||
@@ -65,31 +79,84 @@ def build_context(sources: ReportSources, documents: dict[str, Any]) -> dict[str
|
||||
the template renders as a shorter report.
|
||||
"""
|
||||
metadata = documents.get("metadata") or {}
|
||||
sast = summarize_sarif(documents["findings"]) if documents.get("findings") else None
|
||||
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, sast, sca),
|
||||
"scan": _scan(sources, metadata),
|
||||
"project": _project(metadata),
|
||||
"summary": _summary(sast, sca, documents.get("metrics")),
|
||||
"sections": [],
|
||||
"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],
|
||||
}
|
||||
|
||||
critical = _critical(sast, sca)
|
||||
# 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(project, version, documents.get("cloc"))
|
||||
|
||||
critical = _critical(tools, sca)
|
||||
|
||||
if critical["table"]["rows"]:
|
||||
context["critical"] = critical
|
||||
|
||||
if sast is not None:
|
||||
context["sections"].append(_sast_section(sast))
|
||||
set_aside = _set_aside_section(tools)
|
||||
|
||||
if sca is not None:
|
||||
context["sections"].append(_sca_section(sca))
|
||||
if set_aside is not None:
|
||||
context["sections"].append(set_aside)
|
||||
|
||||
context["contents"] = _contents(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)]
|
||||
|
||||
tools = [(_tool_name(run), _summarize(run)) for run in runs]
|
||||
|
||||
return sorted(tools, key=lambda pair: TOOL_ORDER.index(pair[0]) if pair[0] in TOOL_ORDER else len(TOOL_ORDER))
|
||||
|
||||
|
||||
def _summarize(run: dict[str, Any]) -> SarifReport:
|
||||
"""Summarise one run, as :func:`summarize_sarif` would, keeping each result's own score.
|
||||
|
||||
The summariser reads ``security-severity`` off the rule only. Lynkeus scores each result instead,
|
||||
and a result's own score is the more specific one, so it takes the rule's place.
|
||||
"""
|
||||
rules = _rules_by_id([run])
|
||||
findings = []
|
||||
|
||||
for result in run.get("results") or []:
|
||||
finding = _build_finding(result, run, rules)
|
||||
stated = (result.get("properties") or {}).get("security-severity")
|
||||
|
||||
if stated is not None:
|
||||
finding.rule_security_severity = str(stated)
|
||||
|
||||
findings.append(finding)
|
||||
|
||||
return _build_report(findings, ranked=any(finding.rank is not None for finding in findings))
|
||||
|
||||
|
||||
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 ("project", "summary", "critical") if key in context]
|
||||
|
||||
return [{"id": part["id"], "title": part["title"]} for part in [*parts, *context["sections"]]]
|
||||
|
||||
|
||||
# --- the page ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -117,45 +184,44 @@ def _stated(value: Any) -> str: # noqa: ANN401
|
||||
|
||||
def _cover(
|
||||
sources: ReportSources,
|
||||
metadata: dict[str, Any],
|
||||
sast: SarifReport | None,
|
||||
tools: list[tuple[str, SarifReport]],
|
||||
sca: VdrReport | None,
|
||||
*,
|
||||
project: str | None,
|
||||
manufacturer: str | None,
|
||||
version: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""The first page."""
|
||||
configuration = metadata.get("configuration") or {}
|
||||
technologies = [name for name, present in (("SAST", sast is not None), ("SCA", sca is not None)) if present]
|
||||
technologies = ([SCA] if sca is not None else []) + [name for name, _ in tools]
|
||||
|
||||
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": "SAST/SCA Report",
|
||||
"title": TITLE,
|
||||
"identity": [
|
||||
_fact("Scan Identifier", sources.scan_id, kind="mono"),
|
||||
# Neither exists in the store today: a scan has artifacts, and a report of it is not
|
||||
# itself recorded as one.
|
||||
_fact("Report ID", None, kind="mono"),
|
||||
_fact("Artifact ID", None, kind="mono"),
|
||||
_fact("Technology", ", ".join(technologies) or None),
|
||||
_fact("Project", configuration.get("repository_name"), gap=True),
|
||||
_fact("Version", configuration.get("repository_version")),
|
||||
_fact("Manufacturer", manufacturer),
|
||||
_fact("Project", project),
|
||||
_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("Manufacturer", None),
|
||||
_fact("Scan Identifier", sources.scan_id, kind="mono"),
|
||||
],
|
||||
"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:
|
||||
"""How many issues of each severity survived filtering, over both halves of the pipeline."""
|
||||
def _severity_ledger(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any] | None:
|
||||
"""How many issues of each severity survived filtering, over every tool and the dependencies."""
|
||||
counts = dict.fromkeys(SEVERITY_ORDER, 0)
|
||||
|
||||
for finding in _true_findings(sast):
|
||||
counts[SEVERITY_OF_LEVEL.get(finding.level, "Low")] += 1
|
||||
for _, report in tools:
|
||||
for finding in _kept(report):
|
||||
counts[_finding_severity(finding)] += 1
|
||||
|
||||
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()):
|
||||
return None
|
||||
@@ -168,84 +234,76 @@ def _severity_ledger(sast: SarifReport | None, sca: VdrReport | None) -> dict[st
|
||||
return {"title": "True issues by severity", "rows": rows}
|
||||
|
||||
|
||||
def _scan(sources: ReportSources, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""What the pipeline did, stage by stage."""
|
||||
stages = [stage for stage in metadata.get("stages") or [] if isinstance(stage, dict)]
|
||||
failed = [stage["label"] for stage in stages if stage.get("status") not in ("success", None)]
|
||||
def _version(metadata: dict[str, Any]) -> str | None:
|
||||
"""What version of the project was scanned: the stated one, else the commit the SBOM was uploaded for.
|
||||
|
||||
return {
|
||||
"title": "CipherCell Scan Information",
|
||||
"overview": [
|
||||
_fact("Scan started at", _moment(metadata.get("pipeline_start_iso"))),
|
||||
_fact("Scan duration", _duration(metadata.get("pipeline_duration_seconds"))),
|
||||
_fact("Scan completed at", _moment(metadata.get("pipeline_end_iso"))),
|
||||
_fact(
|
||||
"Pipeline Steps", ", ".join(_stated(stage.get("label") or stage.get("key")) for stage in stages) or None
|
||||
),
|
||||
_fact("Errors Detected", ", ".join(failed) if failed else "None" if stages else None),
|
||||
],
|
||||
"stages": [_stage(stage, sources) for stage in stages],
|
||||
}
|
||||
|
||||
|
||||
def _stage(stage: dict[str, Any], sources: ReportSources) -> dict[str, Any]:
|
||||
"""One stage of the pipeline.
|
||||
|
||||
The tools a stage ran and the profile it ran them under are not recorded in the metadata, so
|
||||
they are left unstated. What is recorded is when it ran, how it ended, and -- indirectly -- what
|
||||
it produced, which is read off the names of the files the scan stored.
|
||||
The pipeline leaves ``repository_version`` empty but tags the Dependency-Track upload with the
|
||||
commit it scanned, which is the most precise version there is.
|
||||
"""
|
||||
return {
|
||||
"title": stage.get("label") or stage.get("key") or UNKNOWN,
|
||||
"rows": [
|
||||
_fact("Selected Tools", None),
|
||||
_fact("Profile", None),
|
||||
_fact("Duration", _duration(stage.get("duration_seconds"))),
|
||||
_fact("Created Artifacts", ", ".join(sorted(sources.files)) or None, kind="mono"),
|
||||
_fact("Errors Detected", "None" if stage.get("status") == "success" else stage.get("status")),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _project(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""What was scanned."""
|
||||
configuration = metadata.get("configuration") or {}
|
||||
stated = configuration.get("repository_version")
|
||||
|
||||
if stated:
|
||||
return str(stated)
|
||||
|
||||
commit = (configuration.get("deptrace_upload") or {}).get("project_version")
|
||||
|
||||
return commit if isinstance(commit, str) and _COMMIT.fullmatch(commit) else None
|
||||
|
||||
|
||||
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", configuration.get("repository_name")),
|
||||
_fact("Version", configuration.get("repository_version")),
|
||||
# Nothing in the pipeline counts lines, so nothing here can report them.
|
||||
_fact("Analysed lines of code", None),
|
||||
_fact("Project", project),
|
||||
_fact("Version", version, kind="mono"),
|
||||
_fact("Analysed lines of code", f"{cloc['code']:,} in {cloc['files']:,} files" if cloc else None),
|
||||
],
|
||||
# Left as one unstated row rather than a table of zeroes: no stage reports a language
|
||||
# breakdown, and a share invented from the file extensions of the findings would be a
|
||||
# measurement of where the bugs are, not of what the project is written in.
|
||||
"languages": {"title": "Languages", "rows": [{"label": UNKNOWN, "share": UNKNOWN}]},
|
||||
"languages": {"title": "Main languages", "rows": _languages(cloc)},
|
||||
}
|
||||
|
||||
|
||||
def _summary(sast: SarifReport | None, sca: VdrReport | None, metrics: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def _languages(cloc: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""The main languages with their lines of code and share of the whole project.
|
||||
|
||||
Left as one unstated row rather than a table of zeroes where cloc did not run: a share invented
|
||||
from the file extensions of the findings would measure where the bugs are, not what the project
|
||||
is written in.
|
||||
"""
|
||||
if not cloc:
|
||||
return [{"label": UNKNOWN, "share": UNKNOWN}]
|
||||
|
||||
return [
|
||||
{"label": one["name"], "share": f"{one['code']:,} lines ({one['code'] / cloc['code'] * 100:.1f} %)"}
|
||||
for one in main_languages(cloc)
|
||||
]
|
||||
|
||||
|
||||
def _summary(
|
||||
tools: list[tuple[str, SarifReport]], sca: VdrReport | None, metrics: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""The counts, and where they concentrate."""
|
||||
true_sast = _true_findings(sast)
|
||||
sca_total = sca.total if sca else 0
|
||||
raw = (sast.total if sast else 0) + sca_total
|
||||
filtered = sast.false_positive_count if sast else 0
|
||||
raw = sum(report.total for _, report in tools) + sca_total
|
||||
true_issues = ([f"{sca_total} {SCA}"] if sca else []) + [f"{len(_kept(report))} {name}" for name, report in tools]
|
||||
|
||||
summary = {
|
||||
"id": "summary",
|
||||
"title": "Summary",
|
||||
"overview": [
|
||||
_fact("Raw findings", raw or None),
|
||||
# Only SAST findings carry a filtering verdict. A vulnerability suppressed in
|
||||
# Dependency-Track never reaches the report at all, so the two cannot be added up.
|
||||
_fact("Filtered", f"{filtered} (SAST)" if sast else None),
|
||||
_fact("True issues", f"{len(true_sast)} SAST, {sca_total} SCA" if sast or sca else None),
|
||||
# Each half stated only where its artifact was there. A scan that ran no SCA stage
|
||||
# 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),
|
||||
# Only SARIF findings can be set aside. A vulnerability suppressed in Dependency-Track
|
||||
# never reaches the report at all, so the two cannot be added up.
|
||||
_fact(
|
||||
"Suppressed / False Positives",
|
||||
", ".join(f"{len(_set_aside(report))} {name}" for name, report in tools) 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
|
||||
@@ -297,13 +355,11 @@ def _metric_cards(dependencies: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
},
|
||||
{
|
||||
"title": "Temporal",
|
||||
"note": "Published dates and vulnerability age are calculated from the advisory timestamps. Time-to-exploit and volume trends require historical exploitation or scan data that this export does not contain.",
|
||||
"note": "Published dates and vulnerability age are calculated from the advisory timestamps.",
|
||||
"rows": [
|
||||
_fact("Newest published", _published(published.get("newest"))),
|
||||
_fact("Mean vulnerability age", _days(published.get("mean_days"))),
|
||||
_fact("Median vulnerability age", _days(published.get("median_days"))),
|
||||
_fact("Time to exploit", temporal.get("time_to_exploit_days")),
|
||||
_fact("CVE volume trends", temporal.get("cve_volume_trends")),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -313,8 +369,6 @@ def _metric_cards(dependencies: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
_fact("Affected dependencies", surface.get("products")),
|
||||
_fact("Potential remedies", _remedy(upgrades)),
|
||||
_fact("CWE coverage", taxonomy.get("coverage")),
|
||||
_fact("CWE abstraction level", taxonomy.get("abstraction_level")),
|
||||
_fact("CWE relationships", taxonomy.get("relationships")),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -340,35 +394,21 @@ def _remedy(value: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]:
|
||||
"""How many distinct kinds of issue each half of the pipeline found."""
|
||||
counted = []
|
||||
def _issue_types(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[str]:
|
||||
"""How many distinct kinds of true issue each tool, and the dependency check, found."""
|
||||
counted = [f"{sca.component_count} {SCA} affected components"] if sca is not None else []
|
||||
|
||||
if sast is not None:
|
||||
counted.append(f"{sast.rule_count} SAST rules")
|
||||
|
||||
if sca is not None:
|
||||
counted.append(f"{sca.component_count} affected components")
|
||||
|
||||
return counted
|
||||
return counted + [f"{len({one.rule_id for one in _kept(report)})} {name} rules" for name, report in tools]
|
||||
|
||||
|
||||
def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[str, Any]]:
|
||||
"""Where the issues concentrate: by rule for SAST, by package for SCA."""
|
||||
def _distributions(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> list[dict[str, Any]]:
|
||||
"""Where the issues concentrate: by package for SCA, by rule for each SARIF tool."""
|
||||
blocks = []
|
||||
|
||||
if findings:
|
||||
blocks.append(
|
||||
{
|
||||
"title": f"True SAST issues by rule ({len(findings)})",
|
||||
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
|
||||
}
|
||||
)
|
||||
|
||||
if sca and sca.vulnerabilities:
|
||||
blocks.append(
|
||||
{
|
||||
"title": f"Most affected components, true SCA issues ({sca.component_count})",
|
||||
"title": f"Most affected components, true {SCA} issues ({sca.component_count})",
|
||||
**_bars(
|
||||
_group(
|
||||
[(one, name) for one in sca.vulnerabilities for name in one.component_names],
|
||||
@@ -380,6 +420,17 @@ def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[
|
||||
}
|
||||
)
|
||||
|
||||
for name, report in tools:
|
||||
findings = _kept(report)
|
||||
|
||||
if findings:
|
||||
blocks.append(
|
||||
{
|
||||
"title": f"True {name} issues by rule ({len(findings)})",
|
||||
**_bars(_group(findings, lambda one: one.rule_id or UNKNOWN, _finding_severity)),
|
||||
}
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
@@ -450,139 +501,153 @@ def _caveat(shown: list[Any], grouped: list[Any], largest: int) -> str:
|
||||
return f"{len(shown)} of {len(grouped)} shown; {scale}"
|
||||
|
||||
|
||||
def _critical(sast: SarifReport | None, sca: VdrReport | None) -> dict[str, Any]:
|
||||
"""The handful worth reading first, whichever half of the pipeline found them."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
def _critical(tools: list[tuple[str, SarifReport]], sca: VdrReport | None) -> dict[str, Any]:
|
||||
"""The handful worth reading first, whichever tool found them."""
|
||||
rows: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
for finding in sorted(_true_findings(sast), key=lambda one: SEVERITY_ORDER.index(_finding_severity(one))):
|
||||
for vulnerability in sca.vulnerabilities if sca else []:
|
||||
severity = _vdr_severity(vulnerability)
|
||||
rows.append(
|
||||
(
|
||||
severity,
|
||||
{
|
||||
"cells": [
|
||||
"SAST",
|
||||
{"value": _finding_severity(finding), "kind": "severity"},
|
||||
_stated(finding.rule_id),
|
||||
{"value": _stated(_where(finding)), "kind": "mono"},
|
||||
_stated(finding.message),
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
for vulnerability in sorted(
|
||||
sca.vulnerabilities if sca else [],
|
||||
key=lambda one: SEVERITY_ORDER.index(_vdr_severity(one)),
|
||||
):
|
||||
rows.append(
|
||||
{
|
||||
"cells": [
|
||||
"SCA",
|
||||
{"value": _vdr_severity(vulnerability), "kind": "severity"},
|
||||
SCA,
|
||||
{"value": severity, "kind": "severity"},
|
||||
_stated(vulnerability.id),
|
||||
{"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"},
|
||||
_stated(vulnerability.description),
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for name, report in tools:
|
||||
for finding in _kept(report):
|
||||
severity = _finding_severity(finding)
|
||||
rows.append(
|
||||
(
|
||||
severity,
|
||||
{
|
||||
"cells": [
|
||||
name,
|
||||
{"value": severity, "kind": "severity"},
|
||||
_stated(finding.rule_id),
|
||||
{"value": _stated(_where(finding)), "kind": "mono"},
|
||||
_stated(_text(finding.message)),
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Sorted is stable, so within a severity the sources keep the report's order, and each tool its own
|
||||
# -- the rank, where it has one.
|
||||
rows.sort(key=lambda pair: SEVERITY_ORDER.index(pair[0]))
|
||||
|
||||
return {
|
||||
"id": "critical",
|
||||
"title": "Most Critical Vulnerabilities",
|
||||
"table": {
|
||||
"columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
|
||||
"rows": rows[:MOST_CRITICAL],
|
||||
"columns": ["Source", "Severity", "CVE/CWE", "Affected File / Component", "Description"],
|
||||
"rows": [row for _, row in rows[:MOST_CRITICAL]],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sast_section(sast: SarifReport) -> dict[str, Any]:
|
||||
"""The SAST findings, split into what was kept and what was set aside."""
|
||||
true_findings = _true_findings(sast)
|
||||
filtered = [one for one in sast.findings if one.false_positive]
|
||||
groups: list[dict[str, Any]] = []
|
||||
def _tool_section(name: str, report: SarifReport) -> dict[str, Any]:
|
||||
"""Every finding of one tool that was not set aside."""
|
||||
kept = _kept(report)
|
||||
group: dict[str, Any] = {"title": f"True Issues ({len(kept)})", "cards": [_finding_card(one) for one in kept]}
|
||||
|
||||
if true_findings:
|
||||
groups.append(
|
||||
{
|
||||
"title": f"True Issues ({len(true_findings)})",
|
||||
"cards": [_finding_card(one) for one in true_findings[:CARDS_PER_GROUP]],
|
||||
}
|
||||
)
|
||||
if not kept:
|
||||
group["note"] = f"Every finding {name} reported is listed under False Positives and Suppressed Findings."
|
||||
|
||||
if filtered:
|
||||
groups.append(
|
||||
return {"id": _anchor(name), "title": name, "new_page": True, "groups": [group]}
|
||||
|
||||
|
||||
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": (
|
||||
"The following findings were reported by the scanners but assessed as not exploitable in "
|
||||
"this artifact. Each entry carries the verdict that filtered it."
|
||||
"The following findings were reported but set aside: classified as false positives, or "
|
||||
"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": _filtered_table(filtered),
|
||||
"table": _set_aside_table(grouped),
|
||||
}
|
||||
)
|
||||
]
|
||||
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."""
|
||||
rows = [
|
||||
_row("Severity", _finding_severity(finding), kind="severity"),
|
||||
_row("Location", _where(finding), kind="mono"),
|
||||
_row("Description", finding.message),
|
||||
rows = []
|
||||
|
||||
# A finding a tool ruled out carries no severity worth stating.
|
||||
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),
|
||||
]
|
||||
|
||||
if filtered_out:
|
||||
rows.append(
|
||||
_row(
|
||||
"Filtering Reason",
|
||||
finding.polygraph_reasoning,
|
||||
aside=True,
|
||||
token=finding.polygraph_verdict,
|
||||
if set_aside:
|
||||
reason = finding.polygraph_reasoning or "; ".join(
|
||||
one.justification for one in finding.suppressions if one.justification
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Polygraph's verdict where it looked at this one, and nothing where it did not: silence is
|
||||
# not a clean bill of health, and must not be written as one.
|
||||
rows.append(_row("Evidence", finding.verdict_label or None))
|
||||
rows.append(_row("Verdict", finding.verdict_label or "Suppressed"))
|
||||
rows.append(_row("Reason", reason, aside=True))
|
||||
elif finding.verdict_label:
|
||||
rows.append(_row("Evidence", finding.verdict_label))
|
||||
|
||||
return {"title": f"{_stated(finding.rule_id)} — {_stated(finding.rule_name)}", "rows": rows}
|
||||
|
||||
|
||||
def _filtered_table(findings: list[Finding]) -> dict[str, Any]:
|
||||
"""How many of each rule were filtered, and on what grounds."""
|
||||
grouped: dict[str, list[Finding]] = {}
|
||||
def _set_aside_table(grouped: list[tuple[str, list[Finding]]]) -> dict[str, Any]:
|
||||
"""How many findings of each tool and rule were set aside."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for finding in findings:
|
||||
grouped.setdefault(finding.rule_id or UNKNOWN, []).append(finding)
|
||||
for name, findings in grouped:
|
||||
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]] = [
|
||||
{
|
||||
"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})
|
||||
rows.append({"cells": ["Total", "", sum(len(findings) for _, findings in grouped)], "total": True})
|
||||
|
||||
return {
|
||||
"title": f"Filtered SAST issues by rule ({len(findings)})",
|
||||
"columns": ["Rule", "Filtered", "Dominant verdict"],
|
||||
"rows": rows,
|
||||
}
|
||||
return {"columns": ["Tool", "Rule", "Set aside"], "rows": rows}
|
||||
|
||||
|
||||
def _sca_section(sca: VdrReport) -> dict[str, Any]:
|
||||
"""The dependency findings."""
|
||||
return {
|
||||
"title": "SBOM / SCA Vulnerabilities",
|
||||
"id": "sca",
|
||||
"title": f"{SCA} — Dependency Vulnerabilities",
|
||||
"new_page": True,
|
||||
"groups": [
|
||||
{
|
||||
"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],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -622,7 +687,6 @@ def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]:
|
||||
kind="mono",
|
||||
),
|
||||
_row("Known for", f"{metrics.published_days_ago} days" if metrics.published_days_ago else None),
|
||||
_row("Recommendation", vulnerability.recommendation),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -630,14 +694,34 @@ def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]:
|
||||
# --- 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."""
|
||||
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:
|
||||
"""The severity band a SARIF level falls in."""
|
||||
return SEVERITY_OF_LEVEL.get(finding.level, "Low")
|
||||
"""The severity band a SARIF finding falls in; see :mod:`severity`."""
|
||||
return finding_severity(finding.rule_id, finding.level, finding.rule_security_severity)
|
||||
|
||||
|
||||
def _vdr_severity(vulnerability: Vulnerability) -> str:
|
||||
@@ -648,13 +732,14 @@ def _vdr_severity(vulnerability: Vulnerability) -> str:
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
line = finding.location.start_line
|
||||
|
||||
return f"{finding.location.file}:{line}" if line else finding.location.file
|
||||
return ", ".join(
|
||||
f"{one.file}:{one.start_line}" if one.start_line else one.file
|
||||
for one in [finding.location, *finding.extra_locations]
|
||||
)
|
||||
|
||||
|
||||
def _percent(share: float | None) -> str | None:
|
||||
@@ -663,24 +748,3 @@ def _percent(share: float | None) -> str | None:
|
||||
return None
|
||||
|
||||
return f"{share * 100:.2f} %" if share < 0.01 else f"{share * 100:.1f} %"
|
||||
|
||||
|
||||
def _moment(stated: Any) -> str | None: # noqa: ANN401
|
||||
"""An ISO timestamp as the report writes it, or nothing where it cannot be read."""
|
||||
if not isinstance(stated, str):
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(stated).strftime("%A, %B %d, %Y %H:%M:%S (%Z)").replace("()", "").strip()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _duration(seconds: Any) -> str | None: # noqa: ANN401
|
||||
"""A number of seconds as ``HH:MM:SS``."""
|
||||
if not isinstance(seconds, (int, float)) or isinstance(seconds, bool):
|
||||
return None
|
||||
|
||||
whole = int(seconds)
|
||||
|
||||
return f"{whole // 3600:02d}:{whole % 3600 // 60:02d}:{whole % 60:02d}"
|
||||
@@ -23,7 +23,7 @@ TEMPLATE_NAME = "report.html.j2"
|
||||
STYLESHEET = TEMPLATE_DIR / "report.css"
|
||||
|
||||
# What the document calls itself, in the PDF's own metadata rather than only on its first page.
|
||||
TITLE = "SAST/SCA Report"
|
||||
TITLE = "Complete Security Findings Report"
|
||||
|
||||
AUTHOR = "CipherCell"
|
||||
|
||||
@@ -32,7 +32,6 @@ AUTHOR = "CipherCell"
|
||||
# shorter report rather than one full of empty tables.
|
||||
CONTEXT = {
|
||||
"cover": "scan_id, footer_date, logo, title, identity[], severity_ledger{title, rows[]}",
|
||||
"scan": "title, overview[], stages[{title, rows[], table?}]",
|
||||
"project": "title, overview[], languages{title, rows[]}",
|
||||
"summary": "title, overview[], distributions[{title, rows[], legend}]",
|
||||
"critical": "title, table{columns[], rows[]}",
|
||||
@@ -40,11 +39,12 @@ CONTEXT = {
|
||||
}
|
||||
|
||||
|
||||
def render_html(context: dict[str, Any]) -> str:
|
||||
def render_html(context: dict[str, Any], template: str = TEMPLATE_NAME) -> str:
|
||||
"""Render the report as an HTML document.
|
||||
|
||||
Args:
|
||||
context: The page-ready context; see :data:`CONTEXT`.
|
||||
template: Which template in :data:`TEMPLATE_DIR` to draw it with.
|
||||
|
||||
Returns:
|
||||
The rendered HTML.
|
||||
@@ -56,14 +56,16 @@ def render_html(context: dict[str, Any]) -> str:
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
return environment.get_template(TEMPLATE_NAME).render(**context)
|
||||
return environment.get_template(template).render(**context)
|
||||
|
||||
|
||||
def render_pdf(context: dict[str, Any]) -> bytes:
|
||||
def render_pdf(context: dict[str, Any], template: str = TEMPLATE_NAME, title: str = TITLE) -> bytes:
|
||||
"""Render the report as a PDF.
|
||||
|
||||
Args:
|
||||
context: The page-ready context; see :data:`CONTEXT`.
|
||||
template: Which template in :data:`TEMPLATE_DIR` to draw it with.
|
||||
title: What the document calls itself in its metadata.
|
||||
|
||||
Returns:
|
||||
The PDF.
|
||||
@@ -79,12 +81,12 @@ def render_pdf(context: dict[str, Any]) -> bytes:
|
||||
|
||||
# Relative URLs in the template and the stylesheet -- the mark, the font files -- resolve
|
||||
# against the template directory.
|
||||
document = HTML(string=render_html(context), base_url=str(TEMPLATE_DIR)).render(
|
||||
document = HTML(string=render_html(context, template), base_url=str(TEMPLATE_DIR)).render(
|
||||
stylesheets=[CSS(filename=str(STYLESHEET), font_config=fonts)],
|
||||
font_config=fonts,
|
||||
)
|
||||
|
||||
document.metadata.title = f"{TITLE} - {context.get('cover', {}).get('subject', '')}".strip(" -")
|
||||
document.metadata.title = f"{title} - {context.get('cover', {}).get('subject', '')}".strip(" -")
|
||||
document.metadata.authors = [AUTHOR]
|
||||
|
||||
return bytes(document.write_pdf())
|
||||
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from .vendor.cc_common.api import PipelineError
|
||||
from .vendor.cc_common.api import context as logging_context
|
||||
|
||||
from .cloc import parse_cloc
|
||||
from .context import build_context
|
||||
from .layout import render_pdf
|
||||
|
||||
@@ -27,40 +28,56 @@ if TYPE_CHECKING:
|
||||
# which stage produced it; the first one a scan carries wins.
|
||||
INPUTS = {
|
||||
"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"),
|
||||
}
|
||||
|
||||
# What a report cannot be built without. The rest is reported as far as it was found: a scan that
|
||||
# ran only half the pipeline is a shorter report, not a refusal.
|
||||
REQUIRED = ("metadata",)
|
||||
# A report needs at least one of these to have anything to say. The rest is reported as far as it
|
||||
# was found: a scan that ran only half the pipeline is a shorter report, not a refusal.
|
||||
REQUIRED_ANY = ("findings", "vulnerabilities")
|
||||
|
||||
# What the file is called. The scan it is about is in the name, because a directory of these is
|
||||
# otherwise unreadable.
|
||||
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,
|
||||
version: 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.
|
||||
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.
|
||||
|
||||
Raises:
|
||||
PipelineError: The scan is missing an input the report cannot be built
|
||||
without, or one of its documents cannot be parsed. Both are reported
|
||||
by name: "no PDF was produced" leaves whoever ran it guessing which
|
||||
stage of their pipeline did not finish.
|
||||
PipelineError: The scan carries neither findings nor vulnerabilities, or
|
||||
one of its documents cannot be parsed. Both are reported by name: "no
|
||||
PDF was produced" leaves whoever ran it guessing which stage of their
|
||||
pipeline did not finish.
|
||||
"""
|
||||
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
|
||||
documents["metrics"] = metrics
|
||||
missing = [name for name in REQUIRED if documents.get(name) is None]
|
||||
|
||||
if missing:
|
||||
message = f"Scan {sources.scan_id} carries no {' and no '.join(missing)}, so no report can be built."
|
||||
documents["cloc"] = parse_cloc(sources.first("cloc.txt"))
|
||||
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."
|
||||
|
||||
raise PipelineError(message)
|
||||
|
||||
@@ -68,7 +85,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, version=version)
|
||||
|
||||
return NAME.format(scan=sources.scan_id), render_pdf(context)
|
||||
|
||||
|
||||
def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401
|
||||
|
||||
@@ -97,108 +97,6 @@ def _cover() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _scan() -> dict[str, Any]:
|
||||
"""What the pipeline did, stage by stage, and what each stage was configured with."""
|
||||
return {
|
||||
"title": "CipherCell Scan Information",
|
||||
"overview": [
|
||||
_fact("Scan started at", "Wednesday, September 16, 2026 05:43:14 (UTC)"),
|
||||
_fact("Scan duration", "07:30:38"),
|
||||
_fact("Scan completed at", "Wednesday, September 16, 2026 13:13:52 (UTC)"),
|
||||
_fact(
|
||||
"Pipeline Steps",
|
||||
"Preanalysis, SAST, SBOM Dependency Tracking, SBOM Reachability, AI FP Detection, AI Ranking",
|
||||
),
|
||||
_fact("Errors Detected", "None (2 warnings, see SBOM Reachability)"),
|
||||
],
|
||||
"stages": [
|
||||
{
|
||||
"title": "Pre-Analysis Settings",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "Propylon v0.9.4"),
|
||||
_fact("Profile", "deterministic"),
|
||||
_fact("Errors Detected", "None"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "SAST Settings",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "ruff v0.14.4, bandit v1.8.6, cppcheck v2.18.0, semgrep v1.145.0"),
|
||||
_fact("Language Coverage", "C (cppcheck, semgrep), Python (ruff, bandit, semgrep)"),
|
||||
_fact("Rule Profile", "security — ruleset ciphercell-sec-2026.03, sha256 8f2b41c6…d97a"),
|
||||
_fact("Deduplication Method", "Deterministic"),
|
||||
_fact("Created Artifacts", "sast_combined.sarif", kind="mono"),
|
||||
_fact("Errors Detected", "None"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "SBOM Dependency Tracking Settings",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "syft v1.33.0, cdxgen v11.10.0, deptrace v0.7.2"),
|
||||
_fact("Profile", "Standard SBOM"),
|
||||
_fact("SBOM Format", "CycloneDX 1.6"),
|
||||
_fact("Advisory Sources", "NVD, OSV.dev, GitHub Advisory Database, Sonatype OSS Index"),
|
||||
_fact("Created Artifacts", "sbom.cdx.json, sbom.vdr.json", kind="mono"),
|
||||
_fact("Errors Detected", "None"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "SBOM Reachability",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "CodeQL v2.24.1, Joern v4.0.312"),
|
||||
_fact("Profile", "Default Taint Analysis"),
|
||||
_fact("Created Artifacts", "sbom_annotated.vdr.json", kind="mono"),
|
||||
_fact("Errors Detected", "2 warnings"),
|
||||
],
|
||||
"table": {
|
||||
"columns": ["ID", "Stage", "Warning"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"W-01",
|
||||
"Joern, JavaScript",
|
||||
"2 of 148 entry points could not be resolved (dynamic require() in "
|
||||
"server/plugins/loader.js:41). The 6 findings depending on these entry points "
|
||||
"were escalated and are reported conservatively as true issues.",
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"W-02",
|
||||
"CodeQL, C",
|
||||
"Database build skipped for vendored sources under third_party/; excluded by "
|
||||
"scan scope, no reachability verdict is claimed for those paths.",
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "AI FP Detection",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "Polygraph v1.6.2"),
|
||||
_fact("Profile", "static and escalation agent"),
|
||||
_fact("AI Model", "Qwen3-32B, digest sha256:4ab1…c70f"),
|
||||
_fact("Inference", "On-premise, CipherCell inference cluster"),
|
||||
_fact("Reasoning Effort", "Default (classification), Default (escalation)"),
|
||||
_fact("Response Cache", "Enabled"),
|
||||
_fact("Context Enrichment", "Static (50 lines before, 50 lines after)"),
|
||||
_fact("Errors Detected", "None"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Ranking",
|
||||
"rows": [
|
||||
_fact("Selected Tools", "Prism v0.4.1"),
|
||||
_fact("Effort", "Standard"),
|
||||
_fact("Context Size", "Minimal"),
|
||||
_fact("Errors Detected", "None"),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _project() -> dict[str, Any]:
|
||||
"""What was scanned, and what it is written in."""
|
||||
return {
|
||||
@@ -517,7 +415,6 @@ def sample_context() -> dict[str, Any]:
|
||||
"""A complete context, covering every section the template can draw."""
|
||||
return {
|
||||
"cover": _cover(),
|
||||
"scan": _scan(),
|
||||
"project": _project(),
|
||||
"summary": _summary(),
|
||||
"critical": _critical(),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""The severity a SARIF finding is reported under.
|
||||
|
||||
In order of preference:
|
||||
|
||||
1. The finding's own ``security-severity`` score, banded like CVSS. Where a tool scored the finding
|
||||
itself -- Lynkeus does -- that is the most specific reading there is.
|
||||
2. Its weakness class, from :data:`CWE_SEVERITY`. The tools do not agree on what their levels mean
|
||||
-- Trivy's ``error`` for a container running as root and Bandit's ``note`` for a hard-coded
|
||||
database password say more about the tool than about the risk -- so the CWE is preferred to them.
|
||||
3. Its SARIF level, from :data:`SEVERITY_OF_LEVEL`, for a finding with neither.
|
||||
|
||||
Each entry of the table is the severity a confirmed instance of that weakness typically carries, before any
|
||||
context about the application is known: roughly where the CVEs filed under it land on the CVSS
|
||||
bands. Critical is kept for classes that usually hand an attacker code execution or the whole data
|
||||
store; weaknesses that only matter together with another flaw, or that harden rather than defend,
|
||||
sit at Medium or Low.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CWE_SEVERITY = {
|
||||
# Injection that typically ends in code execution or full database access.
|
||||
"CWE-77": "Critical", # Command injection
|
||||
"CWE-78": "Critical", # OS command injection
|
||||
"CWE-89": "Critical", # SQL injection
|
||||
"CWE-94": "Critical", # Code injection
|
||||
"CWE-95": "Critical", # Eval injection
|
||||
"CWE-502": "Critical", # Deserialization of untrusted data
|
||||
# Direct compromise of confidentiality or access control, usually needing some foothold.
|
||||
"CWE-22": "High", # Path traversal
|
||||
"CWE-96": "High", # Static code injection
|
||||
"CWE-259": "High", # Hard-coded password
|
||||
"CWE-287": "High", # Improper authentication
|
||||
"CWE-306": "High", # Missing authentication for a critical function
|
||||
"CWE-321": "High", # Hard-coded cryptographic key
|
||||
"CWE-434": "High", # Unrestricted upload of dangerous file type
|
||||
"CWE-611": "High", # XML external entities
|
||||
"CWE-798": "High", # Hard-coded credentials
|
||||
"CWE-862": "High", # Missing authorization
|
||||
"CWE-863": "High", # Incorrect authorization
|
||||
"CWE-918": "High", # Server-side request forgery
|
||||
# Real weaknesses whose impact depends on context, or that need another flaw to be exploited.
|
||||
"CWE-15": "Medium", # External control of a configuration setting
|
||||
"CWE-20": "Medium", # Improper input validation
|
||||
"CWE-79": "Medium", # Cross-site scripting
|
||||
"CWE-200": "Medium", # Exposure of sensitive information
|
||||
"CWE-250": "Medium", # Execution with unnecessary privileges
|
||||
"CWE-269": "Medium", # Improper privilege management
|
||||
"CWE-295": "Medium", # Improper certificate validation
|
||||
"CWE-312": "Medium", # Cleartext storage of sensitive information
|
||||
"CWE-319": "Medium", # Cleartext transmission of sensitive information
|
||||
"CWE-326": "Medium", # Inadequate encryption strength
|
||||
"CWE-327": "Medium", # Broken or risky cryptographic algorithm
|
||||
"CWE-328": "Medium", # Weak hash
|
||||
"CWE-330": "Medium", # Insufficiently random values
|
||||
"CWE-338": "Medium", # Cryptographically weak PRNG
|
||||
"CWE-352": "Medium", # Cross-site request forgery
|
||||
"CWE-400": "Medium", # Uncontrolled resource consumption
|
||||
"CWE-522": "Medium", # Insufficiently protected credentials
|
||||
"CWE-532": "Medium", # Sensitive information in a log file
|
||||
"CWE-601": "Medium", # Open redirect
|
||||
"CWE-732": "Medium", # Incorrect permission assignment
|
||||
"CWE-770": "Medium", # Allocation of resources without limits
|
||||
"CWE-1333": "Medium", # Inefficient regular expression (ReDoS)
|
||||
# Hygiene: worth fixing, rarely exploitable on its own.
|
||||
"CWE-117": "Low", # Log injection
|
||||
"CWE-209": "Low", # Information in an error message
|
||||
"CWE-377": "Low", # Insecure temporary file
|
||||
"CWE-390": "Low", # Error detected without action
|
||||
"CWE-391": "Low", # Unchecked error condition
|
||||
"CWE-614": "Low", # Sensitive cookie without the Secure flag
|
||||
"CWE-703": "Low", # Improper handling of exceptional conditions
|
||||
"CWE-754": "Low", # Improper check for unusual conditions
|
||||
"CWE-755": "Low", # Improper handling of exceptional conditions
|
||||
"CWE-1004": "Low", # Sensitive cookie without the HttpOnly flag
|
||||
}
|
||||
|
||||
# For a finding whose CWE is not in the table, or that has none. One band below the old one-to-one
|
||||
# reading of the level, because a tool's "error" is not a statement that the finding is critical.
|
||||
SEVERITY_OF_LEVEL = {
|
||||
"error": "High",
|
||||
"warning": "Medium",
|
||||
"note": "Low",
|
||||
"none": "Low",
|
||||
"info": "Low",
|
||||
}
|
||||
|
||||
# The CVSS v3 qualitative bands, by their lower bounds. A score of 0 has no band of its own in the
|
||||
# report and reads as Low.
|
||||
SCORE_BANDS = ((9.0, "Critical"), (7.0, "High"), (4.0, "Medium"))
|
||||
|
||||
_CWE = re.compile(r"CWE-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def finding_severity(rule_id: str, level: str, security_severity: str = "") -> str:
|
||||
"""The severity a SARIF finding is reported under.
|
||||
|
||||
Args:
|
||||
rule_id: The finding's rule. The pipeline's tools name their rules by CWE.
|
||||
level: The SARIF level.
|
||||
security_severity: The finding's ``security-severity`` score, empty where it has none.
|
||||
|
||||
Returns:
|
||||
One of Critical, High, Medium or Low.
|
||||
"""
|
||||
scored = _score_band(security_severity)
|
||||
|
||||
if scored is not None:
|
||||
return scored
|
||||
|
||||
found = _CWE.search(rule_id)
|
||||
known = CWE_SEVERITY.get(f"CWE-{found.group(1)}") if found else None
|
||||
|
||||
return known or SEVERITY_OF_LEVEL.get(level, "Low")
|
||||
|
||||
|
||||
def _score_band(stated: str) -> str | None:
|
||||
"""The band a ``security-severity`` score falls in, or nothing where it is not a score."""
|
||||
try:
|
||||
score = float(stated)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not 0 <= score <= 10:
|
||||
return None
|
||||
|
||||
return next((name for bound, name in SCORE_BANDS if score >= bound), "Low")
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Turns a Markdown summary into the summary report.
|
||||
|
||||
The summary is written by hand, beside the scan's artifacts, and reads as a document of its own: a
|
||||
title, a few lines saying what it covers, then one section per source. This module sets it in the
|
||||
dress of the full report -- the same cover, running header and footers, table of contents, headings
|
||||
and tables -- so the two sit together as one series.
|
||||
|
||||
No design of its own. The Markdown is rendered to HTML whose elements are the ones ``report.css``
|
||||
already styles, by moving them onto the full report's heading levels and classes:
|
||||
|
||||
- The first level-one heading is the title. Written as ``<title>: <subject>``, the subject is the
|
||||
project, unless one is given.
|
||||
- What comes before the first section is the introduction, and goes on the cover.
|
||||
- Every further level-one or level-two heading is a section: it starts a new page, as the full
|
||||
report's sections do, is listed in the table of contents and keeps the anchor GitHub would give it, so the links a
|
||||
summary makes to its own sections go on working in the PDF.
|
||||
- A paragraph that is nothing but bold text is the subheading it is being used as.
|
||||
- Tables are drawn as the full report's grids. A number in a severity or CVSS column carries its
|
||||
severity colour, and a row that opens with a bold ``Total`` is set as a total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from markupsafe import Markup
|
||||
|
||||
from .context import UNKNOWN, _fact, _stated
|
||||
from .layout import render_pdf
|
||||
from .severity import _score_band
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
TEMPLATE_NAME = "summary.html.j2"
|
||||
|
||||
# What the document calls itself when the Markdown does not say.
|
||||
TITLE = "Security Scan Summary"
|
||||
|
||||
# The shape the summary template expects.
|
||||
CONTEXT = {
|
||||
"cover": "subject, footer, footer_date, logo, title, identity[], intro?",
|
||||
"contents": "[{id, title}]",
|
||||
"body": "the rendered sections",
|
||||
}
|
||||
|
||||
# What the file is called: after the scan where there is one, else after the project.
|
||||
NAME = "summary_{name}.pdf"
|
||||
|
||||
# A column whose numbers are CVSS scores, to be coloured by the band each falls in.
|
||||
_SCORED_COLUMN = re.compile(r"severity|cvss|score", re.IGNORECASE)
|
||||
|
||||
_SCORE = re.compile(r"\d+(?:\.\d+)?")
|
||||
|
||||
# Where Markdown's heading levels land on the full report's: a section is its ``h1``, and what is
|
||||
# under it one and two steps down.
|
||||
_LEVELS = {"h1": "h1", "h2": "h1", "h3": "h2", "h4": "h3", "h5": "h3", "h6": "h3"}
|
||||
|
||||
|
||||
def render_summary(
|
||||
markdown: str,
|
||||
*,
|
||||
scan_id: UUID | None = None,
|
||||
project: str | None = None,
|
||||
manufacturer: str | None = None,
|
||||
) -> tuple[str, bytes]:
|
||||
"""Render a Markdown summary as a PDF.
|
||||
|
||||
Args:
|
||||
markdown: The summary.
|
||||
scan_id: The scan it is about, if it is to say so.
|
||||
project: The project name; overrides the subject of the title.
|
||||
manufacturer: Who makes the scanned product.
|
||||
|
||||
Returns:
|
||||
The file name the summary should be saved under, and its bytes.
|
||||
"""
|
||||
context = build_summary_context(markdown, scan_id=scan_id, project=project, manufacturer=manufacturer)
|
||||
name = str(scan_id) if scan_id else _slug(context["cover"]["subject"]) or "report"
|
||||
|
||||
return NAME.format(name=name), render_pdf(context, TEMPLATE_NAME, context["cover"]["title"])
|
||||
|
||||
|
||||
def build_summary_context(
|
||||
markdown: str,
|
||||
*,
|
||||
scan_id: UUID | None = None,
|
||||
project: str | None = None,
|
||||
manufacturer: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Map a Markdown summary onto the page.
|
||||
|
||||
Args:
|
||||
markdown: The summary.
|
||||
scan_id: The scan it is about, if it is to say so.
|
||||
project: The project name; overrides the subject of the title.
|
||||
manufacturer: Who makes the scanned product.
|
||||
|
||||
Returns:
|
||||
The page-ready context; see :data:`CONTEXT`.
|
||||
"""
|
||||
parser = MarkdownIt("commonmark").enable(["table", "strikethrough"])
|
||||
tokens = parser.parse(markdown)
|
||||
|
||||
heading = _title(tokens)
|
||||
title, _, subject = heading.partition(":") if heading else (TITLE, "", "")
|
||||
project = project or subject.strip() or None
|
||||
|
||||
contents = _sections(tokens, parser)
|
||||
_subheadings(tokens)
|
||||
_tables(tokens)
|
||||
|
||||
start = next((index for index, token in enumerate(tokens) if token.meta.get("section")), len(tokens))
|
||||
intro, body = tokens[:start], tokens[start:]
|
||||
|
||||
render = parser.renderer.render
|
||||
identity = [
|
||||
_fact("Manufacturer", manufacturer),
|
||||
_fact("Project", project),
|
||||
_fact("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")),
|
||||
]
|
||||
if scan_id:
|
||||
identity.append(_fact("Scan Identifier", scan_id, kind="mono"))
|
||||
|
||||
return {
|
||||
"cover": {
|
||||
"subject": _stated(project),
|
||||
"footer": f"Scan: {scan_id}" if scan_id else _stated(project),
|
||||
"footer_date": datetime.now(UTC).strftime("%d %B %Y"),
|
||||
"logo": "cc_logo.png",
|
||||
"title": title.strip(),
|
||||
"identity": identity,
|
||||
"intro": Markup(render(intro, parser.options, {})) if intro else None, # noqa: S704
|
||||
},
|
||||
"contents": contents,
|
||||
"body": Markup(render(body, parser.options, {})), # noqa: S704
|
||||
}
|
||||
|
||||
|
||||
def _title(tokens: list[Token]) -> str | None:
|
||||
"""Take the first level-one heading out of the document, and say what it read."""
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type == "heading_open" and token.tag == "h1":
|
||||
text = tokens[index + 1].content
|
||||
del tokens[index : index + 3]
|
||||
|
||||
return text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _sections(tokens: list[Token], parser: MarkdownIt) -> list[dict[str, Any]]:
|
||||
"""Move every heading onto the full report's levels, and list the sections for the contents."""
|
||||
contents: list[dict[str, Any]] = []
|
||||
taken: set[str] = set()
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type not in ("heading_open", "heading_close"):
|
||||
continue
|
||||
|
||||
section = token.tag in ("h1", "h2")
|
||||
token.tag = _LEVELS[token.tag]
|
||||
if token.type == "heading_close" or not section:
|
||||
continue
|
||||
|
||||
inline = tokens[index + 1]
|
||||
anchor = _unique(_github_anchor(inline.content), taken)
|
||||
token.attrSet("id", anchor)
|
||||
token.meta["section"] = True
|
||||
# As in the full report, every section opens a page of its own.
|
||||
token.attrSet("style", "break-before: page")
|
||||
title = Markup(parser.renderer.render(inline.children or [], parser.options, {})) # noqa: S704
|
||||
contents.append({"id": anchor, "title": title})
|
||||
|
||||
return contents
|
||||
|
||||
|
||||
def _subheadings(tokens: list[Token]) -> None:
|
||||
"""Set a paragraph that is nothing but bold text as the subheading it stands in for."""
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type != "paragraph_open" or token.hidden:
|
||||
continue
|
||||
|
||||
children = tokens[index + 1].children or []
|
||||
kinds = [child.type for child in children]
|
||||
if kinds == ["strong_open", "text", "strong_close"]:
|
||||
token.tag = tokens[index + 2].tag = "h2"
|
||||
tokens[index + 1].children = [children[1]]
|
||||
|
||||
|
||||
def _tables(tokens: list[Token]) -> None:
|
||||
"""Draw tables as the full report's grids: scores coloured, totals set as totals."""
|
||||
columns: list[str] = []
|
||||
column = 0
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type == "table_open":
|
||||
token.attrSet("class", "grid")
|
||||
columns = []
|
||||
elif token.type == "th_open":
|
||||
columns.append(tokens[index + 1].content)
|
||||
elif token.type == "tr_open":
|
||||
column = 0
|
||||
cell = tokens[index + 2] if tokens[index + 1].type == "td_open" else None
|
||||
if cell is not None and cell.content.strip() == "**Total**":
|
||||
token.attrSet("class", "total")
|
||||
elif token.type == "td_open":
|
||||
header = columns[column] if column < len(columns) else ""
|
||||
content = tokens[index + 1].content.strip()
|
||||
band = _score_band(content) if _SCORED_COLUMN.search(header) and _SCORE.fullmatch(content) else None
|
||||
if band:
|
||||
token.attrSet("class", f"sev sev-{band.lower()}")
|
||||
column += 1
|
||||
|
||||
|
||||
def _github_anchor(text: str) -> str:
|
||||
"""The anchor GitHub gives a heading, so the links a summary makes to itself still land."""
|
||||
return re.sub(r"[^\w\- ]", "", text.strip().lower()).replace(" ", "-")
|
||||
|
||||
|
||||
def _unique(anchor: str, taken: set[str]) -> str:
|
||||
"""The anchor, numbered as GitHub numbers a second heading of the same name."""
|
||||
candidate, number = anchor or "section", 0
|
||||
while candidate in taken:
|
||||
number += 1
|
||||
candidate = f"{anchor}-{number}"
|
||||
taken.add(candidate)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
"""A name fit for a file."""
|
||||
return "" if text == UNKNOWN else re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
@@ -0,0 +1,92 @@
|
||||
{#
|
||||
The pieces the report templates share: `report.html.j2`, the full report, and `summary.html.j2`,
|
||||
the summary set from Markdown. Kept apart so that the two documents draw a label, a value or a
|
||||
table the same way, and a change to one is a change to both.
|
||||
#}
|
||||
{# A value is drawn by what it is, not by where it sits: an identifier, a path or a CVSS vector is
|
||||
set in the monospace face wherever it appears, and a severity carries its colour. #}
|
||||
{% macro value(row) %}
|
||||
{% if row.token %}<span class="token">{{ row.token }}</span> — {% endif %}
|
||||
{% if row.kind == "severity" %}
|
||||
<span class="sev sev-{{ row.value|lower }}">{{ row.value }}</span>
|
||||
{% elif row.kind == "mono" %}
|
||||
<span class="mono">{{ row.value }}</span>
|
||||
{% else %}
|
||||
{{ row.value }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro facts(rows, wide=false) %}
|
||||
<table class="facts{% if wide %} wide{% endif %}">
|
||||
{% for row in rows %}
|
||||
<tr{% if row.gap %} class="gap"{% endif %}>
|
||||
<td>{{ row.label }}:</td>
|
||||
<td>{{ value(row) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card(title, rows, note="") %}
|
||||
{# Wrapped, because break-inside on the table does not hold its caption: the title would stay on
|
||||
one page while the rows it titles moved to the next. #}
|
||||
<div class="block">
|
||||
<table class="card">
|
||||
<caption>{{ title }}</caption>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.aside %} class="aside"{% endif %}>
|
||||
<th>{{ row.label }}</th>
|
||||
<td>{{ value(row) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if note %}<p class="note card-note">{{ note }}</p>{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Scaled against the busiest row, not against the total: what is read off a row of these is which
|
||||
one is the outlier, and a scale set by the sum flattens all of them. #}
|
||||
{% macro distribution(rows, legend) %}
|
||||
{# The bars and the legend that decodes them are one thing; a break between them leaves a reader
|
||||
with coloured segments and no key. #}
|
||||
<div class="block">
|
||||
<table class="bars">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td{% if row.mono %} class="mono"{% endif %}>{{ row.label }}</td>
|
||||
<td class="count">{{ row.count }}</td>
|
||||
<td class="track">
|
||||
<div class="bar">
|
||||
{% for part in row.parts %}
|
||||
<span class="seg-{{ part.severity|lower }}" style="width: {{ part.share }}%"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="legend">
|
||||
{% for item in legend.entries %}
|
||||
<span class="item"><span class="key seg-{{ item.severity|lower }}"></span>{{ item.label }}</span>
|
||||
{% endfor %}
|
||||
{% if legend.caveat %}<span class="caveat">— {{ legend.caveat }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro grid(table) %}
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>{% for column in table.columns %}<th>{{ column }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in table.rows %}
|
||||
<tr{% if row.total %} class="total"{% endif %}>
|
||||
{% for cell in row.cells %}
|
||||
<td>{% if cell is mapping %}{{ value(cell) }}{% else %}{{ cell }}{% endif %}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -62,6 +62,13 @@
|
||||
size: A4;
|
||||
margin: 22mm 18mm 18mm 18mm;
|
||||
|
||||
@top-center {
|
||||
content: element(page-header);
|
||||
width: 100%;
|
||||
vertical-align: bottom;
|
||||
padding-bottom: 4mm;
|
||||
}
|
||||
|
||||
@bottom-left {
|
||||
content: string(scan-footer);
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
@@ -101,6 +108,26 @@ code,
|
||||
font-size: 0.93em;
|
||||
}
|
||||
|
||||
/* The cover carries the mark large already; a second, small one above it would only repeat it. */
|
||||
@page :first {
|
||||
@top-center {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The mark -- the logo and the company name, which the image carries together -- above every page,
|
||||
ruled off from the content. */
|
||||
.page-header {
|
||||
position: running(page-header);
|
||||
padding-bottom: 1.6mm;
|
||||
border-bottom: 0.4pt solid var(--border);
|
||||
}
|
||||
|
||||
.page-header img {
|
||||
height: 5mm;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Carried into the running footer on every page. Set once, on the cover. */
|
||||
.footer-scan {
|
||||
string-set: scan-footer content();
|
||||
@@ -420,3 +447,93 @@ p.note {
|
||||
.legend .caveat {
|
||||
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);
|
||||
}
|
||||
|
||||
/* --- Prose (the summary report) ----------------------------------------------------------- */
|
||||
|
||||
/* The summary is written in Markdown, so it brings what the full report never has: running text,
|
||||
lists and links. Set to the full report's measure and rhythm, so the two read as one series. */
|
||||
.prose p,
|
||||
.prose ul,
|
||||
.prose ol,
|
||||
.prose blockquote {
|
||||
max-width: 165mm;
|
||||
}
|
||||
|
||||
/* Every section of the summary opens a page, so the space a heading keeps above it would only push
|
||||
the page down. */
|
||||
.prose h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin: 0 0 3mm 0;
|
||||
padding-left: 5mm;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 1.4mm;
|
||||
}
|
||||
|
||||
.prose li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 0 0 3mm 0;
|
||||
padding-left: 3mm;
|
||||
border-left: 0.8pt solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--label-fill);
|
||||
border: 0.4pt solid var(--border);
|
||||
padding: 2mm;
|
||||
white-space: pre-wrap;
|
||||
margin: 0 0 3mm 0;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 0.4pt solid var(--border);
|
||||
margin: 5mm 0;
|
||||
}
|
||||
|
||||
/* The severity colour, where it marks a score in a table, is on the cell itself. */
|
||||
.grid td.sev {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cover-intro {
|
||||
margin-top: 9mm;
|
||||
}
|
||||
@@ -8,96 +8,11 @@
|
||||
The context this expects is described by `layout.CONTEXT`, and `sample.sample_context()` builds a
|
||||
complete one.
|
||||
#}
|
||||
{# A value is drawn by what it is, not by where it sits: an identifier, a path or a CVSS vector is
|
||||
set in the monospace face wherever it appears, and a severity carries its colour. #}
|
||||
{% macro value(row) %}
|
||||
{% if row.token %}<span class="token">{{ row.token }}</span> — {% endif %}
|
||||
{% if row.kind == "severity" %}
|
||||
<span class="sev sev-{{ row.value|lower }}">{{ row.value }}</span>
|
||||
{% elif row.kind == "mono" %}
|
||||
<span class="mono">{{ row.value }}</span>
|
||||
{% else %}
|
||||
{{ row.value }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro facts(rows, wide=false) %}
|
||||
<table class="facts{% if wide %} wide{% endif %}">
|
||||
{% for row in rows %}
|
||||
<tr{% if row.gap %} class="gap"{% endif %}>
|
||||
<td>{{ row.label }}:</td>
|
||||
<td>{{ value(row) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro card(title, rows, note="") %}
|
||||
{# Wrapped, because break-inside on the table does not hold its caption: the title would stay on
|
||||
one page while the rows it titles moved to the next. #}
|
||||
<div class="block">
|
||||
<table class="card">
|
||||
<caption>{{ title }}</caption>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.aside %} class="aside"{% endif %}>
|
||||
<th>{{ row.label }}</th>
|
||||
<td>{{ value(row) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if note %}<p class="note card-note">{{ note }}</p>{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Scaled against the busiest row, not against the total: what is read off a row of these is which
|
||||
one is the outlier, and a scale set by the sum flattens all of them. #}
|
||||
{% macro distribution(rows, legend) %}
|
||||
{# The bars and the legend that decodes them are one thing; a break between them leaves a reader
|
||||
with coloured segments and no key. #}
|
||||
<div class="block">
|
||||
<table class="bars">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td{% if row.mono %} class="mono"{% endif %}>{{ row.label }}</td>
|
||||
<td class="count">{{ row.count }}</td>
|
||||
<td class="track">
|
||||
<div class="bar">
|
||||
{% for part in row.parts %}
|
||||
<span class="seg-{{ part.severity|lower }}" style="width: {{ part.share }}%"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="legend">
|
||||
{% for item in legend.entries %}
|
||||
<span class="item"><span class="key seg-{{ item.severity|lower }}"></span>{{ item.label }}</span>
|
||||
{% endfor %}
|
||||
{% if legend.caveat %}<span class="caveat">— {{ legend.caveat }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro grid(table) %}
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>{% for column in table.columns %}<th>{{ column }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in table.rows %}
|
||||
<tr{% if row.total %} class="total"{% endif %}>
|
||||
{% for cell in row.cells %}
|
||||
<td>{% if cell is mapping %}{{ value(cell) }}{% else %}{{ cell }}{% endif %}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
{% from "macros.html.j2" import value, facts, card, distribution, grid %}
|
||||
|
||||
{# ------------------------------------------------------------------------------------------ #}
|
||||
|
||||
<div class="page-header"><img src="{{ cover.logo }}" alt="CipherCell" /></div>
|
||||
<span class="footer-scan">Scan: {{ cover.scan_id }}</span>
|
||||
<span class="footer-date">{{ cover.footer_date }}</span>
|
||||
|
||||
@@ -121,19 +36,17 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if scan %}
|
||||
<h1 style="break-before: page">{{ scan.title }}</h1>
|
||||
{{ facts(scan.overview, wide=true) }}
|
||||
|
||||
{% for stage in scan.stages %}
|
||||
<h3>{{ stage.title }}</h3>
|
||||
{{ facts(stage.rows, wide=true) }}
|
||||
{% if stage.table %}{{ grid(stage.table) }}{% 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 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) }}
|
||||
|
||||
{% if project.languages %}
|
||||
@@ -150,7 +63,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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) }}
|
||||
|
||||
{% for block in summary.distributions %}
|
||||
@@ -164,12 +77,12 @@
|
||||
{% endif %}
|
||||
|
||||
{% if critical %}
|
||||
<h2 style="break-before: page">{{ critical.title }}</h2>
|
||||
<h2 id="{{ critical.id }}" style="break-before: page">{{ critical.title }}</h2>
|
||||
{{ grid(critical.table) }}
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
<h2>{{ group.title }}</h2>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
The summary report, set from a Markdown file in the dress of the full report: the same cover,
|
||||
running header and footers, table of contents and headings, with the body in between.
|
||||
|
||||
The body arrives already rendered -- :mod:`summary` turns the Markdown into HTML whose elements
|
||||
are the ones `report.css` already styles -- so this file only places it. The context this expects
|
||||
is described by `summary.CONTEXT`.
|
||||
#}
|
||||
{% from "macros.html.j2" import facts %}
|
||||
|
||||
<div class="page-header"><img src="{{ cover.logo }}" alt="CipherCell" /></div>
|
||||
<span class="footer-scan">{{ cover.footer }}</span>
|
||||
<span class="footer-date">{{ cover.footer_date }}</span>
|
||||
|
||||
<div class="cover-mark">
|
||||
<img src="{{ cover.logo }}" alt="CipherCell" />
|
||||
</div>
|
||||
|
||||
<div class="cover-title">{{ cover.title }}</div>
|
||||
|
||||
{{ facts(cover.identity) }}
|
||||
|
||||
{% if cover.intro %}
|
||||
<div class="prose cover-intro">{{ cover.intro }}</div>
|
||||
{% 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 %}
|
||||
|
||||
<div class="prose">{{ body }}</div>
|
||||
Reference in new issue
Block a user