Initial commit. monorepo deps are copied in a dirty way to the vendor folder

This commit is contained in:
Florian Frank committed 2026-09-18 16:58:29 +02:00
commit 06593ac488
53 files changed
+7692

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Top-level package for the CipherVerifier Django project."""
@@ -0,0 +1 @@
"""Core Django app for CipherVerifier."""
@@ -0,0 +1 @@
"""Service layer for pipeline orchestration."""
@@ -0,0 +1,677 @@
"""Summarising of SARIF reports for presentation in the UI.
The functions in here are deliberately free of any storage or Django dependency: they take an
already parsed SARIF document and turn it into a plain summary that a template can render.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
TOP_LIST_SIZE = 8
"""How many entries of a top list are shown before the "See all" button has to be used."""
LEVEL_ORDER = ["error", "warning", "note", "none"]
# Kept in sync with the --scan-chart-color-* palette in tailwind/input.css so the charts on the
# scan page look like the ones on the dashboard.
LEVEL_COLORS = {
"error": "oklch(0.5 0.199 28.536)",
"warning": "oklch(0.747 0.155 103.227)",
"note": "oklch(0.451 0.251 265.29)",
"none": "oklch(0.708 0 0)",
}
# The verdicts polygraph can hand down, with the wording the UI uses for them.
POLYGRAPH_VERDICT_LABELS = {
"false_positive": "Likely false positive",
"true_positive": "Likely true positive",
"uncertain": "Uncertain",
}
@dataclass
class Region:
"""A span within a file. Carries no path of its own - the location around it holds that."""
start_line: int | None
end_line: int | None = None
start_column: int | None = None
end_column: int | None = None
@property
def display(self) -> str:
"""The span as ``3``, ``3-5`` or ``3:13-26``, empty when it does not name a line."""
if self.start_line is None:
return ""
# A span across lines reads as a line range; one within a single line adds its columns.
if self.end_line and self.end_line != self.start_line:
return f"{self.start_line}-{self.end_line}"
if self.start_column is None:
return str(self.start_line)
columns = str(self.start_column)
if self.end_column and self.end_column != self.start_column:
columns = f"{columns}-{self.end_column}"
return f"{self.start_line}:{columns}"
@dataclass
class Occurrence:
"""A single tool hit that was merged into a finding by the sarifcombiner."""
found_by: str
tool_identifier: str
message: str
start_line: int | None
end_line: int | None = None
start_column: int | None = None
end_column: int | None = None
related_positions: list[Region] = field(default_factory=list)
@property
def region(self) -> Region:
"""The span this tool reported the finding at."""
return Region(self.start_line, self.end_line, self.start_column, self.end_column)
@dataclass
class Location:
"""A source location a finding points at."""
file: str
start_line: int | None
end_line: int | None
start_column: int | None = None
end_column: int | None = None
@property
def region(self) -> Region:
"""The span within the file."""
return Region(self.start_line, self.end_line, self.start_column, self.end_column)
@property
def display(self) -> str:
"""The location as ``app/views.py:3:13-26``, degrading to just the path without a line."""
region = self.region.display
return f"{self.file}:{region}" if region else self.file
@dataclass
class Suppression:
"""A reason the finding was suppressed. Polygraph adds one for every false positive it finds."""
kind: str
justification: str
@dataclass
class Finding:
"""A single SARIF result, flattened into the fields the template needs."""
rule_id: str
rule_name: str
rule_description: str
level: str
message: str
location: Location | None
extra_locations: list[Location]
tools: list[str]
occurrences: list[Occurrence]
rank: int | None
polygraph_verdict: str | None = None
polygraph_reasoning: str | None = None
polygraph_certainty: str = ""
polygraph_priority: str = ""
polygraph_prompt_hash: str = ""
rule_full_description: str = ""
rule_help: str = ""
rule_help_uri: str = ""
rule_tags: list[str] = field(default_factory=list)
rule_precision: str = ""
rule_security_severity: str = ""
rule_default_level: str = ""
suppressions: list[Suppression] = field(default_factory=list)
@property
def tool_count(self) -> int:
"""Number of distinct tools that reported this finding."""
return len(self.tools)
@property
def downgraded(self) -> bool:
"""Whether this result is reported below the severity its rule defaults to.
Tools do this per hit, so the gap is worth showing: a rule that defaults to ``error`` and
lands as a ``note`` says the tool itself was unsure about this particular match. The
reverse (a hit reported *above* the rule default) is not flagged here.
"""
if self.rule_default_level not in LEVEL_ORDER:
return False
return LEVEL_ORDER.index(self.level) > LEVEL_ORDER.index(self.rule_default_level)
@property
def false_positive(self) -> bool:
"""Whether polygraph classified this finding as a likely false positive."""
return self.polygraph_verdict == "false_positive"
@property
def verdict_label(self) -> str:
"""The polygraph verdict in words, empty when the finding carries no known verdict."""
return POLYGRAPH_VERDICT_LABELS.get(self.polygraph_verdict or "", "")
@dataclass
class Stat:
"""A labelled count with its share of the largest count in the same list, in percent."""
label: str
sublabel: str
count: int
percent: int
@dataclass
class SarifReport:
"""Everything the scan page shows about one SARIF report."""
total: int
findings: list[Finding]
level_counts: list[Stat]
tool_counts: list[Stat]
top_rules: list[Stat]
top_files: list[Stat]
file_count: int
rule_count: int
multi_tool_count: int
false_positive_count: int
ranked: bool
chart_data: dict[str, Any] = field(default_factory=dict)
@property
def levels(self) -> dict[str, int]:
"""Finding count per severity level, including the levels without any finding."""
counts = dict.fromkeys(LEVEL_ORDER, 0)
for stat in self.level_counts:
counts[stat.label] = stat.count
return counts
@property
def top_list_size(self) -> int:
"""How many entries of ``top_rules`` and ``top_files`` the page shows without unfolding."""
return TOP_LIST_SIZE
def _rules_by_id(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Collect the rule definitions of every run, keyed by rule id."""
rules: dict[str, dict[str, Any]] = {}
for run in runs:
for rule in run.get("tool", {}).get("driver", {}).get("rules", []):
if isinstance(rule, dict) and "id" in rule:
rules.setdefault(rule["id"], rule)
return rules
def _driver_name(run: dict[str, Any]) -> str:
"""Return the display name of the tool that produced a run."""
driver = run.get("tool", {}).get("driver", {})
return str(driver.get("name") or driver.get("fullName") or "unknown")
def _level_of(result: dict[str, Any], rule: dict[str, Any]) -> str:
"""Return the severity of a result.
Some tools (semgrep for instance) omit ``level`` on the result and only declare it on the rule,
so the rule's default configuration is used as a fallback.
Args:
result: A SARIF result.
rule: The rule definition belonging to the result, or an empty dict.
Returns:
One of the levels in ``LEVEL_ORDER``.
"""
level = result.get("level") or rule.get("defaultConfiguration", {}).get("level")
return level if level in LEVEL_ORDER else "none"
def _region_of(region: dict[str, Any]) -> Region:
"""Read a SARIF region, or a sarifcombiner position - both spell the keys the same way."""
return Region(
start_line=region.get("startLine"),
end_line=region.get("endLine"),
start_column=region.get("startColumn"),
end_column=region.get("endColumn"),
)
def _locations_of(result: dict[str, Any]) -> list[Location]:
"""Extract all physical locations of a result."""
locations = []
for location in result.get("locations", []):
physical = location.get("physicalLocation", {})
uri = physical.get("artifactLocation", {}).get("uri")
if not uri:
continue
region = _region_of(physical.get("region", {}))
locations.append(
Location(
file=uri,
start_line=region.start_line,
end_line=region.end_line,
start_column=region.start_column,
end_column=region.end_column,
)
)
return locations
def _occurrences_of(result: dict[str, Any]) -> list[Occurrence]:
"""Extract the per-tool occurrences the sarifcombiner merged into a result."""
occurrences = []
for occurrence in result.get("properties", {}).get("sarifgrouper", {}).get("occurrences", []):
found_by = occurrence.get("foundBy")
if not found_by:
continue
position = _region_of(occurrence.get("position", {}) or {})
related = [_region_of(related) for related in occurrence.get("relatedPositions", []) or []]
occurrences.append(
Occurrence(
found_by=found_by,
tool_identifier=occurrence.get("toolIdentifier", ""),
message=occurrence.get("message", ""),
start_line=position.start_line,
end_line=position.end_line,
start_column=position.start_column,
end_column=position.end_column,
related_positions=[region for region in related if region.start_line is not None],
)
)
return occurrences
def _suppressions_of(result: dict[str, Any]) -> list[Suppression]:
"""Extract the suppressions of a result. Polygraph adds one for each false positive it finds."""
suppressions = []
for suppression in result.get("suppressions", []) or []:
if not isinstance(suppression, dict):
continue
suppressions.append(
Suppression(
kind=str(suppression.get("kind") or ""),
justification=str(suppression.get("justification") or ""),
)
)
return suppressions
def _rank_of(result: dict[str, Any]) -> int | None:
"""Return the prism rank of a result, if the report was ranked."""
rank = result.get("properties", {}).get("prism", {}).get("rank")
if isinstance(rank, bool) or rank is None:
return None
if isinstance(rank, int | float):
return int(rank)
if isinstance(rank, str) and rank.isdigit():
return int(rank)
return None
POLYGRAPH_VERDICTS = set(POLYGRAPH_VERDICT_LABELS)
EMPTY_POLYGRAPH: dict[str, Any] = {"verdict": None, "reasoning": None, "certainty": "", "priority": "", "hash": ""}
def _polygraph_of(result: dict[str, Any]) -> dict[str, Any]:
"""Return what polygraph concluded about a result, if it was classified.
Polygraph annotates each classified SARIF result with a ``polygraph`` property block. Findings
that were not classified (polygraph disabled, below the severity threshold, or errored) carry no
such block, so an empty verdict is returned and the finding reads as "not classified".
Args:
result: A SARIF result.
Returns:
A dict with the ``verdict`` (one of ``POLYGRAPH_VERDICTS`` or ``None``), the ``reasoning``,
and the ``certainty``, ``priority`` and prompt ``hash`` of the classification. Certainty and
priority are lowercased, so the templates can capitalise them like they do severities.
"""
polygraph = result.get("properties", {}).get("polygraph")
if not isinstance(polygraph, dict):
return dict(EMPTY_POLYGRAPH)
verdict = str(polygraph.get("verdict", "")).lower()
if verdict not in POLYGRAPH_VERDICTS:
# Tolerate an older shape that only carries the boolean flag.
verdict = "false_positive" if polygraph.get("isFalsePositive") else ""
reasoning = polygraph.get("reasoning")
return {
"verdict": verdict or None,
"reasoning": str(reasoning) if reasoning else None,
"certainty": str(polygraph.get("certainty") or "").lower(),
"priority": str(polygraph.get("priority") or "").lower(),
"hash": str(polygraph.get("promptHash") or ""),
}
def _rule_metadata(rule: dict[str, Any]) -> dict[str, Any]:
"""Pull everything a rule definition says about itself.
Which of these a rule carries is entirely up to the tool that declared it: the sarifcombiner
keeps descriptions and tags, while raw tool reports (rayfusion's, for instance) also come with
a ``helpUri`` and a precision. Anything absent comes back empty and the template leaves it out.
Args:
rule: A SARIF rule definition, or an empty dict when the rule was not declared.
Returns:
The rule's descriptions, help text and link, tags, precision, security severity and the
severity it defaults to.
"""
properties = rule.get("properties") or {}
tags = properties.get("tags")
security_severity = properties.get("security-severity", properties.get("securitySeverity"))
return {
"short_description": rule.get("shortDescription", {}).get("text") or "",
"full_description": rule.get("fullDescription", {}).get("text") or "",
"help": (rule.get("help") or {}).get("text") or "",
"help_uri": str(rule.get("helpUri") or ""),
"tags": [str(tag) for tag in tags] if isinstance(tags, list) else [],
"precision": str(properties.get("precision") or ""),
"security_severity": str(security_severity) if security_severity is not None else "",
"default_level": str(rule.get("defaultConfiguration", {}).get("level") or ""),
}
def _build_finding(result: dict[str, Any], run: dict[str, Any], rules: dict[str, dict[str, Any]]) -> Finding:
"""Flatten a single SARIF result into a ``Finding``.
Args:
result: The SARIF result to flatten.
run: The run the result belongs to, used as fallback for the tool name.
rules: All rule definitions of the report, keyed by rule id.
Returns:
The flattened finding.
"""
rule_id = str(result.get("ruleId", ""))
rule = rules.get(rule_id, {})
meta = _rule_metadata(rule)
occurrences = _occurrences_of(result)
locations = _locations_of(result)
polygraph = _polygraph_of(result)
# The full description repeats the short one when a tool declares only one of the two, so it is
# dropped rather than shown twice underneath it.
description = meta["short_description"] or meta["full_description"]
full_description = meta["full_description"] if meta["full_description"] != description else ""
return Finding(
rule_id=rule_id,
rule_name=rule.get("name", ""),
rule_description=description,
level=_level_of(result, rule),
message=result.get("message", {}).get("text", ""),
location=locations[0] if locations else None,
extra_locations=locations[1:],
tools=sorted({occurrence.found_by for occurrence in occurrences}) or [_driver_name(run)],
occurrences=occurrences,
rank=_rank_of(result),
polygraph_verdict=polygraph["verdict"],
polygraph_reasoning=polygraph["reasoning"],
polygraph_certainty=polygraph["certainty"],
polygraph_priority=polygraph["priority"],
polygraph_prompt_hash=polygraph["hash"],
rule_full_description=full_description,
rule_help=meta["help"] if meta["help"] not in (description, full_description) else "",
rule_help_uri=meta["help_uri"],
rule_tags=meta["tags"],
rule_precision=meta["precision"],
rule_security_severity=meta["security_severity"],
rule_default_level=meta["default_level"],
suppressions=_suppressions_of(result),
)
def _sort_findings(findings: list[Finding], ranked: bool) -> list[Finding]:
"""Sort findings, keeping the ones polygraph flagged as false positives at the bottom.
The likely false positives sink below every real, uncertain or unclassified finding; within
each of those two groups the usual order is kept - by rank when the report is ranked, by
severity and location otherwise.
"""
if ranked:
return sorted(findings, key=lambda f: (f.false_positive, f.rank is None, f.rank or 0))
return sorted(
findings,
key=lambda f: (
f.false_positive,
LEVEL_ORDER.index(f.level),
f.location.file if f.location else "",
f.location.start_line or 0 if f.location else 0,
),
)
def _to_stats(counts: list[tuple[str, int]], sublabels: dict[str, str] | None = None) -> list[Stat]:
"""Turn ``(label, count)`` pairs into ``Stat`` entries with a percentage of the largest count."""
if not counts:
return []
largest = max(count for _, count in counts)
return [
Stat(
label=label,
sublabel=(sublabels or {}).get(label, ""),
count=count,
percent=round(count / largest * 100) if largest else 0,
)
for label, count in counts
]
def _level_stats(findings: list[Finding]) -> list[Stat]:
"""Count findings per severity level, in severity order and without empty buckets."""
counter = Counter(finding.level for finding in findings)
return _to_stats([(level, counter[level]) for level in LEVEL_ORDER if counter[level]])
def _tool_stats(findings: list[Finding]) -> list[Stat]:
"""Count findings per reporting tool, most findings first."""
counter: Counter[str] = Counter()
for finding in findings:
counter.update(finding.tools)
return _to_stats(counter.most_common())
def _rule_stats(findings: list[Finding]) -> list[Stat]:
"""Rank every triggered rule by how often it was triggered, most triggered first.
The scan page renders the whole list: it shows the first ``TOP_LIST_SIZE`` entries and reveals
the rest behind a "See all" button.
"""
counter = Counter(finding.rule_id for finding in findings if finding.rule_id)
names = {finding.rule_id: finding.rule_name for finding in findings}
return _to_stats(counter.most_common(), sublabels=names)
def _file_stats(findings: list[Finding]) -> list[Stat]:
"""Rank every affected file by its number of findings, most findings first."""
counter = Counter(finding.location.file for finding in findings if finding.location)
return _to_stats(counter.most_common())
def _chart_data(level_counts: list[Stat], tool_counts: list[Stat]) -> dict[str, Any]:
"""Build the data blob the Chart.js charts on the scan page are fed with."""
return {
"severity": {
"labels": [stat.label.capitalize() for stat in level_counts],
"values": [stat.count for stat in level_counts],
"colors": [LEVEL_COLORS[stat.label] for stat in level_counts],
},
"tools": {
"labels": [stat.label for stat in tool_counts],
"values": [stat.count for stat in tool_counts],
},
}
def _build_report(findings: list[Finding], ranked: bool) -> SarifReport:
"""Sort a list of findings and derive every statistic the templates render from it."""
findings = _sort_findings(findings, ranked)
level_counts = _level_stats(findings)
tool_counts = _tool_stats(findings)
return SarifReport(
total=len(findings),
findings=findings,
level_counts=level_counts,
tool_counts=tool_counts,
top_rules=_rule_stats(findings),
top_files=_file_stats(findings),
file_count=len({finding.location.file for finding in findings if finding.location}),
rule_count=len({finding.rule_id for finding in findings if finding.rule_id}),
multi_tool_count=sum(1 for finding in findings if finding.tool_count > 1),
false_positive_count=sum(1 for finding in findings if finding.false_positive),
ranked=ranked,
chart_data=_chart_data(level_counts, tool_counts),
)
def summarize_sarif(sarif_data: dict[str, Any]) -> SarifReport:
"""Summarise a parsed SARIF document for rendering on the scan page.
All runs of the document are merged, so reports that keep one run per tool are summarised just
as well as the combined report the pipeline usually produces. Every finding of the report is
included; the scan page renders the complete list.
Args:
sarif_data: A parsed SARIF document.
Returns:
The summarised report.
"""
runs = [run for run in sarif_data.get("runs", []) if isinstance(run, dict)]
rules = _rules_by_id(runs)
findings = [_build_finding(result, run, rules) for run in runs for result in run.get("results", [])]
return _build_report(findings, ranked=any(finding.rank is not None for finding in findings))
@dataclass
class FindingsSummary:
"""The numbers of one or more SARIF reports, in a form that can be stored and added up.
The listing pages only show counts, never individual findings, so they work off this instead of
the full report: it is small enough to cache per scan and can be merged across repositories
without having the findings themselves at hand.
"""
total: int = 0
file_count: int = 0
multi_tool_count: int = 0
false_positive_count: int = 0
level_totals: dict[str, int] = field(default_factory=dict)
rule_totals: dict[str, int] = field(default_factory=dict)
rule_names: dict[str, str] = field(default_factory=dict)
@property
def levels(self) -> dict[str, int]:
"""Finding count per severity level, including the levels without any finding."""
counts = dict.fromkeys(LEVEL_ORDER, 0)
for level, count in self.level_totals.items():
if level in counts:
counts[level] = count
return counts
@property
def level_counts(self) -> list[Stat]:
"""Findings per severity level, in severity order and without empty buckets."""
levels = self.levels
return _to_stats([(level, levels[level]) for level in LEVEL_ORDER if levels[level]])
@property
def rule_count(self) -> int:
"""Number of distinct rules that were triggered."""
return len(self.rule_totals)
@property
def top_rules(self) -> list[Stat]:
"""The most frequently triggered rules."""
return _to_stats(Counter(self.rule_totals).most_common(TOP_LIST_SIZE), sublabels=self.rule_names)
@classmethod
def of(cls, report: SarifReport) -> FindingsSummary:
"""Reduce a full report to the numbers the listing pages need."""
return cls(
total=report.total,
file_count=report.file_count,
multi_tool_count=report.multi_tool_count,
false_positive_count=report.false_positive_count,
level_totals=report.levels,
rule_totals=dict(Counter(finding.rule_id for finding in report.findings if finding.rule_id)),
rule_names={
finding.rule_id: finding.rule_name
for finding in report.findings
if finding.rule_id and finding.rule_name
},
)
def to_dict(self) -> dict[str, Any]:
"""Serialise the summary so it can be stored as JSON."""
return {
"total": self.total,
"file_count": self.file_count,
"multi_tool_count": self.multi_tool_count,
"false_positive_count": self.false_positive_count,
"level_totals": self.level_totals,
"rule_totals": self.rule_totals,
"rule_names": self.rule_names,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FindingsSummary:
"""Rebuild a summary from stored JSON, tolerating entries that miss a key."""
return cls(
total=int(data.get("total", 0)),
file_count=int(data.get("file_count", 0)),
multi_tool_count=int(data.get("multi_tool_count", 0)),
false_positive_count=int(data.get("false_positive_count", 0)),
level_totals=dict(data.get("level_totals") or {}),
rule_totals=dict(data.get("rule_totals") or {}),
rule_names=dict(data.get("rule_names") or {}),
)
def merge_summaries(summaries: list[FindingsSummary]) -> FindingsSummary:
"""Add up the summaries of several scans.
Files are counted per scan, so the same path in two repositories counts twice - which is what
the listing pages want, as those are two different files.
Args:
summaries: The summaries to add up.
Returns:
One summary covering all of them.
"""
levels: Counter[str] = Counter()
rules: Counter[str] = Counter()
names: dict[str, str] = {}
for summary in summaries:
levels.update(summary.level_totals)
rules.update(summary.rule_totals)
names.update(summary.rule_names)
return FindingsSummary(
total=sum(summary.total for summary in summaries),
file_count=sum(summary.file_count for summary in summaries),
multi_tool_count=sum(summary.multi_tool_count for summary in summaries),
false_positive_count=sum(summary.false_positive_count for summary in summaries),
level_totals=dict(levels),
rule_totals=dict(rules),
rule_names=names,
)
@@ -0,0 +1,813 @@
"""Summarising of vulnerability reports for presentation in the UI.
Two shapes end up in the ``vdr.json`` artifact of a scan: the Finding Packaging Format that
Dependency-Track's findings export produces, and a plain CycloneDX VDR document. Both are accepted
here and reduced to the same summary.
The functions in here are deliberately free of any storage or Django dependency: they take an
already parsed document and turn it into a plain summary that a template can render.
"""
from __future__ import annotations
import json
import re
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from cipherverifier.core.services.sarif import TOP_LIST_SIZE, Stat
from persistent_storage.utils.cvss_utils import IMPACT_KEYS, describe, parse_vector
# CycloneDX severity values, most severe first. ``unknown`` covers reports that carry no rating.
SEVERITY_ORDER = ["critical", "high", "medium", "low", "info", "unknown"]
# Higher rank means more severe, so the highest rated severity of a vulnerability wins.
SEVERITY_RANK: dict[str, int] = {
"critical": 5,
"high": 4,
"medium": 3,
"low": 2,
"info": 1,
"unknown": 0,
}
# Kept as OKLCH so the scan page charts match the style of the SARIF charts.
SEVERITY_COLORS: dict[str, str] = {
"critical": "oklch(0.45 0.22 27)",
"high": "oklch(0.62 0.20 38)",
"medium": "oklch(0.75 0.15 80)",
"low": "oklch(0.65 0.12 250)",
"info": "oklch(0.708 0 0)",
"unknown": "oklch(0.708 0 0)",
}
# How CycloneDX names each CVSS generation in a rating's ``method``, by which one is newer.
CYCLONEDX_METHODS = {"cvssv2": 2, "cvssv3": 3, "cvssv31": 3, "cvssv4": 4}
@dataclass
class Component:
"""A dependency a vulnerability affects.
Attributes:
name: What the package is called.
version: The version this scan depends on, empty when the report named none.
latest_version: The newest version the reporting tool knew of. What makes
a finding answerable -- a newer version existing is not proof that it
fixes this advisory, only that there is somewhere to upgrade to.
"""
name: str
version: str
latest_version: str = ""
@property
def is_outdated(self) -> bool:
"""Whether the package has been released again since the version in use."""
return bool(self.latest_version) and self.latest_version != self.version
@dataclass
class VulnerabilityMetrics:
"""What one advisory's CVSS vector and scoring say, in the words a reader uses.
Attributes:
attack_vector: Where an attacker has to be.
attack_complexity: Whether anything outside their control has to line up.
privileges_required: What account they need. v2 states how often they log
in instead, which is the same question.
user_interaction: Who they have to fool.
confidentiality: What they get to read.
integrity: What they get to change.
availability: How far they can take the service down.
epss: The probability of exploitation in the next 30 days, 0 to 1, or
``None`` where nobody scored it -- which is not a probability of zero.
epss_percentile: Where that probability ranks among everything scored.
published_days_ago: How long the advisory has been public.
"""
attack_vector: str = ""
attack_complexity: str = ""
privileges_required: str = ""
user_interaction: str = ""
confidentiality: str = ""
integrity: str = ""
availability: str = ""
epss: float | None = None
epss_percentile: float | None = None
published_days_ago: int | None = None
@dataclass
class Vulnerability:
"""A single CycloneDX vulnerability, flattened into the fields the template needs."""
id: str
severity: str
description: str
cwes: list[str]
components: list[Component]
score: float | None
recommendation: str
url: str
metrics: VulnerabilityMetrics = field(default_factory=VulnerabilityMetrics)
cwe_names: dict[str, str] = field(default_factory=dict)
@property
def cwes_json(self) -> str:
"""The weakness classes as one JSON array of identifier and name.
One attribute rather than two parallel pipe-joined lists, which would have to be kept in
step by whoever writes the template and would silently mispair if they ever were not.
A class the report named without describing carries an empty name, and the panel shows the
identifier alone rather than inventing a description for it.
"""
return json.dumps(
[{"id": one, "name": self.cwe_names.get(one, "")} for one in self.cwes],
separators=(",", ":"),
)
@property
def fix_available(self) -> bool:
"""Whether any affected package has been released again since."""
return any(component.is_outdated for component in self.components)
@property
def metrics_json(self) -> str:
"""The metrics as one JSON object, for the row to carry into the panel.
One attribute rather than ten: the panel needs all of them or none, and a single blob
cannot end up half-updated by a template that forgot a field. Keyed the way the script
reads them, which is the same shape its sample values have -- so the panel does not know
or care which of the two it was handed.
"""
return json.dumps(
{
"score": self.score,
"band": self.severity.capitalize(),
"epss": self.metrics.epss,
"epssPercentile": self.metrics.epss_percentile,
"confidentiality": self.metrics.confidentiality,
"integrity": self.metrics.integrity,
"availability": self.metrics.availability,
"attackVector": self.metrics.attack_vector,
"attackComplexity": self.metrics.attack_complexity,
"privilegesRequired": self.metrics.privileges_required,
"userInteraction": self.metrics.user_interaction,
"publishedDaysAgo": self.metrics.published_days_ago,
"fixAvailable": self.fix_available,
},
separators=(",", ":"),
)
@property
def component_names(self) -> list[str]:
"""Display names of the affected components, name plus version when the version is known."""
return [
f"{component.name}@{component.version}" if component.version else component.name
for component in self.components
]
@dataclass
class VdrReport:
"""Everything the scan page shows about one CycloneDX VDR report."""
total: int
vulnerabilities: list[Vulnerability]
severity_counts: list[Stat]
top_components: list[Stat]
component_count: int
chart_data: dict[str, Any] = field(default_factory=dict)
@property
def critical_high_count(self) -> int:
"""Number of vulnerabilities rated critical or high."""
return sum(1 for vuln in self.vulnerabilities if vuln.severity in ("critical", "high"))
def _components_by_ref(data: dict[str, Any]) -> dict[str, Component]:
"""Map the bom-ref of every component to a ``Component``, for resolving ``affects``.
Args:
data: A parsed CycloneDX VDR document.
Returns:
The components keyed by their bom-ref.
"""
components: dict[str, Component] = {}
for component in data.get("components", []):
if not isinstance(component, dict):
continue
bom_ref = component.get("bom-ref") or component.get("purl")
if not bom_ref:
continue
components[bom_ref] = Component(
name=str(component.get("name") or bom_ref),
version=str(component.get("version") or ""),
)
return components
def _severity_of(vulnerability: dict[str, Any]) -> str:
"""Return the most severe rating of a vulnerability, ``unknown`` when none is given.
Args:
vulnerability: A single CycloneDX vulnerability.
Returns:
One of the severities in ``SEVERITY_ORDER``.
"""
best = "unknown"
for rating in vulnerability.get("ratings", []):
if not isinstance(rating, dict):
continue
severity = rating.get("severity")
if isinstance(severity, str) and severity in SEVERITY_RANK and SEVERITY_RANK[severity] > SEVERITY_RANK[best]:
best = severity
return best
def _score_of(vulnerability: dict[str, Any]) -> float | None:
"""Return the highest CVSS score carried by any rating of a vulnerability.
Args:
vulnerability: A single CycloneDX vulnerability.
Returns:
The highest score, or ``None`` when no rating has a numeric score.
"""
scores = [
float(rating["score"])
for rating in vulnerability.get("ratings", [])
if isinstance(rating, dict) and isinstance(rating.get("score"), int | float)
]
return max(scores) if scores else None
def _cwes_of(vulnerability: dict[str, Any]) -> list[str]:
"""Return the CWE ids of a vulnerability as ``CWE-<n>`` strings.
Args:
vulnerability: A single CycloneDX vulnerability.
Returns:
The CWE ids, normalised to the ``CWE-<n>`` form.
"""
cwes: list[str] = []
for value in vulnerability.get("cwes", []):
if isinstance(value, int):
cwes.append(f"CWE-{value}")
elif isinstance(value, str) and value:
cwes.append(value if value.startswith("CWE-") else f"CWE-{value}")
return cwes
def _components_of(vulnerability: dict[str, Any], components: dict[str, Component]) -> list[Component]:
"""Resolve the affected components of a vulnerability via their bom-ref.
Affects whose reference is unknown are still listed, using the reference itself as the name so
the vulnerability is never left without a target.
Args:
vulnerability: A single CycloneDX vulnerability.
components: All components of the document, keyed by bom-ref.
Returns:
The resolved components the vulnerability affects.
"""
resolved: list[Component] = []
for affect in vulnerability.get("affects", []):
ref = affect.get("ref") if isinstance(affect, dict) else None
if not ref:
continue
resolved.append(components.get(ref, Component(name=str(ref), version="")))
return resolved
def _url_of(vulnerability: dict[str, Any]) -> str:
"""Return a reference url for a vulnerability: its source url or the first advisory.
Args:
vulnerability: A single CycloneDX vulnerability.
Returns:
A url, or an empty string when the vulnerability carries none.
"""
source_url = vulnerability.get("source", {})
if isinstance(source_url, dict) and source_url.get("url"):
return str(source_url["url"])
for advisory in vulnerability.get("advisories", []):
url = advisory.get("url") if isinstance(advisory, dict) else None
if url:
return str(url)
return ""
def _build_vulnerability(vulnerability: dict[str, Any], components: dict[str, Component]) -> Vulnerability:
"""Flatten a single CycloneDX vulnerability into a ``Vulnerability``.
Args:
vulnerability: A single CycloneDX vulnerability.
components: All components of the document, keyed by bom-ref.
Returns:
The flattened vulnerability.
"""
return Vulnerability(
id=str(vulnerability.get("id") or "Unknown"),
severity=_severity_of(vulnerability),
description=str(vulnerability.get("description") or vulnerability.get("detail") or ""),
cwes=_cwes_of(vulnerability),
components=_components_of(vulnerability, components),
score=_score_of(vulnerability),
recommendation=str(vulnerability.get("recommendation") or ""),
url=_url_of(vulnerability),
metrics=_metrics_of(vulnerability, _vector_of(vulnerability)),
)
def _vector_of(vulnerability: dict[str, Any]) -> str | None:
"""Return the newest CVSS vector a CycloneDX vulnerability rates itself with.
Args:
vulnerability: A single CycloneDX vulnerability.
Returns:
The vector, or ``None`` when no rating carries one. CycloneDX names the
method rather than versioning the vector, so the ratings are ordered by
the method they name and the last generation wins.
"""
rated = [
(str(rating.get("method") or ""), str(rating["vector"]))
for rating in vulnerability.get("ratings", [])
if isinstance(rating, dict) and rating.get("vector")
]
if not rated:
return None
return max(rated, key=lambda pair: CYCLONEDX_METHODS.get(pair[0].lower(), 0))[1]
def _sort_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnerability]:
"""Sort vulnerabilities by severity, most severe first."""
return sorted(vulnerabilities, key=lambda vuln: -SEVERITY_RANK.get(vuln.severity, 0))
def _to_stats(counts: list[tuple[str, int]], sublabels: dict[str, str] | None = None) -> list[Stat]:
"""Turn ``(label, count)`` pairs into ``Stat`` entries with a percentage of the largest count.
Args:
counts: The ``(label, count)`` pairs to turn into stats.
sublabels: Optional extra labels keyed by the stat label.
Returns:
The stats, each carrying its share of the largest count in percent.
"""
if not counts:
return []
largest = max(count for _, count in counts)
return [
Stat(
label=label,
sublabel=(sublabels or {}).get(label, ""),
count=count,
percent=round(count / largest * 100) if largest else 0,
)
for label, count in counts
]
def _severity_stats(severity_totals: Mapping[str, int]) -> list[Stat]:
"""Turn vulnerability counts per severity into stats, in severity order and without empty buckets.
The percentage is the share of the total number of vulnerabilities, so the bars in the overview
show each severity's proportion rather than just its size relative to the largest bucket.
Args:
severity_totals: The number of vulnerabilities per severity.
Returns:
The per-severity stats.
"""
total = sum(severity_totals.values())
return [
Stat(
label=severity,
sublabel="",
count=severity_totals[severity],
percent=round(severity_totals[severity] / total * 100) if total else 0,
)
for severity in SEVERITY_ORDER
if severity_totals.get(severity)
]
def _component_stats(vulnerabilities: list[Vulnerability]) -> list[Stat]:
"""Count the components with the most vulnerabilities.
Args:
vulnerabilities: The vulnerabilities to count components from.
Returns:
The most frequently affected components.
"""
counter: Counter[str] = Counter()
for vuln in vulnerabilities:
counter.update(vuln.component_names)
return _to_stats(counter.most_common(TOP_LIST_SIZE))
def _chart_data(severity_counts: list[Stat], component_counts: list[Stat]) -> dict[str, Any]:
"""Build the data blob the Chart.js charts on the scan page are fed with.
Args:
severity_counts: The per-severity stats.
component_counts: The per-component stats.
Returns:
The chart data for the severity doughnut and the components bar chart.
"""
return {
"severity": {
"labels": [stat.label.capitalize() for stat in severity_counts],
"values": [stat.count for stat in severity_counts],
"colors": [SEVERITY_COLORS[stat.label] for stat in severity_counts],
},
"components": {
"labels": [stat.label for stat in component_counts],
"values": [stat.count for stat in component_counts],
},
}
def _build_report(vulnerabilities: list[Vulnerability]) -> VdrReport:
"""Sort a list of vulnerabilities and derive every statistic the templates render from it.
Args:
vulnerabilities: The vulnerabilities to summarise.
Returns:
The summarised report.
"""
vulnerabilities = _sort_vulnerabilities(vulnerabilities)
severity_counts = _severity_stats(Counter(vuln.severity for vuln in vulnerabilities))
component_counts = _component_stats(vulnerabilities)
return VdrReport(
total=len(vulnerabilities),
vulnerabilities=vulnerabilities,
severity_counts=severity_counts,
top_components=component_counts,
component_count=len({name for vuln in vulnerabilities for name in vuln.component_names}),
chart_data=_chart_data(severity_counts, component_counts),
)
# ── Dependency-Track findings export (Finding Packaging Format) ─────────────
# Dependency-Track spells its severities in upper case and uses ``UNASSIGNED`` for unrated findings.
FPF_SEVERITIES: dict[str, str] = {
"CRITICAL": "critical",
"HIGH": "high",
"MEDIUM": "medium",
"LOW": "low",
"INFO": "info",
"UNASSIGNED": "unknown",
}
# The ``references`` field is markdown, so the urls are pulled back out of the link targets.
_MARKDOWN_LINK = re.compile(r"\((https?://[^)\s]+)\)")
def _fpf_severity_of(vulnerability: dict[str, Any]) -> str:
"""Return the severity of a Dependency-Track finding, ``unknown`` when it carries none."""
severity = vulnerability.get("severity")
return FPF_SEVERITIES.get(severity.upper(), "unknown") if isinstance(severity, str) else "unknown"
def _fpf_score_of(vulnerability: dict[str, Any]) -> float | None:
"""Return the highest CVSS base score a Dependency-Track finding carries.
Args:
vulnerability: The ``vulnerability`` part of a single finding.
Returns:
The highest score of any CVSS version, or ``None`` when the finding carries none.
"""
scores = [
float(vulnerability[key])
for key in ("cvssV2BaseScore", "cvssV3BaseScore", "cvssV4Score")
if isinstance(vulnerability.get(key), int | float)
]
return max(scores) if scores else None
def _fpf_cwes_of(vulnerability: dict[str, Any]) -> list[str]:
"""Return the CWE ids of a Dependency-Track finding as ``CWE-<n>`` strings.
Newer reports carry a ``cwes`` list, older ones only a single ``cweId``.
Args:
vulnerability: The ``vulnerability`` part of a single finding.
Returns:
The CWE ids, normalised to the ``CWE-<n>`` form.
"""
cwes = [
f"CWE-{cwe['cweId']}"
for cwe in vulnerability.get("cwes", [])
if isinstance(cwe, dict) and cwe.get("cweId") is not None
]
if not cwes and vulnerability.get("cweId") is not None:
cwes.append(f"CWE-{vulnerability['cweId']}")
return cwes
def _fpf_cwe_names_of(vulnerability: dict[str, Any]) -> dict[str, str]:
"""Return what each weakness class of a Dependency-Track finding is called.
Args:
vulnerability: The ``vulnerability`` part of a single finding.
Returns:
The names by identifier. Older reports name only the single ``cweId``
they carry, under ``cweName``; newer ones name each entry of ``cwes``.
Both are read, and a report that names none simply yields nothing.
"""
names = {
f"CWE-{cwe['cweId']}": str(cwe["name"])
for cwe in vulnerability.get("cwes", [])
if isinstance(cwe, dict) and cwe.get("cweId") is not None and cwe.get("name")
}
if not names and vulnerability.get("cweId") is not None and vulnerability.get("cweName"):
names[f"CWE-{vulnerability['cweId']}"] = str(vulnerability["cweName"])
return names
def _fpf_url_of(vulnerability: dict[str, Any]) -> str:
"""Return the first reference url of a Dependency-Track finding.
Args:
vulnerability: The ``vulnerability`` part of a single finding.
Returns:
A url, or an empty string when the finding references none.
"""
references = vulnerability.get("references")
if not isinstance(references, str):
return ""
match = _MARKDOWN_LINK.search(references)
return match.group(1) if match else ""
def _fpf_component_of(finding: dict[str, Any]) -> Component | None:
"""Return the component a Dependency-Track finding was raised against."""
component = finding.get("component")
if not isinstance(component, dict) or not component.get("name"):
return None
return Component(
name=str(component["name"]),
version=str(component.get("version") or ""),
latest_version=str(component.get("latestVersion") or ""),
)
# The vectors a Dependency-Track export can state, newest generation first.
FPF_VECTORS = ("cvssV4Vector", "cvssV3Vector", "cvssV2Vector")
def _fpf_vector_of(vulnerability: dict[str, Any]) -> str | None:
"""Return the newest CVSS vector a Dependency-Track finding states, if any."""
for key in FPF_VECTORS:
if isinstance(vulnerability.get(key), str) and vulnerability[key]:
return str(vulnerability[key])
return None
def _metrics_of(vulnerability: dict[str, Any], vector: str | None) -> VulnerabilityMetrics:
"""Read one advisory's vector and scoring into the words the panel shows.
Args:
vulnerability: The advisory, in either document's shape.
vector: The CVSS vector to read, if the report stated one.
Returns:
The metrics. Everything the report did not state stays empty rather than
being filled with a default: a metric nobody stated is not one stated as
harmless, and the panel greys out what it was not told.
"""
reading = parse_vector(vector)
spoken = {}
if reading is not None:
confidentiality, integrity, availability = IMPACT_KEYS[reading.generation]
spoken = {
"attack_vector": _spoken("AV", reading.attack_vector, reading.generation),
"attack_complexity": _spoken("AC", reading.attack_complexity, reading.generation),
"privileges_required": _spoken(
"Au" if reading.generation == 2 else "PR",
reading.authentication if reading.generation == 2 else reading.privileges_required,
reading.generation,
),
"user_interaction": _spoken("UI", reading.user_interaction, reading.generation),
"confidentiality": _spoken(confidentiality, reading.confidentiality_impact, reading.generation),
"integrity": _spoken(integrity, reading.integrity_impact, reading.generation),
"availability": _spoken(availability, reading.availability_impact, reading.generation),
}
return VulnerabilityMetrics(
epss=_probability(vulnerability.get("epssScore")),
epss_percentile=_probability(vulnerability.get("epssPercentile")),
published_days_ago=_days_since(vulnerability.get("published")),
**spoken,
)
def _spoken(metric: str, value: str | None, generation: int) -> str:
"""Return the word behind one abbreviation, or nothing where the vector carried none."""
return "" if value is None else describe(metric, value, generation)
def _probability(stated: Any) -> float | None: # noqa: ANN401
"""Return a probability the report stated, refusing anything that is not one."""
return float(stated) if isinstance(stated, int | float) and not isinstance(stated, bool) else None
def _days_since(stated: Any) -> int | None: # noqa: ANN401
"""Return how many days ago a timestamp was, or nothing where it cannot be read."""
if not isinstance(stated, str):
return None
try:
published = datetime.fromisoformat(stated)
except ValueError:
return None
now = datetime.now(published.tzinfo) if published.tzinfo else datetime.now() # noqa: DTZ005
return max(0, (now - published).days)
def _is_suppressed(finding: dict[str, Any]) -> bool:
"""Tell whether a finding was suppressed in Dependency-Track."""
analysis = finding.get("analysis")
return isinstance(analysis, dict) and bool(analysis.get("isSuppressed"))
def _summarize_findings(findings: list[Any]) -> VdrReport:
"""Summarise a Dependency-Track findings export.
Every finding pairs one component with one vulnerability, so a vulnerability affecting several
dependencies arrives as several findings. They are merged back into a single vulnerability that
lists all of its components, which is what the scan page shows.
Args:
findings: The ``findings`` list of the export.
Returns:
The summarised report.
"""
merged: dict[str, Vulnerability] = {}
for finding in findings:
if not isinstance(finding, dict) or _is_suppressed(finding):
continue
vulnerability = finding.get("vulnerability")
if not isinstance(vulnerability, dict):
continue
key = str(vulnerability.get("uuid") or vulnerability.get("vulnId") or "")
component = _fpf_component_of(finding)
if key in merged:
if component is not None:
merged[key].components.append(component)
continue
merged[key] = Vulnerability(
id=str(vulnerability.get("vulnId") or "Unknown"),
severity=_fpf_severity_of(vulnerability),
description=str(vulnerability.get("description") or vulnerability.get("title") or ""),
cwes=_fpf_cwes_of(vulnerability),
components=[component] if component is not None else [],
score=_fpf_score_of(vulnerability),
recommendation=str(vulnerability.get("recommendation") or ""),
url=_fpf_url_of(vulnerability),
metrics=_metrics_of(vulnerability, _fpf_vector_of(vulnerability)),
cwe_names=_fpf_cwe_names_of(vulnerability),
)
return _build_report(list(merged.values()))
def summarize_vdr(vdr_data: dict[str, Any]) -> VdrReport:
"""Summarise a parsed vulnerability report for rendering on the scan page.
Both the Dependency-Track findings export and a CycloneDX VDR document are accepted; the
``findings`` key is what tells them apart. Every vulnerability of the document is included; the
scan page renders the complete list.
Args:
vdr_data: A parsed findings export or CycloneDX VDR document.
Returns:
The summarised report.
"""
findings = vdr_data.get("findings")
if isinstance(findings, list):
return _summarize_findings(findings)
components = _components_by_ref(vdr_data)
vulnerabilities = [
_build_vulnerability(vuln, components) for vuln in vdr_data.get("vulnerabilities", []) if isinstance(vuln, dict)
]
return _build_report(vulnerabilities)
@dataclass
class VulnerabilitySummary:
"""The numbers of one or more vulnerability reports, in a form that can be stored and added up.
The counterpart of ``FindingsSummary``: the listing pages only show counts, never individual
vulnerabilities, so they work off this instead of the full report.
"""
total: int = 0
component_count: int = 0
severity_totals: dict[str, int] = field(default_factory=dict)
@property
def severities(self) -> dict[str, int]:
"""Vulnerability count per severity, including the severities without any vulnerability."""
counts = dict.fromkeys(SEVERITY_ORDER, 0)
for severity, count in self.severity_totals.items():
if severity in counts:
counts[severity] = count
return counts
@property
def severity_counts(self) -> list[Stat]:
"""Vulnerabilities per severity, in severity order and without empty buckets."""
return _severity_stats(self.severities)
@property
def critical_high_count(self) -> int:
"""Number of vulnerabilities rated critical or high."""
severities = self.severities
return severities["critical"] + severities["high"]
@classmethod
def of(cls, report: VdrReport) -> VulnerabilitySummary:
"""Reduce a full report to the numbers the listing pages need."""
return cls(
total=report.total,
component_count=report.component_count,
severity_totals={stat.label: stat.count for stat in report.severity_counts},
)
def to_dict(self) -> dict[str, Any]:
"""Serialise the summary so it can be stored as JSON."""
return {
"total": self.total,
"component_count": self.component_count,
"severity_totals": self.severity_totals,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> VulnerabilitySummary:
"""Rebuild a summary from stored JSON, tolerating entries that miss a key."""
return cls(
total=int(data.get("total", 0)),
component_count=int(data.get("component_count", 0)),
severity_totals=dict(data.get("severity_totals") or {}),
)
def merge_vulnerability_summaries(summaries: list[VulnerabilitySummary]) -> VulnerabilitySummary:
"""Add up the summaries of several scans.
Components are counted per scan, so the same dependency in two repositories counts twice - which
is what the listing pages want, as those are two different components to update.
Args:
summaries: The summaries to add up.
Returns:
One summary covering all of them.
"""
severities: Counter[str] = Counter()
for summary in summaries:
severities.update(summary.severity_totals)
return VulnerabilitySummary(
total=sum(summary.total for summary in summaries),
component_count=sum(summary.component_count for summary in summaries),
severity_totals=dict(severities),
)