From 06593ac488d131a63b8e431a456ed7f807abaac1 Mon Sep 17 00:00:00 2001 From: Florian Frank Date: Fri, 18 Sep 2026 16:58:29 +0200 Subject: [PATCH] Initial commit. monorepo deps are copied in a dirty way to the vendor folder --- report_generator/commands/__init__.py | 1 + report_generator/commands/generate.py | 88 ++ report_generator/context.py | 595 +++++++++++++ report_generator/layout.py | 90 ++ report_generator/rendering.py | 100 +++ report_generator/sample.py | 525 +++++++++++ report_generator/sources.py | 137 +++ report_generator/template/cc_logo.png | Bin 0 -> 60351 bytes report_generator/template/fonts/OFL.txt | 93 ++ .../template/fonts/SpaceGrotesk-Bold.ttf | Bin 0 -> 88200 bytes .../template/fonts/SpaceGrotesk-Medium.ttf | Bin 0 -> 88328 bytes .../template/fonts/SpaceGrotesk-Regular.ttf | Bin 0 -> 88300 bytes .../template/fonts/SpaceMono-Bold.ttf | Bin 0 -> 98232 bytes .../template/fonts/SpaceMono-Regular.ttf | Bin 0 -> 99356 bytes report_generator/template/report.css | 422 +++++++++ report_generator/template/report.html.j2 | 182 ++++ report_generator/vendor/README.md | 42 + report_generator/vendor/__init__.py | 53 ++ report_generator/vendor/cc_common/__init__.py | 1 + .../vendor/cc_common/api/__init__.py | 11 + .../vendor/cc_common/api/cli/__init__.py | 5 + .../vendor/cc_common/api/cli/_utils.py | 203 +++++ .../vendor/cc_common/api/cli/cli_manager.py | 216 +++++ .../vendor/cc_common/api/context.py | 70 ++ .../vendor/cc_common/api/pipeline_error.py | 70 ++ report_generator/vendor/cc_common/config.py | 190 ++++ .../vendor/cc_common/defines/__init__.py | 5 + .../cc_common/defines/general_defines.py | 49 ++ .../vendor/cc_common/docker/__init__.py | 26 + .../vendor/cc_common/docker/compose_utils.py | 112 +++ .../vendor/cc_common/docker/defs.py | 19 + .../vendor/cc_common/docker/docker_utils.py | 266 ++++++ .../vendor/cc_common/processes/__init__.py | 5 + .../cc_common/processes/subprocess_utils.py | 72 ++ report_generator/vendor/cc_common/py.typed | 0 .../vendor/cc_common/utils/__init__.py | 14 + .../cc_common/utils/progress_manager.py | 57 ++ .../vendor/cc_common/utils/rich_utils.py | 32 + .../cc_common/utils/system_detection.py | 41 + .../vendor/cipherverifier/__init__.py | 1 + .../vendor/cipherverifier/core/__init__.py | 1 + .../cipherverifier/core/services/__init__.py | 1 + .../cipherverifier/core/services/sarif.py | 677 +++++++++++++++ .../cipherverifier/core/services/vdr.py | 813 ++++++++++++++++++ .../vendor/persistent_storage/__init__.py | 1 + .../vendor/persistent_storage/helper.py | 471 ++++++++++ .../vendor/persistent_storage/shared.py | 101 +++ .../persistent_storage/utils/__init__.py | 1 + .../persistent_storage/utils/cvss_utils.py | 492 +++++++++++ .../persistent_storage/utils/db_utils.py | 324 +++++++ .../persistent_storage/utils/s3_utils.py | 170 ++++ .../vendor/persistent_storage/wrapper.py | 744 ++++++++++++++++ report_generator/vendor/sync.py | 103 +++ 53 files changed, 7692 insertions(+) create mode 100644 report_generator/commands/__init__.py create mode 100644 report_generator/commands/generate.py create mode 100644 report_generator/context.py create mode 100644 report_generator/layout.py create mode 100644 report_generator/rendering.py create mode 100644 report_generator/sample.py create mode 100644 report_generator/sources.py create mode 100644 report_generator/template/cc_logo.png create mode 100644 report_generator/template/fonts/OFL.txt create mode 100644 report_generator/template/fonts/SpaceGrotesk-Bold.ttf create mode 100644 report_generator/template/fonts/SpaceGrotesk-Medium.ttf create mode 100644 report_generator/template/fonts/SpaceGrotesk-Regular.ttf create mode 100644 report_generator/template/fonts/SpaceMono-Bold.ttf create mode 100644 report_generator/template/fonts/SpaceMono-Regular.ttf create mode 100644 report_generator/template/report.css create mode 100644 report_generator/template/report.html.j2 create mode 100644 report_generator/vendor/README.md create mode 100644 report_generator/vendor/__init__.py create mode 100644 report_generator/vendor/cc_common/__init__.py create mode 100644 report_generator/vendor/cc_common/api/__init__.py create mode 100644 report_generator/vendor/cc_common/api/cli/__init__.py create mode 100644 report_generator/vendor/cc_common/api/cli/_utils.py create mode 100644 report_generator/vendor/cc_common/api/cli/cli_manager.py create mode 100644 report_generator/vendor/cc_common/api/context.py create mode 100644 report_generator/vendor/cc_common/api/pipeline_error.py create mode 100644 report_generator/vendor/cc_common/config.py create mode 100644 report_generator/vendor/cc_common/defines/__init__.py create mode 100644 report_generator/vendor/cc_common/defines/general_defines.py create mode 100644 report_generator/vendor/cc_common/docker/__init__.py create mode 100644 report_generator/vendor/cc_common/docker/compose_utils.py create mode 100644 report_generator/vendor/cc_common/docker/defs.py create mode 100644 report_generator/vendor/cc_common/docker/docker_utils.py create mode 100644 report_generator/vendor/cc_common/processes/__init__.py create mode 100644 report_generator/vendor/cc_common/processes/subprocess_utils.py create mode 100644 report_generator/vendor/cc_common/py.typed create mode 100644 report_generator/vendor/cc_common/utils/__init__.py create mode 100644 report_generator/vendor/cc_common/utils/progress_manager.py create mode 100644 report_generator/vendor/cc_common/utils/rich_utils.py create mode 100644 report_generator/vendor/cc_common/utils/system_detection.py create mode 100644 report_generator/vendor/cipherverifier/__init__.py create mode 100644 report_generator/vendor/cipherverifier/core/__init__.py create mode 100644 report_generator/vendor/cipherverifier/core/services/__init__.py create mode 100644 report_generator/vendor/cipherverifier/core/services/sarif.py create mode 100644 report_generator/vendor/cipherverifier/core/services/vdr.py create mode 100644 report_generator/vendor/persistent_storage/__init__.py create mode 100644 report_generator/vendor/persistent_storage/helper.py create mode 100644 report_generator/vendor/persistent_storage/shared.py create mode 100644 report_generator/vendor/persistent_storage/utils/__init__.py create mode 100644 report_generator/vendor/persistent_storage/utils/cvss_utils.py create mode 100644 report_generator/vendor/persistent_storage/utils/db_utils.py create mode 100644 report_generator/vendor/persistent_storage/utils/s3_utils.py create mode 100644 report_generator/vendor/persistent_storage/wrapper.py create mode 100644 report_generator/vendor/sync.py diff --git a/report_generator/commands/__init__.py b/report_generator/commands/__init__.py new file mode 100644 index 0000000..7675758 --- /dev/null +++ b/report_generator/commands/__init__.py @@ -0,0 +1 @@ +"""The commands the report generator exposes.""" diff --git a/report_generator/commands/generate.py b/report_generator/commands/generate.py new file mode 100644 index 0000000..463fb02 --- /dev/null +++ b/report_generator/commands/generate.py @@ -0,0 +1,88 @@ +"""report_generator generate command.""" + +from pathlib import Path +from uuid import UUID + +from cc_common.api import PipelineError, context +from cc_common.utils import print +from pydantic import BaseModel, Field + +from persistent_storage.shared import StorageConfiguration + +from ..rendering import render +from ..sources import collect + +# Where the report goes when the caller names no path. +DEFAULT_DIRECTORY = Path("reports") + + +class GenerateProperties(BaseModel): + """Configuration properties for the generate CLI command. + + Attributes: + scan_id: The scan to report on. + path: Where to write the PDF. A directory is written into under the name + the renderer chose; leaving it out writes into ``reports/``. + 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.") + storage_config: StorageConfiguration + + +class GeneratedReport(BaseModel): + """The report that was written. + + Attributes: + scan_id: The scan it is about. + path: The file written. + size: How large it came out, in bytes. + """ + + scan_id: UUID + path: Path + size: int + + def print_as_cc_common_result(self) -> None: + """Say what was written and where.""" + print(f"[bold]Report for scan {self.scan_id}[/bold]") + print(f" {self.size / 1024:.0f} kB written to {self.path}") + + +def generate(properties: GenerateProperties) -> GeneratedReport: + """Build the PDF report of one scan and write it. + + Args: + properties: Parsed command properties naming the scan and the destination. + + Returns: + The report that was written. + + Raises: + PipelineError: The scan cannot be read, is missing an input the report + needs, or the destination cannot be written. + """ + sources = collect(properties.storage_config, properties.scan_id) + name, content = render(sources) + 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 report to '{destination}': {error}" + + raise PipelineError(message) from error + + context.logger.info(f"Report written to {destination} ✔") + + return GeneratedReport(scan_id=properties.scan_id, path=destination, size=len(content)) + + +def _destination(path: Path | None, name: str) -> Path: + """Where the report goes, treating a directory as a place to put it rather than as the file.""" + if path is None: + return DEFAULT_DIRECTORY / name + + return path / name if path.is_dir() or not path.suffix else path diff --git a/report_generator/context.py b/report_generator/context.py new file mode 100644 index 0000000..1369900 --- /dev/null +++ b/report_generator/context.py @@ -0,0 +1,595 @@ +"""Turns a scan's stored artifacts into the context the report template draws. + +No parsing of its own. The SARIF and the vulnerability report are read by the summarisers in +:mod:`cipherverifier.core.services.sarif` and :mod:`cipherverifier.core.services.vdr` -- both of +which are deliberately free of Django and of any store -- and the pipeline metadata by the helpers +in :mod:`persistent_storage.utils.rep_utils`. What is left here is the mapping: which summarised +number goes in which row, and what to say where the artifacts do not say anything. + +That last part is the rule this module is built around. A field the artifacts cannot answer is +written as :data:`UNKNOWN` rather than guessed, left blank or quietly dropped. A reader who sees +```` knows the pipeline did not report it; a reader who sees a plausible zero does not. + +Moving this to archon is a change of what :mod:`sources` reads +from. The two summarisers travel with it -- they belong beside whatever holds the artifacts, not +inside the web application that happens to import them today. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from cipherverifier.core.services.sarif import summarize_sarif +from cipherverifier.core.services.vdr import summarize_vdr + +if TYPE_CHECKING: + from cipherverifier.core.services.sarif import Finding, SarifReport + from cipherverifier.core.services.vdr import VdrReport, Vulnerability + + from .sources import ReportSources + +# What stands in for a field the stored artifacts do not answer. +UNKNOWN = "" + +# 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. +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") + + +def build_context(sources: ReportSources, documents: dict[str, Any]) -> 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. + + Returns: + The context :func:`layout.render_pdf` + draws. Sections whose artifact is missing are left out entirely, which + the template renders as a shorter report. + """ + metadata = documents.get("metadata") or {} + sast = summarize_sarif(documents["findings"]) if documents.get("findings") else None + 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), + "sections": [], + } + + critical = _critical(sast, sca) + + if critical["table"]["rows"]: + context["critical"] = critical + + if sast is not None: + context["sections"].append(_sast_section(sast)) + + if sca is not None: + context["sections"].append(_sca_section(sca)) + + return context + + +# --- the page ------------------------------------------------------------------------------ + + +def _fact(label: str, value: Any, *, gap: bool = False, kind: str | None = None) -> dict[str, Any]: # noqa: ANN401 + """One label/value row, with anything unstated written as :data:`UNKNOWN`.""" + return {"label": label, "value": _stated(value), "gap": gap, "kind": kind} + + +def _row(label: str, value: Any, **marks: Any) -> dict[str, Any]: # noqa: ANN401 + """One row of a card.""" + return {"label": label, "value": _stated(value), **marks} + + +def _stated(value: Any) -> str: # noqa: ANN401 + """What a value reads as, or :data:`UNKNOWN` where there is nothing to read. + + Zero is a reading and survives; an empty string and ``None`` are not, and do not. The difference + matters on a page where both appear: "0 filtered" is a measurement, and a blank is a gap. + """ + if value is None or (isinstance(value, str) and not value.strip()): + return UNKNOWN + + return str(value) + + +def _cover( + sources: ReportSources, + metadata: dict[str, Any], + sast: SarifReport | None, + sca: VdrReport | 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] + + return { + "scan_id": str(sources.scan_id), + "subject": _stated(configuration.get("repository_name")), + "footer_date": datetime.now(UTC).strftime("%d %B %Y"), + "logo": "cc_logo.png", + "title": "SAST/SCA Report", + "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("Report Created", datetime.now(UTC).strftime("%A, %B %d, %Y")), + _fact("Manufacturer", None), + ], + "severity_ledger": _severity_ledger(sast, 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.""" + counts = dict.fromkeys(SEVERITY_ORDER, 0) + + for finding in _true_findings(sast): + counts[SEVERITY_OF_LEVEL.get(finding.level, "Low")] += 1 + + for vulnerability in sca.vulnerabilities if sca else []: + counts[_vdr_severity(vulnerability)] = counts.get(_vdr_severity(vulnerability), 0) + 1 + + if not any(counts.values()): + return None + + rows: list[dict[str, Any]] = [ + {"label": f"{name} severity issues", "count": counts.get(name, 0)} for name in SEVERITY_ORDER + ] + rows.append({"label": "Total true issues", "count": sum(counts.values()), "total": True}) + + 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)] + + 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. + """ + 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 {} + + return { + "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), + ], + # 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}]}, + } + + +def _summary(sast: SarifReport | None, sca: VdrReport | 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 + + return { + "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), + ], + "distributions": _distributions(true_sast, sca), + } + + +def _issue_types(sast: SarifReport | None, sca: VdrReport | None) -> list[str]: + """How many distinct kinds of issue each half of the pipeline found.""" + counted = [] + + 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 + + +def _distributions(findings: list[Finding], sca: VdrReport | None) -> list[dict[str, Any]]: + """Where the issues concentrate: by rule for SAST, by package for SCA.""" + 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})", + **_bars( + _group( + [(one, name) for one in sca.vulnerabilities for name in one.component_names], + lambda pair: pair[1], + lambda pair: _vdr_severity(pair[0]), + ), + mono=True, + ), + } + ) + + return blocks + + +def _group[T]( + items: list[T], + label_of: Any, # noqa: ANN401 + severity_of: Any, # noqa: ANN401 +) -> list[tuple[str, dict[str, int]]]: + """Count items by label, and within each label by severity.""" + grouped: dict[str, dict[str, int]] = {} + + for item in items: + counts = grouped.setdefault(label_of(item), dict.fromkeys(SEVERITY_ORDER, 0)) + counts[severity_of(item)] += 1 + + return sorted(grouped.items(), key=lambda pair: -sum(pair[1].values())) + + +def _bars(grouped: list[tuple[str, dict[str, int]]], *, mono: bool = False) -> dict[str, Any]: + """Turn grouped counts into bars scaled against the busiest row. + + Only the busiest :data:`BARS` are drawn, and the caveat says so where the rest were left off. + Without that the legend counts what is on the page while the heading counts the report, and the + two quietly disagree by however much the tail held. + """ + shown = grouped[:BARS] + largest = max((sum(counts.values()) for _, counts in shown), default=0) or 1 + rows: list[dict[str, Any]] = [] + totals = dict.fromkeys(SEVERITY_ORDER, 0) + + for label, counts in shown: + rows.append( + { + "label": label, + "count": sum(counts.values()), + "mono": mono, + "parts": [ + {"severity": name, "share": round(counts[name] / largest * 100, 1)} + for name in SEVERITY_ORDER + if counts[name] + ], + } + ) + + for name, count in counts.items(): + totals[name] += count + + return { + "rows": rows, + "legend": { + "entries": [ + {"severity": name, "label": f"{name} ({totals[name]})"} + for name in reversed(SEVERITY_ORDER) + if totals[name] + ], + "caveat": _caveat(shown, grouped, largest), + }, + } + + +def _caveat(shown: list[Any], grouped: list[Any], largest: int) -> str: + """What the bars leave out, and what their length is measured against.""" + scale = f"bar length is proportional to the issue count (100 % = {largest})." + + if len(shown) == len(grouped): + return scale + + 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]] = [] + + for finding in sorted(_true_findings(sast), key=lambda one: SEVERITY_ORDER.index(_finding_severity(one))): + rows.append( + { + "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"}, + _stated(vulnerability.id), + {"value": _stated(", ".join(vulnerability.component_names)), "kind": "mono"}, + _stated(vulnerability.description), + ] + } + ) + + return { + "title": "Most Critical Vulnerabilities", + "table": { + "columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"], + "rows": 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]] = [] + + 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 filtered: + groups.append( + { + "title": f"Filtered Issues ({len(filtered)})", + "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." + ), + "cards": [_finding_card(one, filtered_out=True) for one in filtered[:CARDS_PER_GROUP]], + "table": _filtered_table(filtered), + } + ) + + return {"title": "SAST Vulnerabilities", "groups": groups} + + +def _finding_card(finding: Finding, *, filtered_out: 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), + _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, + ) + ) + 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)) + + 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]] = {} + + for finding in findings: + grouped.setdefault(finding.rule_id or UNKNOWN, []).append(finding) + + 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}) + + return { + "title": f"Filtered SAST issues by rule ({len(findings)})", + "columns": ["Rule", "Filtered", "Dominant verdict"], + "rows": rows, + } + + +def _sca_section(sca: VdrReport) -> dict[str, Any]: + """The dependency findings.""" + return { + "title": "SBOM / SCA 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]], + } + ], + } + + +def _vulnerability_card(vulnerability: Vulnerability) -> dict[str, Any]: + """One dependency finding, as much of it as the report stated.""" + metrics = vulnerability.metrics + impact = [ + f"{name} ({value})" + for name, value in ( + ("Confidentiality", metrics.confidentiality), + ("Integrity", metrics.integrity), + ("Availability", metrics.availability), + ) + if value + ] + + return { + "title": _stated(vulnerability.id), + "rows": [ + _row("Affected component", ", ".join(vulnerability.component_names) or None, kind="mono"), + _row("Severity", _vdr_severity(vulnerability), kind="severity"), + _row("Description", vulnerability.description), + _row("CWE", ", ".join(f"{one}: {vulnerability.cwe_names[one]}" for one in vulnerability.cwes) or None), + _row("CVSS", f"{vulnerability.score} / 10" if vulnerability.score is not None else None), + _row("Impact", ", ".join(impact) or None), + _row("Exploitation likelihood (EPSS)", _percent(metrics.epss)), + _row( + "Fixed in", + # The report names the newest version of the package, which is not the same as the + # version that fixes this advisory -- so it is labelled as what it is. + next( + (one.latest_version for one in vulnerability.components if one.is_outdated), + None, + ), + kind="mono", + ), + _row("Known for", f"{metrics.published_days_ago} days" if metrics.published_days_ago else None), + _row("Recommendation", vulnerability.recommendation), + ], + } + + +# --- readings ------------------------------------------------------------------------------ + + +def _true_findings(sast: SarifReport | None) -> list[Finding]: + """The findings that survived filtering.""" + return [one for one in sast.findings if not one.false_positive] if sast else [] + + +def _finding_severity(finding: Finding) -> str: + """The severity band a SARIF level falls in.""" + return SEVERITY_OF_LEVEL.get(finding.level, "Low") + + +def _vdr_severity(vulnerability: Vulnerability) -> str: + """The severity band a vulnerability reports, capitalised the way the report bands them.""" + named = (vulnerability.severity or "").capitalize() + + return named if named in SEVERITY_ORDER else "Low" + + +def _where(finding: Finding) -> str | None: + """Where a finding points, as ``file:line``.""" + if finding.location is None: + return None + + line = finding.location.start_line + + return f"{finding.location.file}:{line}" if line else finding.location.file + + +def _percent(share: float | None) -> str | None: + """A probability as a percentage, at the precision it is worth stating.""" + if share is 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}" diff --git a/report_generator/layout.py b/report_generator/layout.py new file mode 100644 index 0000000..ee268b4 --- /dev/null +++ b/report_generator/layout.py @@ -0,0 +1,90 @@ +"""Renders the SAST/SCA report template. + +The template and its stylesheet are the design; this module is only what turns them into a +document. It takes a context that is already shaped for the page -- every number formatted, every +bar already a percentage -- so that the template places strings and never computes. + +Nothing here reads an artifact. Filling a context from a scan is :mod:`context`, which works from +what :mod:`sources` collected; :func:`sample.sample_context` builds one by hand, which is what the +template tests render. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +TEMPLATE_DIR = Path(__file__).resolve().parent / "template" + +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" + +AUTHOR = "CipherCell" + +# The shape :func:`render_html` expects. Written down because the template guards on it: a key that +# is absent leaves its section out, which is how a scan that ran half the pipeline produces a +# 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[]}", + "sections": "[{title, new_page?, groups[{title, note?, cards[], table?}]}]", +} + + +def render_html(context: dict[str, Any]) -> str: + """Render the report as an HTML document. + + Args: + context: The page-ready context; see :data:`CONTEXT`. + + Returns: + The rendered HTML. + """ + environment = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(default_for_string=True, default=True), + trim_blocks=True, + lstrip_blocks=True, + ) + + return environment.get_template(TEMPLATE_NAME).render(**context) + + +def render_pdf(context: dict[str, Any]) -> bytes: + """Render the report as a PDF. + + Args: + context: The page-ready context; see :data:`CONTEXT`. + + Returns: + The PDF. + """ + # WeasyPrint pulls in native cairo and pango bindings. Imported here so that importing this + # module -- to render the HTML, or merely to read CONTEXT -- does not pay for them. + from weasyprint import CSS, HTML + from weasyprint.text.fonts import FontConfiguration + + # Without this the @font-face rules are ignored and Space Grotesk is silently replaced by + # whatever the host happens to have, which is how the design sample came to be set in Carlito. + fonts = FontConfiguration() + + # 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( + stylesheets=[CSS(filename=str(STYLESHEET), font_config=fonts)], + font_config=fonts, + ) + + document.metadata.title = f"{TITLE} - {context.get('cover', {}).get('subject', '')}".strip(" -") + document.metadata.authors = [AUTHOR] + + return bytes(document.write_pdf()) diff --git a/report_generator/rendering.py b/report_generator/rendering.py new file mode 100644 index 0000000..eaa3d93 --- /dev/null +++ b/report_generator/rendering.py @@ -0,0 +1,100 @@ +"""Turns collected sources into a PDF. + +Three steps, each somewhere else: this module decides which stored file feeds which part of the +report, :mod:`context` maps the parsed artifacts onto the page, +and :mod:`layout` draws it. + +Adding a section to the report is an entry in :data:`INPUTS` and a block in the template -- not +another renderer. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from cc_common.api import PipelineError +from cc_common.api import context as logging_context + +from .context import build_context +from .layout import render_pdf + +if TYPE_CHECKING: + from .sources import ReportSources + +# Which stored file each input of the report is taken from, under the names the pipeline stages +# write. Several per input because the same document travels under more than one name depending on +# 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"), + "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",) + +# 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) -> tuple[str, bytes]: + """Render the report of one scan. + + Args: + sources: What :func:`sources.collect` found. + + 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. + """ + documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()} + 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." + + raise PipelineError(message) + + found = [name for name, document in documents.items() if document is not None] + + logging_context.logger.info(f"Rendering the report from {', '.join(found)}...") + + return NAME.format(scan=sources.scan_id), render_pdf(build_context(sources, documents)) + + +def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401 + """Read one of the scan's documents, or nothing where it stored none. + + Args: + sources: The scan and its files. + name: What the document is called in :data:`INPUTS`. + candidates: The file names it may have been stored under. + + Returns: + The parsed JSON, or ``None`` when the scan carries none. + + Raises: + PipelineError: The file is there and is not readable. Kept apart from an + absent one on purpose: one is a pipeline that did not run a stage, + the other is a stage that produced something broken, and a report + that treated them alike would hide the second. + """ + content = sources.first(*candidates) + + if content is None: + return None + + try: + return json.loads(content) + except ValueError as error: + message = f"The {name} of scan {sources.scan_id} is not readable JSON: {error}" + + raise PipelineError(message) from error diff --git a/report_generator/sample.py b/report_generator/sample.py new file mode 100644 index 0000000..35ea060 --- /dev/null +++ b/report_generator/sample.py @@ -0,0 +1,525 @@ +"""A complete report context, filled by hand. + +Every section the template can draw appears here at least once, with the content of the design +sample in ``sample_report_template/``. It is what the tests render, and what to compare a real +context against once one is built from a scan: whatever the sample shows, the template supports. + +Written out rather than generated so that it stays readable as a specification of the shape. When a +section gains a field, this file is where it is seen first. +""" + +from __future__ import annotations + +from typing import Any + +SCAN_ID = "3f7a1c9e-8b42-4d61-9c05-27ae1f6b3d80" + + +def _fact(label: str, value: str, *, gap: bool = False, kind: str | None = None) -> dict[str, Any]: + """One label/value row, optionally opening a gap above itself.""" + return {"label": label, "value": value, "gap": gap, "kind": kind} + + +def _row( + label: str, + value: str, + *, + aside: bool = False, + kind: str | None = None, + token: str | None = None, +) -> dict[str, Any]: + """One row of a card. + + Args: + label: What the row is called. + value: What it says. + aside: Tints the row, for the one that argues rather than states. + kind: ``severity`` to colour it, ``mono`` for anything a reader might + have to type or compare character by character. + token: A VEX justification or similar, set as the token it is and placed + before the prose that explains it. + """ + return {"label": label, "value": value, "aside": aside, "kind": kind, "token": token} + + +def _mono(value: str) -> dict[str, Any]: + """A grid cell set in the monospace face.""" + return {"value": value, "kind": "mono"} + + +def _sev(value: str) -> dict[str, Any]: + """A grid cell carrying a severity's colour.""" + return {"value": value, "kind": "severity"} + + +def _bar(label: str, count: int, parts: list[tuple[str, float]], *, mono: bool = False) -> dict[str, Any]: + """One distribution row: a label, its count, and the severity segments of its bar. + + ``mono`` sets the label in the monospace face, for the rows labelled by a package rather than by + a sentence. + """ + return { + "label": label, + "count": count, + "mono": mono, + "parts": [{"severity": severity, "share": share} for severity, share in parts], + } + + +def _cover() -> dict[str, Any]: + """The first page: who it is about, and the one table that answers "how bad".""" + return { + "scan_id": SCAN_ID, + "subject": "SampleRepository v2.4.0", + "footer_date": "16 September 2026", + "logo": "cc_logo.png", + "title": "SAST/SCA Report", + "identity": [ + _fact("Scan Identifier", SCAN_ID, kind="mono"), + _fact("Report ID", "b64d05f2-1ac7-4e9a-8f13-6d20c9b47e55", kind="mono"), + _fact("Artifact ID", "e1c8a730-5f96-42bb-b0d4-9a7351ec2f18", kind="mono"), + _fact("Technology", "SAST, SCA"), + _fact("Project", "SampleRepository", gap=True), + _fact("Version", "v2.4.0 (commit 9f3c2ab1d4e7)"), + _fact("Report Created", "Wednesday, September 16, 2026"), + _fact("Manufacturer", "Firma XY welche den Code besitzt"), + ], + "severity_ledger": { + "title": "True issues by severity", + "rows": [ + {"label": "Critical severity issues", "count": 3}, + {"label": "High severity issues", "count": 73}, + {"label": "Medium severity issues", "count": 28}, + {"label": "Low severity issues", "count": 11}, + {"label": "Total true issues", "count": 115, "total": True}, + ], + }, + } + + +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 { + "title": "Project Information", + "overview": [ + _fact("Project", "SampleRepository"), + _fact("Version", "v2.4.0 (commit 9f3c2ab1d4e7)"), + _fact("Analysed lines of code", "184,920"), + ], + "languages": { + "title": "Languages", + "rows": [ + {"label": "C", "share": "40.2 %"}, + {"label": "Python", "share": "39.6 %"}, + {"label": "JavaScript", "share": "20.2 %"}, + {"label": "Total", "share": "100.0 %", "total": True}, + ], + }, + } + + +def _summary() -> dict[str, Any]: + """The counts, and where they concentrate.""" + return { + "title": "Summary", + "overview": [ + _fact("Raw findings", "210"), + _fact("Filtered", "95 (82 SAST, 13 SCA)"), + _fact("True issues", "115 (86 SAST, 29 SCA)"), + _fact("Distinct issue types (true)", "8 SAST CWEs, 12 affected components"), + ], + "distributions": [ + { + "title": "True SAST issues by CWE (86)", + "rows": [ + _bar("CWE-22: Improper Limitation of a Pathname to a Restricted Directory", 3, [("High", 6)]), + _bar( + "CWE-78: Improper Neutralization of Special Elements used in an OS Command", + 2, + [("Critical", 4)], + ), + _bar("CWE-327: Use of a Broken or Risky Cryptographic Algorithm", 3, [("Medium", 6)]), + _bar("CWE-674: Uncontrolled Recursion", 50, [("High", 84), ("Medium", 16)]), + _bar( + "CWE-770: Allocation of Resources Without Limits or Throttling", + 10, + [("High", 14), ("Medium", 6)], + ), + _bar("CWE-798: Use of Hard-coded Credentials", 4, [("High", 5), ("Medium", 3)]), + _bar("CWE-918: Server-Side Request Forgery", 2, [("High", 4)]), + _bar("CWE-1333: Inefficient Regular Expression Complexity", 12, [("Medium", 5), ("Low", 19)]), + ], + "legend": { + "entries": [ + {"severity": "Low", "label": "Low (9)"}, + {"severity": "Medium", "label": "Medium (20)"}, + {"severity": "High", "label": "High (55)"}, + {"severity": "Critical", "label": "Critical (2)"}, + ], + "caveat": "bar length is proportional to the issue count (100 % = 50).", + }, + }, + { + "title": "Most affected components, true SCA issues (top 4 of 12)", + "rows": [ + _bar("next@16.3.2", 4, [("Critical", 25), ("High", 50), ("Medium", 25)], mono=True), + _bar("glibc@2.36-9+deb12u7", 3, [("High", 75)], mono=True), + _bar("nanoid@3.3.14", 2, [("Medium", 50)], mono=True), + _bar("lodash@4.17.21", 2, [("High", 25), ("Medium", 25)], mono=True), + ], + "legend": { + "entries": [ + {"severity": "Low", "label": "Low"}, + {"severity": "Medium", "label": "Medium"}, + {"severity": "High", "label": "High"}, + {"severity": "Critical", "label": "Critical"}, + ], + "caveat": "11 of 29 true SCA issues shown; 100 % = 4.", + }, + }, + ], + } + + +def _critical() -> dict[str, Any]: + """The handful worth reading first, whichever half of the pipeline found them.""" + return { + "title": "Most Critical Vulnerabilities", + "table": { + "columns": ["Type", "Severity", "CVE/CWE", "Affected File / Component", "Description"], + "rows": [ + { + "cells": [ + "SAST", + _sev("Critical"), + "CWE-78", + _mono("src/tools/exporter.py:57"), + "User-controlled argument passed to subprocess.run(…, shell=True).", + ] + }, + { + "cells": [ + "SCA", + _sev("Critical"), + "GHSA-2xp9-vwfh-vxw4", + _mono("next@16.3.2"), + "Heap buffer overflow in libheif, reached through sharp during AVIF image " + "optimization; allows unauthenticated remote code execution.", + ] + }, + { + "cells": [ + "SAST", + _sev("High"), + "CWE-22", + _mono("src/storage/paths.py:114"), + "Non-literal path component passed to os.path.join().", + ] + }, + ], + }, + } + + +def _sast_section() -> dict[str, Any]: + """The SAST findings: what was kept, and what was set aside and why.""" + return { + "title": "SAST Vulnerabilities", + "groups": [ + { + "title": "True Issues by CWEs", + "cards": [ + { + "title": "CWE-22: Improper Limitation of a Pathname to a Restricted Directory (3 issues)", + "rows": [ + _row("Severity", "High", kind="severity"), + _row( + "Description", + "A path component that is not a string literal is passed to a file system API. " + "Where the component originates from a request parameter, an attacker can " + "traverse outside the intended directory.", + ), + ], + }, + { + "title": "CWE-22 #1 — sast-9f3c2ab-0001", + "rows": [ + _row("Location", "src/storage/paths.py:114", kind="mono"), + _row( + "Description", + "Non-literal path component passed to os.path.join(). The value originates " + 'from request.args["file"] and is not normalised before use.', + ), + _row("Reported By", "bandit v1.8.6 (B108), semgrep v1.145.0"), + _row( + "Reachability", + "Reachable — taint path from entry point api/upload.py:31 (upload_handler), " + "4 hops (Joern)", + ), + _row("Evidence", "Deterministic, no LLM escalation"), + ], + }, + ], + }, + { + "title": "Filtered Issues by CWEs", + "note": ( + "The following findings were reported by the scanners but assessed as not exploitable in " + "this artifact. Each entry carries the filtering reason together with the VEX justification " + "that is written to sbom_annotated.vdr.json. Filtered issues are not counted in the " + "severity summary." + ), + "cards": [ + { + "title": "CWE-1333 #1 — sast-9f3c2ab-0113", + "rows": [ + _row("Location", "server/lib/llm.js:68", kind="mono"), + _row( + "Description", "Backtracking regular expression in the log line parser parseLogLine()." + ), + _row("Reported By", "semgrep v1.145.0"), + _row( + "Filtering Reason", + "no call path from any of the 148 resolved entry points to parseLogLine(); the " + "only caller is the debug CLI, which is excluded from the production build. " + "Deterministic (Joern), no LLM escalation. Decided 2026-09-16 by Polygraph " + "v1.6.2.", + aside=True, + token="code_not_reachable", + ), + ], + }, + ], + "table": { + "title": "Filtered SAST issues by CWE (82)", + "columns": ["CWE", "Filtered", "Dominant VEX justification"], + "rows": [ + {"cells": ["CWE-674: Uncontrolled Recursion", "41", _mono("protected_by_mitigating_control")]}, + { + "cells": [ + "CWE-1333: Inefficient Regular Expression Complexity", + "18", + _mono("code_not_reachable"), + ] + }, + { + "cells": [ + "CWE-327: Use of a Broken or Risky Cryptographic Algorithm", + "9", + "code_not_reachable", + ] + }, + {"cells": ["CWE-798: Use of Hard-coded Credentials", "8", _mono("code_not_present")]}, + {"cells": ["Total", "82", ""], "total": True}, + ], + }, + }, + ], + } + + +def _sca_section() -> dict[str, Any]: + """The dependency findings, in the same two halves.""" + return { + "title": "SBOM / SCA Vulnerabilities", + "new_page": True, + "groups": [ + { + "title": "True Issues by CVEs", + "cards": [ + { + "title": "GHSA-2xp9-vwfh-vxw4", + "rows": [ + _row("Affected component", "next@16.3.2 (via sharp → libheif@1.23.1)", kind="mono"), + _row("CVE", "None assigned"), + _row("Severity", "Critical", kind="severity"), + _row( + "Description", + "A heap buffer overflow in the libheif library used by sharp, which Next.js uses " + "for image optimization, can lead to remote code execution when AVIF files are " + "optimized.", + ), + _row("CVSS", "9.5 / 10 (CVSS v4.0) — CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H"), + _row("Impact", "Confidentiality (High), Integrity (High), Availability (High)"), + _row("Fixed in", "next 16.3.3 / 15.5.24", kind="mono"), + _row( + "Reachability", + "Reachable — AVIF output is enabled in next.config.js:22 " + "(images.formats contains image/avif)", + ), + _row("Known for", "22 days (advisory published 2026-08-25)"), + ], + }, + ], + }, + { + "title": "Filtered Issues by CVEs", + "note": ( + "Vulnerabilities present in the SBOM whose vulnerable code path is not exploitable in this " + "artifact. The filtering reason is carried into sbom.vdr.json as a CycloneDX VEX statement." + ), + "cards": [ + { + "title": "CVE-2026-75604 (GHSA-p293-qw3h-jr36)", + "rows": [ + _row("Affected component", "next@16.3.2", kind="mono"), + _row("Reported Severity", "Critical", kind="severity"), + _row( + "Description", + "Path traversal (CWE-22) in Next.js applications served from a Windows " + "filesystem, leading to unauthenticated remote code execution.", + ), + _row("CVSS", "9.0 / 10 (CVSS v3.1) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H"), + _row("Fixed in", "next 16.3.3 / 15.5.24", kind="mono"), + _row( + "Filtering Reason", + "the vulnerable code path only executes on a Windows filesystem. The deployment " + "target of this artifact is linux/amd64 (base image debian:12-slim, see " + "sbom.cdx.json). Deterministic, no LLM escalation.", + aside=True, + token="requires_environment", + ), + _row("Known for", "22 days (advisory published 2026-08-25)"), + ], + }, + ], + "table": { + "title": "Filtered SCA issues by justification (13)", + "columns": ["VEX justification", "Filtered", "Typical case in this artifact"], + "rows": [ + { + "cells": [ + _mono("code_not_reachable"), + "7", + "Development-scoped dependencies and unused packages", + ] + }, + {"cells": [_mono("requires_environment"), "3", "Windows-only or macOS-only code paths"]}, + { + "cells": [ + _mono("requires_configuration"), + "2", + "Feature flags not enabled in the shipped config", + ] + }, + {"cells": [_mono("code_not_present"), "1", "Vulnerable function removed by the tree shaker"]}, + {"cells": ["Total", "13", ""], "total": True}, + ], + }, + }, + ], + } + + +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(), + "sections": [_sast_section(), _sca_section()], + } diff --git a/report_generator/sources.py b/report_generator/sources.py new file mode 100644 index 0000000..7dab0e9 --- /dev/null +++ b/report_generator/sources.py @@ -0,0 +1,137 @@ +"""Collects everything a scan's report is built from. + +The one place that knows where the bytes come from. Swapping the store -- persistent_storage today, +archon tomorrow -- is a change to :func:`collect` and to nothing else: what it returns is files by +name, and a renderer cannot tell which store handed them over. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast +from uuid import UUID + +from cc_common.api import PipelineError, context +from persistent_storage.shared import StorageConfiguration +from persistent_storage.utils.db_utils import get_scan +from persistent_storage.utils.s3_utils import fetch_artifact_files + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import datetime + + from mypy_boto3_s3 import S3Client + + from persistent_storage.wrapper import Artifact + + +@dataclass(frozen=True) +class ReportSources: + """One scan and every stored file belonging to it. + + Attributes: + scan_id: The scan the report is about. + run_at: When it ran, which is what the report is dated by. + artifacts: The artifact rows, for the inventory the report lists. + files: The stored bytes by file name. Held in memory because a report is + built once and every section wants a different one of them; the + largest scan in the sample set comes to a few megabytes. + """ + + scan_id: UUID + run_at: datetime + artifacts: Sequence[Artifact] + files: dict[str, bytes] + + def first(self, *names: str) -> bytes | None: + """Return the first of ``names`` the scan stored, or nothing. + + Matched by suffix rather than equality: a stored name carries whatever the producing stage + called the file, and the same document travels under `meta.json` and `metadata.json` + depending on which stage wrote it. + """ + for name in names: + for stored, content in self.files.items(): + if stored.endswith(name): + return content + + return None + + +def _why(configuration: StorageConfiguration, artifacts: Sequence[Artifact], error: Exception) -> str: + """Say which artifacts the store has a row for but no object. + + The bucket reports a missing key without naming it, which on a scan of six artifacts leaves + whoever ran this with nothing to act on. Rows outliving their objects is the ordinary way this + fails -- a bucket restored from nothing, a lifecycle rule, a bucket that was never persistent -- + so the answer worth giving is which files are gone, not that one is. + + Args: + configuration: The store being read. + artifacts: The artifact rows the scan claims. + error: What the fetch raised. + + Returns: + The original message, followed by the missing file names when they can be + established. Diagnosing must never raise on top of the failure it is + explaining, so anything going wrong here falls back to the message alone. + """ + try: + client = cast("S3Client", configuration.s3.s3_client) + missing = [] + + for artifact in artifacts: + try: + client.head_object(Bucket=configuration.s3.bucket, Key=artifact.s3_key) + except Exception: # noqa: BLE001, PERF203 + missing.append(artifact.file_name or artifact.s3_key) + + if not missing: + return str(error) + + return ( + f"the bucket holds no object for {len(missing)} of {len(artifacts)} artifact(s) -- " + f"{', '.join(missing)}. The rows outlived the files; re-upload the scan with " + f"'persistent_storage put'." + ) + except Exception: # noqa: BLE001 + return str(error) + + +def collect(configuration: StorageConfiguration, scan_id: UUID) -> ReportSources: + """Read one scan and its files out of the store. + + Args: + configuration: The store to read from. + scan_id: The scan to collect. + + Returns: + The scan's rows and files. + + Raises: + PipelineError: The scan is unknown, or its files cannot be fetched. + """ + context.logger.info(f"Collecting the artifacts of scan {scan_id}...") + + try: + scan = get_scan(configuration.database, scan_id) + except Exception as error: + message = f"Cannot read scan {scan_id}: {error}" + + raise PipelineError(message) from error + + try: + contents, names = fetch_artifact_files(configuration, [one.artifact_id for one in scan.artifacts]) + except Exception as error: + message = f"Cannot collect the artifacts of scan {scan_id}: {_why(configuration, scan.artifacts, error)}" + + raise PipelineError(message) from error + + context.logger.info(f"Collected {len(names)} file(s) ✔") + + return ReportSources( + scan_id=scan_id, + run_at=scan.run_at, + artifacts=scan.artifacts, + files=dict(zip(names, contents, strict=True)), + ) diff --git a/report_generator/template/cc_logo.png b/report_generator/template/cc_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7b7542afa20f8aee1eb7e15ba092defdfbd7ff20 GIT binary patch literal 60351 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

vqX%tWV0hOC$fTB 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 H>&|}wkeC3w literal 0 HcmV?d00001 diff --git a/report_generator/template/fonts/OFL.txt b/report_generator/template/fonts/OFL.txt new file mode 100644 index 0000000..f831dbc --- /dev/null +++ b/report_generator/template/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/report_generator/template/fonts/SpaceGrotesk-Bold.ttf b/report_generator/template/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..15f9ac2de5fbad01b049b76eea09fdc1749cf9e4 GIT binary patch literal 88200 zcmcG%34ByV)(2Wux9JWc=_H-*B!r~tq?7JWXJ0z|+SzyZMG)Bu$Y$6gn~Df7pooZy zsK~hMILe@diVou_!-u$z<0v|a%Q%jX4#Oy)qtJQ(Q}_0gfI8pry%(r^yHA}ub*k#r zsZ&*_su^dDMdP7kthu4FY3I1RFK6uUXBp#fH;?KbyYGWNrHp;~24fdL);xB+ciz(( zLl|#e%b4**_t@M5PyQvp2JW?>GJWj0x^b7^a%n5$lcwUkd)A^EODAqC8p&A97RJJ^ zoi}6IQpyeVzrj0f-onf0eqV2mV=UzqWB0t!GiSzZ$FRm7pcjGo;vN9Prs>Y$djr0w z^(cuBl5*77)wlI%<%T28GTEY@7eDW-vfNx;u(wPM8Ea) zLyZ0I2^274>5^s3KV0^||77gtBOsT#w0F+Z9{rYUklqY@1EabSjJ^3y!ULB?ResA9 z1^F=c{_fN}=>6GSCtmCSrSAjXJG#Zd)f2A#LptSM-v^8hVg0{6m#2G2Qqm&nrTm>B zcur+uY&Os+r{JFgN;*+24kCt$>%}BQwX1JFjTLZv5S6ie8S5X#Y-{|mrgnVV!yRp@;;!hMLJs=fR0p2ku|2TKrpShZNdOv(mk1XGJ`s{|iwhKQ}28fD5>oE%BQOk8i>E2OSqs*xYZhjJFJINaO6RbhBum+`% zHL&B%$hWalv5?g%XHb@ImL!r`mTV7dlb;BR2l3Qig9EjnKTtdVT%b1g2eh@=*8dF; z>;E2YUX1rk2(N4JlYkW>Kpy%}N_jvzNgjk1-)EzZ=`+cNKahOCRzT;KzRPp z)BiGLm+|oa|3K!;@O~NL4TM(_K9c<)y#GV=g+FM0p@h$-K7qLVAg714enEXh4IbG? zsGs~qP{1$sAA|&iBI-v2!VQS)Wr@_c0{x5n*v|yn|3Y!75BdZ3cj7S)usBvGc7R7O z=>LMHqVL;8IocF~ABV0$UleaZ9&{{ExDb~L{??*zFGL%VOsofuh5bG;0XXY{dxj+{ z&mvSq2b3bLVP@qLyq^TD3Gb`%{YsXg>Ijd=3qS)1<{`;!T0#)A;q^ zXCW&@$U<0+K;J77$`OdiMDBu4q40(l_TzmB;dX@G2sVUYF&8_HcMINMBi&x;BMJ*y zEn|S|(Y{3MQf6jHknao7Np~@u?C(FJ4}MBl1mf1CElJO>@Bc>A8(!UOEJt?$@Qn!j zzz69L`mDWmdk}v;zW)>bo@Fn^zGM$TR_wQ+5h(IM*sU~bffqj^?jib zl8*GJKLm8BUzhsdq)(|I`vd7vwO{{t@BafX^$CBFx_VHa=-(I4Q^P}lQpfC&Je3bH z=HffXVzYP)<+xrQ>&0Ve@AdeM@c|)$FNQu`%o6_l8$gF?WBUW4>Et-Fkrjl( ze(p2+Yye-{ACI#%e;VYI zusnME1L!yq@&n&P=#gC`u7s?@#u3e0UJt)D z*lV!d6Cm@&^!5kf6d*MDzr$WLk$orM(8=`n2jG#8w)(%5ZpPT*5Ybwodisgaq4i3i zrT%&YV~`pe@NV$ibyV9z8;=L4qdK857!LW9_Mm*LHo#M=4FFq_Y~gT}Gn|~of< zIGCH!d?097kUfw98ZPACC3(uB??j)!16c@3D`AhIPE3fSu$P759ZrG4E@v-d zZh8WBaR##T4RcWaK!zD~kQ=sBIYJmhG3vd50_r>*bso-Vpstn%brw6t(h%~1Tfn#U ze7xFN27uwrnCeKgKHQtr(HFW()e?>;U>GY!jMS$#)}g@ZBFkgZQ%3 z_dp&*1M*ArG5Kx)-z3jk06xuuyfQA-j=a!k($N`0YSn1Fc8k7 zzKfZ0K%le)`)}W{ZTVW%F9OXq<$TlseJSJ?!5=^P4c^kUKz3wSXj(rm>W%&cf`VYi zlgqa*Y&qT^vn!dN&18`*mzA+vb`hJ-R8+t*e5#?Rnd8Q({x%Qfw)yDUOt!lyNE3Qtn6?Iz4qx`cdcKKPvn|*WZt_Mlu`AW96)lO<^q2Xv z^-AlF*6p&4du#@j(PT@oCE0AUj0LtbS;nQd<+gRUt+I^2uswh>K5BasWqihVa8Mb? zrc6oMhB6L88Dmh!;ej&FMj1c`^jG>` z&b{Tp2<&V@gJdgf!QTlR4vpOXb2LDEj9o&4%!t3S=j zcRzZCK7WEWImSMER6aL<^!JbTC&Nz~PCoI`!Vf?F5E}Bs4g`#CC&AB$IUlBfC{BES z;^h0+5I+7$a!(?h#Ya7Yk&|9v;tlbZcpK8FSHhGwWfZ~aPw7&|DHD{L%0dOQuB@j3 z8;@e-Q+@(O*`#byZdA4@+tIQ+l|9Pi%0cD0a#HzBIi;MISf_M*vC{XD?s45d-O~h7 zpLp-n?bp4adq=|FrZ-mcj^lY;hjqKbf4XCc0o)&g@w&HkGBx_=WY)?&dXYFZ(5Xoc)SD34Q(o+T}&8l)l7X zW{27TuvgeI_BQ(ydxyOXUGoY1lzqnDWdCO0vNP;|*>~(eyq2Bg;d~g6Kx^1B|zFWM^HbGW4vya#f?5}Jq`y1QF{?2Z~*n6Y+f*s}K`_e_otoU$X7& zAN-Iw&2C{|v7H!UZ)N{vx3PcmKPX}BcFeH9VZY@`>>l<#7D>;tJ?sbe3)nXIv3~v; zr0sr)<%6)(AL0sogb!g4a~(ge+`t~?VeBzJls&;C**+e{e$AuVejdY~f?e=5k7d7s z^ghGm*t6_8p1_{xiR>UB&JAKRe^<%iKIK>ZYJP*bg1^9D6zBNM{HPMgmkBF>K>U>- zQ~@)-Y4d6@r4d4zwjJj(ABFY=F+O1@51^F^YNf2>sV%LPUdQNnK#oA^$# zgFh@j^nW{|TllWxiIq{$}U%a3^%P;2B`7}P0U&JpFNBLYaQ`v>3=T5O&tbhic zEKZ84Vxm|q){5m~k(i-4mFtwv$_C{sewDa{Zx+k=b)t{oE_U+U#I1a*xRT#3j_^I= z&-@qQvu0lp6l2Qp-;&`TRpAkAJR= zVGnT5zfh*}e=2kMDP=DIQklX3q0Hp}Qs(h*lpFcC%5I@oo)JTor^Qg^1z}JQiZJDQ z5w5%>qLnxJYLU;^2oGN_Qus=d&Q}R1?-Ln(g|PF*!pxV71b&%F$^FA;HkhuF+- z71#1z;!b{}xQcHRSM!_2M!sEK!*3EB_&)I^|CRVV|C8e2e^y-lRmI9*S8V(ZC56AK zr1IC4k^DWy&EHqD_y=Q!ywNRA(f+2GiLL@d4{%-E0gS#YVHS+|JY3EM5N)tx<@1a-Q#pPnR_?OZlUQ?!v z9~7ZnBEC_6ql^{n#Tn&*lBgso*C?CB?cyswhF6Oze6;vZ8PBckcj9h7i_aGKh23N6`mOp0^nW#QLzkI;eEqbVXm-?!`6g7752l>_@TW+4-9>K=&5j9_^9y3;Wvcu3V$&Cx$vXme+)kv z{zdq|hlycP!{Ue8hm{Q*KWxUZMZ?w&yJOhX!_G$3M@))X6mfaPH4!&Q?233g;>F0^ z$V(%iiu_&VtC85|t8_6;%@Djp~e=6g4Yqan$8e8>4QD+7PW z9p-z?kC>k_A2PpUe$V`=`JWcOCB~9$$+Xm3IxLecvn-1(ms>Vkwps49JYad!a?tVz z%UhPe#1+NW#I?muikls`ByM%wHF1B5`!=4(r^V;SSHw5PkB*-jKQF#FetrCP@wdj` z8~@Ar1M$C)e?9(q{AUT)gwBM?33Cz_C0w6yG~v{6-SCmauO9w*q9JiY;^m1iCw@Jm zYQ)wNPmOp#DK053X?4lK5Tp1_JZxO?KRsw zwiC9`QW8?iQ>LcOPw7orld>`8-jqWr|4jJ-cBm!Q1`D)2wJ~*E>a5h>)T>grrQVtP zVCt_^f1CP>U2iwpt@a#yr+u=0j(w@U&%V*V&3>o-0sB7t3-(v-AK1UNf1frqEg{XG zmYY_Q)|@sWZBE+Kv^8m)(zd6)pZ2%3(`ml+*!0r$1?el&uS~xoeP{Z;>5rxVCjG_q zH`7n1f0=&9!5tBfct@%u$5HNRbc}LLar8MhI<`4>J05cEcN}sYbG+;L)bUTp4^D$K z);ZFd=`3;9JG-41Ip;h3oSU6Ho%cJRaQ@c$y7PUP*=2KOxk_CPu2HV3u6eFyuB%U!4oKdyIOpSu3(`XR%RVaiC!$ji7S(8CPfAl5tPQqZz-+croMEjCV8s zmT|^C#BFxl+*$5YcY}MBd#by~z07@u`zH6@?nm5DyI*v_=05KJ%zfH@E;B6CoH;6U zYUaGm-pqBGTQcv=d?545%!8SK$b2XBubHPZf5_5jS+dfy3bVXfqqC-GEy-G+wKZ#Z z*27s(XZ=3wwXFBDKFvCnbv8RJ+njC7&dM&y_GWixPtIPIeMR;S*|%l?A}2a$M2Q_~7RJ~vIS@n?WwCc&#H&)+QeW3cYny8w_n$a~=YkF!{)NH7ExaO&vk84iVn(@z8 zn^jv{ds*$}wHs=W)V@*sUhT)VU(}wi{h>}*7hN}^uB>ii-Kx6V>i$yqdENhd^aCApHsiC zepCJS`q%1@*Z;l#y9Pr;T0>34MGdPOZf$c@UN?Ja^vjAC5<;W z{<87+jel-ZnsS@QH?3*9wdq$)&o;f>^uNvS=CbCN<}uCFnin*$Y~IwoyZOH6=bK+{ zey#b-=6^SzZ5h&%+EUZf(lWZ``j+i2ceb2t`Ml*{E#J3_*6`NY)}&TI zv9IHePJO4PGo>@Tv#hhRb9Cpr&fT34b^f~Zh0dd$?{t3L`E}=ix^!L9T}fTeu7a+r zu9mKGUF*6Y>Dt%zY}fC*j&=FEOS)^io4ZGKPwt-CeQEcy?ls+4ci+(cT=z@euXexF z{bBcq@bkE&f4!2c6tO5al4Zj3V2!B`PfSs9VM;-qCC03`r>7JY1K_Zyq++Bn$CV%+ z(TFwco%mEzQe5moG-54we5a^53nKLfgB~!RGI{>|$-IM}zN6e$QBqRjJBjC!r1bP8 zgpMRzgw>X5Ps_U~w>vMdBg>kT5HW1%hPg9^W9HnsGtV8LId^((CHGa<)>isNW$hMw zQj(p*p19PU$RUwQ!$+jpQ=`o}-ZXo)TX7bS3?HJ4iHgGdi3#(}9(X+%*f7SH#TLfe zopwWPp<*{3`0T*7j_)0f2gjSl!E+6}c5yQwk9i!e;x0yt;mppmk;YRPWA~sgv?5uo zLYB?$EJU?Xx$U07r<|!%c~9*np0xb9xT5spOKP^P&GZyytzDhrDRQq~&fi|1*J=~FjnQ}q@X>pM_infsLpj9;0t6IV4IGI@?s+V_c zxc&AGS8d(U*xWQ~R8w=~hONe%H$Sxb=B;b=;nkxiv^~~3adef*xMnNr!C$IK79Ub3 zDoIE|h<)4ETW;A}>mAkBHp)v3-?Vk()|>jqb!_PzOEY;dY9}1EL$w)`V)6g;@)vzC z@>Jgm-XIS4J=pilJ{1?MGY8qw5Zo9qKj1sYlYE~7@8Le*UqZ_4lx23HQl)gsLJuf4 z+5v50Hws?VvZ7IzePnem3g6P=eSo`Gn+zkXjdklTLLFSULKl`^X{Sn<+)*CB)VHfX z#*yeRL6^S-g)u2HXkR?N8@X=dM&DU+(DxxvIoH7Lk~fr>9R@bmMwS87yOCrCJloJt zrGQ(sc0z;5mLXZO*wJnHu!UD_EXvKw@8cb#RvK3>@OpDSMOZxV>+{{XXqlfAk~DZX z5$(d*LeA}q9hsOudD!;>&w2cFh^Frg9_M?|cNf)}Kb0P-hN!7hEa1ErTpjGwVwF6^ zM#)&nJCzPZiR`zRn=b$MN>ktQK9cym#msXJV#>MuDQGP_6)9!QQaYrhR6pEc+JF#9 zG4tHrkSCJUUdU-A8!=E)p%b)zLHfWP$H#2ky?Y~twlQPcTE~u6WmF34$_Xt`v`ml> zh?SHrFE~(TG$zpJ?Dm+LKo|3Jn|DrG#hlvRH|3U>=iVd^dZv4gbu&EujQ15~hi4a| ztqS@(0^B)%n!i|RGY?v?u+VZItE=*>u1u=9FvGr2s|p+PtaCNKJHU4wRw}7iXu85+ zA)O(6?!W@%d1n?^X1TI94o^OCHwnev;$Y6WvWS{4lSvf1|N2&f)%P90vd|swE~GY1 zMO{~;u8aKr!!K+_4u{j3BZ4}YGLt>V92XZK4^0In%sr#mHf`v_Xqr-!9p5sqq-sff z`-=JvBgsA@d7j`5i)zlQblhfkEheYR(s;!-pUpS}3YIWtuXFC(q z5{3?mE*Mi=JEMen!j7mYLYWOzVpZlpJtmTliH5$ua|a2d8eGo=My$p) zO;}U6v9+9Afa#}(3T z9*sH6nXhlzVcK!a*Z<Rs15)IXSJY_SIVoML>Jmnb~qY-&2c9^ zJF_>*H7-3bR+m(1_0(Tsce#z}&4q=H_9T0DW~$Rsgh}CTVhOW*PDi= zS7leut*)Lsv%IUbqP(lin3GfqMfQWqW~t~M9X-0YVsU#*cuTu{kgVh&FBi&;jFFQZ z0i+cXZ^#iSm{cUBUQ6Pk`*T}XG&HPeX`WjXzFL=5=x|io?NyGlim=s@HFJ%Xy`znz zdn*ejmLyv9l2Yn&!*lCWD)N#&6S0AgaS%C?-u9O&BrmD)Ja+wH1!k<6hq6>=S2@B> z>(`oMnyvm~X@#-aOnk_>hRURz&|;~1#iLZiNiPg2m&KA2=P!?f5}=IS)pl7;_0qQ1 zg_XYBL`#vUAab73GwJ!ybyfL=mByN7T@hW&YU-ACWER%uBo!xCW%8QLBB%t;Xjr=) zWkP?Uz6eo)yjbk177Ly+x36DcmA_#^(uNKE+McO}RXx_G9v{XE;;&Scg1=D+_%4xh=^Qo>rMtHLsfMwTd|t-L<45 zFR}96fx@cA)~ljZ;wqL@w$+C>w6->c*S8VR*ku$B*$(R1r%4qCE6vbpT5k$VEz2sO zn76*9up9%N?|-r5;LBiv- z$HWehA21!$QZF?GdR6)`)252fu5##!BjP}1a<;!$rNsF9xhq6b1SMAOy)n>zlmQux zq%NfB@&Q@#XHDS_RU6H#XlRCd@}2c(#yJdFEwk+8%5yCPGE-}xFXgA<2@t4#>QYtqs}kMA>*8F&9_{weCht!m5?E0i_OjkHmkcr^G2Xl;Dv<{4`v zmm5;@le65etfZm|MGGg5T^zFl%NA)4S87t}6whVGaa~rsD`sST{P3_L;r8l$OfxIf z9Jb+c3Gw4DwwWP%=;L321#1rSg&N6EU;#|I>M{7-4>zdxIHPs^3 z9Db(^(ezeJD2kDqM(Yx%9IXxUG^0!0zi<0xrpvbP+rI;^@6X(S|C#R}d{CCHRF*9n zV`->H8&F87yS3by1cg(c9VlsK!I8z*bup=Np`|5_2CAfX-vJ+TOi!0%`VfriMLgQ~ zJ>TX##S?aZ9Nj~KdI;Id6aFh(xnb?joom(2G4%30^5R6{JvPA>Rzt7Il%5 zl4>DmtQVT88!@d-vAFZ5c_mQR1j_#U>hB+mu zSIE1Vi=4tqnr473OD6Yr5M4B;~Ecc5T%24qn(C|O)q4B7YFbmO+!@s=t;Wg)jsF~fS!vPC26uH+b&ENz zXjW51CGmwKvu_wNzKPG#T@zR7d31q!~)sUQbq7mPbB#WsW<{og*Jq^DZfqp*3HqEu=uZg$^Mq>q-Gu zv{Zwerm%55i|QN;k}al~#)irs)cGjc7H#gTl-3$lf3;Cod2^LkmT2t_aROzL4 zb*0n*sQIS;a6SS4WHHjZmn&^Fs$gXeO8T+vva;+)){Gdj=1f_(V$CjFXg%k)E~FNV zMmn384l7PbC|91;HM(d@@(3k9v(PHuu`VpjKKpTY8Kp}@x)+hoE?di=EHq!e-A=tx z<$xz-myKAqY=mSZ(;gjd&y>vY;^e+$UQ8VMzD~_C<)r$)24gB;kE`@2s(jIG1(oX0 zN+T?bsJDYtKUMIn<^-O(Nz&vdV%>KXdHIg2{1<@#O=`JipTW{(aiN7qVKgD%wB+{A z&fAwP!rz&$J!^T~+C5zh*InZ~ea$-X#*lWLns&SgBQY759-5xh40Q3rg^QQo*4cU6 z86@DwYt}6U0pAxOz+DE!C;FKb##>9Aq29;)(ID zKch?f0jo1u!wAGcGmk}_BIDPu=ZmrM)pBk(JqOZn1br;=9AD2JG6UGP#r!&U3KpyZ z{v%8zsStil9Mw5h2=xAV_JKc!aCK2^Ja>qaL$H8>vv|9lw`Pyi;W()+e1Ocl{Cpt zkrE??MO*VT+q<1PF)okCl~J_xmWCW#Ub?HGFmu^bnK$y_KD`4THIz4=yi4Ut!ksDv zo^j)a%Ij(Px<3J437=xWSJUxt@WyE_;#sya#*bBKCqoG5j#Q<8yx#C;+7^ zrTozPNJs6j$g+~i7ZoEN%IiYfBm6C2jyN1hh#YBbOn~-GgGZa3acOJknvM^yo-pt5}$<9kIvZs}$Y-m{C(!8WLtKJ@CGFH1vN?aMm#h)bHKi!bz#Yf-&Lr6b`WrX#ftN>APDmv4Bl-*1!()g}jt{ z=_;5U4cv9Ow^xM=T`2re0In2;z{M%RAzw!JTXu}E(?R0;--TxrXQgZjd}(f?_FqL4 z5Ow}R0;*@pHA3P<0(`sXkE;+Go;~e9h?cJmASop@@^2^Kf^Gi#QEIM*s z!gYUGIRD_fw)ra@HepG#)Guv}Zd&SvKSHJN?wFX8l9o%ZYwNgmQS_pnoxMD#!yDo4 zkPnnB2_3BteJqvM7?x>uRpRllYRTyoNtu79;-sO2R8B)s>WsZz&+7hS1;4Hy2tE**5Wl2$KMr34L zWMoEZQAy>HmaYo{`BOiG5RwXV1bruZls@P$9qA$Q3Vhs1PYs4=!n@{txL(W}AB3*f zsOXhDrw5_i{ZO$o1b3Prs_@H#xPCtLqALWt-_MS7pyJNjP0-f?EmKJTB~-cd!5}X2rsA%YxF><@QmF1!Tz-IZ!=?)RlM=*T zGmN0#{s|15k^x+&mFTJzf;@Cd3bgw-MskLg2-L(Zh5<({2mBKW8zTcsC(9uv20L~d z)F3Wa>#hGJBwsJNBWx$mFg;4C6mSX6iPjdht29L2KgCX^CEb}Ge=TDXS z5BB|K^kbtX_rMO#V>OTTou*O&K2>sr`35-6gh#I*KM02e&J9ATZV4N@MQR4Q*C%P9 z&r7|vCm;{KA@EFi^%9kV_>hMMK`7BCDxke02&J}=P_Z%um)cF{sBm}%2eGMsRZc#F zKl6p)T><=Gh+ZYqPhGhRh9K5spt;hd=EC0P0L?|iEDTYaU0?{p?82|Vd^7j%+qZY= z^yy1A1$N)A+B<)-^wNtvF1}Q&!93)!x+T6_-5~d!+<3jZBi^TNkzX&jFo+)3?A5(~$Om>70=xb3 zG=rD+zuo)8b*7C7yQd{xS&4C5a~Qz(|B)W6cT!O$=%hgpJ|X!6zpz_nQkpW*;F^spZ)R+I&yyZlgvbq1j%sVWzp?Lp`#8q}an#>oy9K0y@8c`;$r=)M?l z9hC+C^*;0tJJ#x2w3&r}t`f2ZnNE`h)$ER!lh{Gq&GCg;WjBi0Lb-P#<~3hhQ8BN+ zWwyunsHiO}^h919>*?@K@zkwzWM(H%T(W55rh>ejg7Brr;z`w2?FmV7Lz9NOT3>#b zW9m_EY`MI(bwz`BNqtprb!J@sh=P%GtMU!W*ms^grlYefIW;FMg*K)g)~BC|a@%-W zZgB~fGY47?yTmBJjmjxz;B0{k$L_IgGYLoQJa+-OUd(JhA6qY`hCoSUs90qX~us6oB9-BePH?I!GPVT(sws|}rR#bUO>3p-bOu`*oP5O0dTVulM# zP2LsV&Pu!7Q%fzsqr%8*S9FB8FRtc1bwu}aoO|2)+oiKNH7rT>=IWX|I+}G&o&1T+ zKW53YpB9+UU@ap>mi=OZ_SE3mnFQYwu3U!`Eq-{sgr@=@a@>19l;n8d1+iz!ae?;A zLupJ6fs!1nlvgwbDU%#asDdMj8h4=-I#r7r)+qMHHC&Qc=-mJ|wE@vEh}GH*MDA0| zvZTqf%$%-bWA{1)+POLiB^rcFG+-~nj(Zsz)FJgSGzscs)?I{+KiY)`wuCCzy-T?N z0r*XVYP-ru-z3~J*pe|pD4!dn(dCUnsF+D~F~euPNI(Oq6qH>L5%g6+s|0FJqr2jM zf_?;OhCt0}xa=7~SrM$!zsf!AFTjn1cD6BkI!98950N4>B+t&@5cCgNv22#me z{T`sPCMqFzs*qgnAfLbM%}@PC-SkGTB}ft_YYBTspph9xynqHyB9ZJ7lAwE;&$U0+?!%dta}65xr$eZ( zX8-VAtHQOmLBC@pt7@;7C8Bate*}+e|5c%+b0t*SHHJ9HawA&3$PX12{RAbs#@-L0 zS(3uh_2)w=zZU{h8Hzo#Er?C|NgB#7Ip0<(Q{PZ2uaI1*Q0f~hE)ED@fQyrBE(GnA z^VppJXTWh`fWiics+&&ZjFAtT$wQ~LgX>1L6)tzgVH*S;O|W?6JkPr&UDfG4vw2y4 z{j%ofnaL*W^vVHFC`A*!^|ObYl4sUx{s6p7I;B+k{Q^AM*`83xl;W&xPl%Ji>zPHx zYJ1}R71eq!)jFRmO9so0d{_KXG4&MHLJ=fuE%gbB3&}Dfg#sZ)oZMrVDX@kr<>F@9 zP6OeP|K#(rAuSrzP5bnqPc-ym1_n&pnK><%VHHlROJ|8({iLNer&BIi1st8R`2ar; zt%_-_7W-!AfWzG*H52lO_J(D<(lSh`3GS?n6kCF`Fe9bTy}qHZt9oXsHGf3d(8S^H zdPm+x#>~2u^i+3-)jT3LEGoJn#hx7>lRYvyEv2K**To;d=!*Kxj=apOOw*8XgK_B4 zD0gjsepfc}3$2NhZUkOLM;s_@^>T+=wL}>o1{Gu z4*5-@x>lhy%1LaDayKBAPMiEA|kFm4mfG;HK zggNQ-J4#=MdQFxi#wP?9Uu$sEvun|-^@3I})IIJbjD05JO`DHkkChhF!Cz085M4~ip(Z&ukiDeN39+=L74w(0rw6v8^9$_148!~!W zyVpA=HpQDb5{EwH+|l-oQE?-Z__(qXPgyjsPB4#fmDM+1ZD~oc3{5n*rKS&yk1;_Z z3fwC(4x{H`tejy+uH`THm}oCubrmqj9UnJ$?l=nWf&w=JA2(ytq%9ba!p0<2TEsX+Uo*JJa%Et2K7{hQe1>m z0o|#^oSJdj!u+C4x63o`l40SN+|fl77A}lVx28JFGcOu#8ai}NBv1Fm*rJt+jF?2@ zqRGaHtYVwZni3w>)sm1Alaj43XmU*MjEgX*nyrbp?h#Cl2sWQMV<)w=c1JdU5Hj8eYDzYm3d6 zSyIlEJX6XeDyI~}oqoc(NBD-fOLO(Pr8Ghp;FdLOv*3 z1gizK=@syFha-VpoPdbhxv0FPq~wxH_1QjUN;`rwKHU*(iHUMsax-J1qhm6;8}3zA z(>&_aEs=m&b|NhyHaRSun`6o=Eu&YNBaJ56FHXo4B~h|pU|$C_eh3x(pU69zBo>l- zb`%`vDR|xC2ism~<0noWIDr57b2tuwiklzj--tsfBg#j+L!ne?!!=ZfRUxKwlJvrf z0pmQ0uKr9&HupI5T*HA8ZwXIL%e32bTp8)ka9d_}FmIhRedy5GxY$UeQ6~&ZBdsa+ zwD9n>r1&slNK6@-4!&r783!30L7tie`XCshl2Je@EisVB9SiZ-yR=t*EnWKT!letJ z#h?1(j-{kW9IQ(;iN%mUta-0;mZ%*PlT)d~;;|1Vi(mBJ0$@pNhSWNW2A>C1*yvbGTaMZe-b$E9D7!t6FNFxvf=)_%W{}xfL zJJE+!cPbVLtnt!)-p{W4(^!LBJlfbYie3EfBu^FCAm4JvuE?J-pA%H=vltJr`nrWT%5Of z?qj{s2K_$Jqw_w)AbF%^W1O0wq#=e(EDGOPKH7PQ>5iQrS>OkJ*jLUE^I_rqLEk&v z?VB9#`*y+Hxx44mnUez4U6z=Q5ej2tsO1k2)AN1iWla_Sl;8Zir9b64KUcha&V?F@ z?mvSP9G3gnP98tDUW}*@FA;Z?V3#7X|KH*R@jr+oPvdM>f$;!1N{Bu??`+Ud#O>%1 zi5<&3CQRsPn>3*#IVB|-!FSIfKxCD;qw3s((M^Wt(W9FUO{2%8B}XTx$p>$_Aeipj z0k6uQ0ld~=;cd$QJB~SeDf&;ywmU7R`E|KjFH5w!zOAjkzM-Ks(Pm3T@ZC44MEqOR zOf4=Qc=hm^*9tkNNl%5;H;oySjvI{9<>N;R>1%oIHQeCj#~Kq%3;PMJsK7BrhnX?# zBnQujiC_@FrfnxJHfeCIau>XQDAtU$m&(}fYT7kw+HbYA-zbgerQJsUHb|S;|BZ5+ zID&k!Gy!WaA9uSTwX#iM_AYb&MsW{LEw1BlX*qwR%sVgV>qL}{O#toL6f3Y3M`h|G zzOEAyGByISW|?-O8Vgm6Slk#&7SbHHRTxArSxBpB4AHdh4?B0R+_`h>?Y+0-zr^F| z!hjpz;7>9ZeRU4s%hTb$0SeRE^&&zPND4TTUqYG{I`zrX8#j)=<{J6De)QE>`~QQ& z9M+0Fm*EqQw31$^s}J=WRo9sH$mh+pf`YV=K>2=9Xtx(q;OqULpcm$_*TCb~po?`E z;&O<}sc#y%UEACHxb%RT%{GWI+_?)J^0u+SgGPk;rto%=h}~e!mcnFesIuI!7uk2bo06 z#dk_gGY1I`B1vk{0c}OOsqN+ahVR|Gv3Dc>lWz@X_52}u(uC?mJOA6X{owX3;GaLl z&AxxU^pb3kOF?%`HK;T7sf zb5vwFYAKUlA!z#QKwaDT^|)h4wS8&U z^`mz0%Q@{?S?xLMQ#-`Yug_@B&1uQVXvxiK&6u8+lx(*rCt(pPlwU<(8RErndQY<`<7uuN>E&#K0jfS@%;ND%oE&+P#&jwsLE zNslAFR&iryMX&W-ZzjZ(GaK?6qfC+>3ko!;Jc;>o>7qZCM{m3O5$^8wJ)Lr0@?k(FP64YhF%9kb+Xj_l^R6J>XAwC|AbP&D=Ca-_p%Bx$aR zIJHf4vvU63`m@q=RxzFZ8siAr(G6HT*W<=dIqncwSX<+hg)=Hk#V$y?)WEptTNKZhtDP>9 zMk{u}?9@_@VBd*PKxuLyC-_<+{-pNZ&5mLP1ZUXP?^qYLF*Cl;(7sQ=eQo%zIFYj= zPehP?X2QNd_E0Wx8W2lDF`l?t9cLPK{W2z}Igqp3J4bBYTAq30 zkr6lFT%OryJ=Aw?W<{TMQr{^E8%`3btte|5yBe;x8y5)IjvZu2@M-J>+S{NMKq#jH zk_h=8C5`}cmj*H8zN^s^2Q~=sv6qeKeac+WgGDiRP&Ij`<&H>CAAz9u6uu=bF)@t- zI4J;Zn|O(AdZg1%1ZvYoIFb}hH4e?sscWCso{>YTb4+RDyl)&CRg(V%)u*jLhp%IM zP@5P-Vc~vjMwi1@ zm>idGE6pA23G1HSmX(~GMY3#ySXkta&Sj7g`2TRs1XZ}`(OI#Ar~_OtMoCW}6LwgG z9*X3d@{)`V@emA2Ge%8=LiFes2PlmTf#zsX2l*Jn(z_Fu(tdyLk}Ea$X#sD=OOn--k#g1Hn% zjo_^USZw*J8%VsoJoNsNXCI;aM;>{I?jM0l`^no!zzzAIV1^2AFmj{4uI)eFzZiRq zu-|EALhqoJa0x#^aMgEHwxb?!C$!R&6b~;mXkN&+7Y}G&j0bYQD_2p_vx8Tscu&!F zBMTSPbtA|)7gNgPIy}8YMKUfbZAvCxqbZMgMY{7xoB(*nmpkE!3Y5dt82w z`EZN^8fQA(KH%pV@Qr~ygTACxe>b2+@W`F&FV6(oAHo22h*`B%x7mEVlv?s43r)LL zN)C3mYyD|6&P)3$pQWZno%G255K8%d{2~SQ7OKZ^*&jk;(I46w^#>U%+ni$cqBldW z4{#$ThoICSAjbi0>JKXXJ>1$Egj0V|;S=F7K>b0@+oZuAA5qp`2xo>7X` zTAFGZ6I*!oo(Sf&TB8ULH0eu2K8mV$G4>QtifO9ntM3EN7nz_2u?TmRl3hbSZ%ZJ@ z7*Alod9=f0bgoxjQ7a+unZTwXPB+qipnsi;KR&HzgTrb~aU|wtq>mgqXCrn3H#V*w z&BJ#XB22?l)5=n38t052nKCjVF=?2^RB5-DCryqX*<6h&*&b{F-nYpRIV^nG5F<7V z^EzfxsZ{=_p;VGn_{v=!kf<)%4zTu6t`6xHL9!BrQtP}f%LRGGy_f3VfO^jn-SJ?_ zS-hA}U%Z&E|5@(KS`KlLH30uotnFCTI>01ZUFXM0iQ8^yDK101@n+*n-{TvluT_3Y zNk0F=7q@5`JiFHVAoMyqbt9%JeUj2}8uO?|&!-mf=gJPHN}gPA`#re^QxAG^$#O|e zaIdU|OzB@Z7}{k*O`hjvJdHeu$?uBa9YSH+nF~Q@5wstY&SPbdh>*GUUPHLhF8sz2 z3cVi&DQt(9Q8`;FDZB*S23<8;lPEX@yqBfa8`)t$FBB`&Aoh$J+aqJuQUxilksfP> z{ciRrDF-S~Yo(7fbRV^MAosNrcW}MDA}t44cks!ko~u%L?`z^;#RXL3|GgHfffK-W z%h8Q+4Ps*d)3P_K*y!aF8*uEiP%l^EkY1IF!l@YP>g>37V`JjaPQ;* z(EUpsd$EwB3(1fF?xl_D#Y;g!0X=zhlWeU>$!jLZ3aSRrlkEiu7OH)o7l6+VfzQ(X zLcGuwi)9TPCH^AZpiFeU{ik&kgO}-j(StBHNH~AS4+kB6eN#a#vKP@AT8bh!n)fh$MZj(DUg&C{T}`LxNPJZ@5;sr|Q>8Dx3SWa;qt(*72jH*8P0uR4R9PvxSMzoH>4W=F z`u&=Z?_gi@X#xN6E9K}0`<6z=1=M2MSkI&Ji%ygT{nE*beGga|83Sp1&rAC%->RiO zEw;;%F_iNA_#Fk6GH$(+BV$M`M#dF#WE{+E?^r6agzC~zb7~rRxWj@Xk9O+YYHXqw z>%^G~;#G}5=r4=(0E z(kG3h;t$N1^a8;Lo+lu?LgjKIshZb2xRMb-j7-Qsl0w;81 z4D2?&SR=hWWvnh=i*=CQ4ZhaEe!hoh$7E?iQd2ZdO)EXB<)E$Q(Y~tc9soO{@v7}u zg+9^R($d=2+|<5d`&CzO-(lRg;mST^YiDQc(Yp2){1D@1S8rbT;)ZR+8EPZ+q{AYt zhn&x`%8d#icrs$GrnTjX38}|0?#VM5(B@hxC|v^Tu}IcBJTd^6mO8Y^sQNOYV2cD^bH5zgrf@CWJdBV~jO`J$vX)6#E)<6&OL#HrEpIkV08h#z% z$75e%cZbB1rV619x!b74xXZj3N0`~=1+k}$&m zTIvO##1EbyaRcr~e^%k4^h5E9TYo)#tsq|we_+0(7l;n@Ldb~|l6RGxiTM2iKQ}X_ zMkhGw1sJI)#Y)r|^?S8GsUK53`tdS!$AjgAgsF(ev+iK1}Bas98%XKrA~#f^}{7TNrno41*1uj4z+~}e~;~zZK2Yq z*#g0F!fas>8#Sdt@0>zVnjf2Gp0LePrnT%Lyw%&x;9Uh=x9rJ*5l20TtT|R)=>9ai z=}EhNDakcEE!Py&Zr3hhG6wHo^6j+txa@H23B5#3@&m6f;zxRQk$2rOSyt)o6@X(! zwU@Y;Co+`fC===$eWqK^_9QMrB@I9|TsQX6skR80?1VdI8uhdV)k82}ihV(iyBUsd2Y(=Rri2oO$x5EasYBpA3nU6Qj!3zyt1}Q zTdGon{t=oMv9mD7(E9Tk&Xm5u`ZH#x;tUQEQH%q((#g9+#?s1k(dstg*twHp@OxS> zDcb{ST+6E!JJ*_O(eJV!t_G;N~duEnn(rnQ5->jl%9U^#*0=Hc?gis9RLVc1`hI~aHU9WZSTJ>fv7ght8_rK`RFR%tnAM~$vPwXMDyPk>fbT#O8Ne)Mn zPT$Z)d}_Pcl#YDUe=MCy8<3FFo#IO|VqP%avCfHeDZjZB#i`~hoh~6YH9@DVYK8`q z`AMs%Qyr<;Gm66xt6lm za7WSCw5k~5VIzG_LhmSgZSWmM4RAG$VKpZ`CLEB50?P!JZ#Hc0i{N{Ob-R-_j-a{9jObuJ1>FLqT;4 zCQel!4DvunFAW|Nv)f_oNq2btowlktb8O}ab7Fj=!!SYT@Hnd8i7>S5abuA&a(0C~ z!B+rIDP~D{HsB2^K7L<~EO{0G7&rUz@tbl%{8zc%k1ssZidONDbBiBexM;OhqyIh+ z^W&p6$#RzX=pn=88UoqO57BH}#YYbrh7-tiPGXB#thJoeueE=??S|kcc)AUqPuP6d zU7I&;-_X|4K7M?AN85(&zW>?My(NE3#}?ylH|*H5bNiJ-@0~EU>-Mgx6THUoD{hg^ zsQGuhd>^nE@w=I@WiDVx>g{DPBqMQQda6CsQ|SAk!erJvM%o-1&W3E5G}L;j>_x>a z(?j7yzDWH;>yuK%xbUPE?*4XHhCMY41G9NxdbC2&7A5@!zc}>c>!GbQpHRQ|w7NKk zwXuMaYt}C%@94#uRTbIU71h28+6UE9cy47@R%JzYPPqv4f1=(&{S&@LpziNu=%pJj z+E7{3uriONZO0CYb%u7W24Y<|V?%SzsJ>#7w+;TDsGhyXs-B!{&>Vv9*>v;U`;Vek z(K~2g&(C|%Ku0;6H9w<&Y43M3)6+9UgXBZ{RH>A32Iaq_Bpjh+=(Ae#(~!pP=hI3F zq01*~-1z#n590KB#OPkgS^Qx|-u$TE3t73&_tIU;@6T5GXDq>b!RlTgS)e)n>+ySg z>Yej0^2bBX1XfRQR2Nzur#b=`zG|Av0G~K=LFGSs|2%HN^PT47{A(x|yoUbcrY7-t zQ+4# z{NMC?r3doDaeC}~+Lxv=s!_e54RxVj(8h;;Icom!n6TAhF~jGN`cjW?8Tvf_i7z=% zp8)`Jc8bV^B8C#`hq_n|&G5?pV z_mUeqr>8VDcLz5bAMtW7uEO7yC8XPVzrmQl03PK|I`KnRB#v%r*X^mwNxBcI2QmDv zv~*~}|M|JjG3KOUM(3EOrZG=dF{LEsRCHKObT-Ij*=3H3GMS>H%-~eJFBrF{)k@Bj;CDBPJn_VJ5P)gWGkGX^ zIkevYS2qfKT30pN3lidUtaZIDt(VnVbK(;UQyW*d)wDL()wZ52` zp9EI(;*}o1Ow;Wf(k|M^9z#Ad&EQzncrR`j!Y!HDxi8|o(Gt>IwE;$~n@%;*sC+AW z=M>njcF5B}twpmNa!ZuQUFb-sw65X|IPt@4J65-}tnN^s?gp2u!7ZP83%PBfv2J;1 zSm*LO?bVxJ>oR84q^H+p7+tmLujh!{b4W9QE8Ni;ro@5@M7?qfmgvWSuum@sQL~RFUS1Z*R?jXQg z3GH>IYrfw8ZCUMyfV6s&7dVj>O6LpY8H2vH+iNc$zi#u!n!227Yh7z|*~Y7UxxCA_ zk9QfjZ`nQR%BJg9B$#SC-OZU#G)?HLHYF^-{**ioh88Y{EPMk=YR0cn!?U@T9inj$ zdk+GBu-NfTr#bpyA6q%>40O8<{8-5B*+*v2zQE6hggWzLWJ3=4Ra^2mrt*5|HnsC| zfs^(i=U(}nTT1BIdxA2PUdX;m?dM8n&lbiDdnAYaipgN_WN?I=RQ(*~vC99JqakwM zEPwbU_GcO6Pk8C*dagEdhSo{wXPjggIrM`8{||F-0-sfJ{r}&Yn><+wAwYltAps0w z5wZ~y79lH(2#BbN8v(**NH76K>x!s|ORZJfs#Vn1T19IW*Q%w68&ay)g<3^Kq&8Bt zsZvWZ&+mQCz0Z<>xU~KL|IhRK+&g#f%-or|Gv}O{d*+PY7v_Pq5vj8~{kNi;orhG9 zhy(53i^vEerRwN+(^kDY`sq3q$J5Z4_cX#~wZ-9DfiBP|dF0mUqDK_N7k_1Gk5EFs zAkiw)rL>1_d4-)iGh7N4Y30fnFDjA2VAQ*n0_fmSsDbl5(rc6@-`&2&UZ_NFG?mz` zRDvGi>)@mHc^Z9#{i@)M)8IE*?MH2p1~m=ft8I(?{%G~|z!@=pGkRwCN$=V%u*md( zJ|(eVcJJYRrzMx?$93-6yIZ&J9phe|T$#{`-9k$y@;ssK#Uk0r>>bzLq5d)^kF$I| z81b30xE?FJ*hnf}Tfpi~AA#Nue624>rlK5bZKaxAV+(^72lu6FxxOw9=uR3XK8G^vz6`RCnMK;nh;XopXE9YP5E=Q{vOu)6FvD7JCpj;RIA$W z_OP(^@jW{m8(SaWlJ@wEFRnHVThCwoqA4(Wtve+(*0Y?WHNh8zsB2a|axL8YFwY}L zXe4>hJ7jl>#jTHi@E4xeW*YQ~M4b+E-9=N@3y55#0u*-ZM2masJl6#hE!n;!=}x`}P}{kb1OD z>C`E8Qe}L_@$tf0<-yYCh0`gPT11@>^7F}XPfn2$U`mN z&kkFk{;!qtolQ3eP#6cG;Mn-2E(5c3j~<|D?KN5M^WQqHy@yS!7x~)%t<&mt zxU}3Z4Onc3B0{V`@274g0KF_t537mdBvWgGb_lEbg4pF`kR~3 zrOUa-R8^X_v$}SztgI-L`eLouCw#9!YySimcCD&p-v5l3()6~l0BlxPOkZ;fM>}w_L@9`ZYI#KGIW3h6m zW6@5I=(844V~6b6C-&YvU*USRU$G>pwhNujaC}ClO$eyf7m2t}8qC+4WSRZ&pSyjp zJoi0(2did5v!-$KV^W7ASI7z^o%L*DJ?c@}ZoiECcii7P&p79%iIBO>yf5+DHPCcc zF7t`RH}fGEM)wkYUZoCad}NG}t|D<*{FNSv9-!9)bBQflnRj#cxcCD+KUwszr4-Ct zd%OI)!pK*pefW#k-6m&!bJ|WjoDB$fu!*v4_M6K#hePMjC*=}msbAB+jr>mCEKxbk zt1;>Ep08J9Ly+F~Ve>%e(}uGGow+oG2HEDor0(85ZBAqgZBEMW>3A^1*2oOo9I5Xi z&Pl{xDzR$^d`;|=+Qnv_G0*-?pUqb&X7kud0nXy>ywEi^lpdmlZG(`J#|XfB8tmubDE@iosVj1wc8 zc@~`>D3Ms0VGh2AU5`RU25knU$D>I1sIJD2pWbg}#kDP~wwub>IwCwFAE)2DU9}q|VucxyKc?%D?mdQd znOSw*=`{;`ExuyNfL>kGQj^9M&b16i?XKgdE;wn<_5BuPE;Mtx9Y1h758Ri~YPS@o(DrcOO!{t9!?DJ#d1UwKOF zOtXIKDW^yjK?&JXZ2Ed0B zOj(u7o~NTPlAU{p*|ZK7!?Ok?r?ER`>&~K)6Ut4D=t}nQ8`E!Cr-Zj9W#-jc9qCDk zw7k5eOLLiN@80?mWtBE@Fx)1D28FGC%UD8M*%iL2@16FwFNa!)O~lssF0XBcD4dpx zk19&xey6;mn)Gv2Gv4!8%gJ6XV0l(*!SnZ|533{b>HZwGj8DP?VXyRlyJB<_ZE5V0 zaZ}^Ybi4QJ-8Uufs6ItgXI53sJf$)-JDX*7*~w!D$8|{0?vW9fR>I6~_2{~>6Sws2 z+O?;Eoa928O2i(3Q>HnVQSWqJ4bHa^#;KM$=s{L?!tE>eK--MY+i$uf!zMODqEC4( z10mWJOsFplToOBK%(w|@Njakik6JLc{G2IMo5od~bL^;NCygFG>DV`iojf`zchsbD z)g5|u9#LDB>}?SDF?;oy`IV*drImV+)+3|#=`yp-m&NGoIG5apN9|GhkCc#6yH`Tl z+o`~`VizFgz&A~U-iJP*SX42&`#w{iJhfq@e_A@eGEg~Q54;(f*Oxh`khS5{)P}0~HtIL3A@ozeZ%vZkIt01T)8QQy z6-T~rK}?Y<2<4|Z@{qV&=_4Wi-;4G8R&>g5n=`|Ai}m|f)Lp)Bof!VU6`j|!M~0qP z?4IP809kQ5d4)XBS6NZZ7rr|H8yJ$;==+pKzmCZhWqc#|l`}Y4#Sn=L`XbXG$iy=7 z0i8Xk?~vG-4#y`Nx7*NrbNY1bn_4m=u47#KjBtGl#q!eI?(Z8R`B-76&@(*$_t>2; zlzZl10y(boEA&vF@%;x$_>jMsFF*ZA_>hHMFLj>krGoa+Poh%i3xoP&Yb46Tw0!@O zyzu?Uw+~HA&@VjVo8wY}Dsw**{{C~wU3mIM`u&Gma(3{Bk#U%*jYEs2)5?^NC+6H^ zQm5S7dMEi#rczf@&VcCpaox_o1~o1-ekal^qtbXQt?-iBADFePcBssr%AR>UWCqS1 z<)z_YtBnq&YVA{%k-NiQqhjj?%Ey-l-Q`yLn8`{jzWC&p);rA?_S8SiZnrDMYAYX( zEV~9rg{LeUexPQi^-7y&etZQPTC6JpLwSniffcCHEs9lMs8W%#*kj#Duh+E?Y8i-D zCHg5}v-aVsffEJ~89#7Hep2#jowKKO@0*-l)HSEDFz2YELNjCFw2{dprw#1YsbA1Z zpU@-S^vIr4R2W}aqzA8siQRAM;a`%Eh-G1~XUq%r8)+Nkit{gY6vUNAM9_s6+R7{*Uu|oRlP?Kol(TYD}ER)zSRy?^p&CBJ< zvahEwW8K$NxO04XW6w%9_9Sz%ucu^Ho|o9V+u7~q@`R|E!qyWdgJiUIx0k&s`cR2u z5}n;%9=|G%NsNxeJ4i7d*fa2DZ_hx}lT=cjZO%jN%t2c|H|i5pjsF#CTjU#&A)~#V zJJEJ0tY}z|Y6|$~fyt>52@w(xgpT zGG^S8s_QNtQCU%P=}vp^mnYeK?Ng--#-~&-EZu2ljwwnUK6-0)_5A8;YRH#ap$M@` z5#-xx7V<|&=T!Luu~FLp$kumc2X@*joL0uZY2q7C{Vdg9;rX7uQ+)Zp(k86fjQ3U_ zdZSW*ObR{l$E5xkw6(YSz#E0O9d>H$@JQ1iXC5JT+o7byPGtAl`@9k1$^Pgu(F|>U z?J|?ux@*7rG?Dp?LC3Trpi9_SOp_nB^Ki7=y1I$`_g}VWPo7zwXl`g-eN5{Od^luu z)05rLM&t7A2Ey5*?FL56%&W%NpLX2fF`@&;$Mw|Iln$NPzv7Zh?kF8RxU^)*(BkS& z9qmrBVX8Om1pMUWSV#SjvHDv$;{}} zyLTVnQ66!&`f-GI2+{G3x8+Ij@>zqc%{v-+hM80~bX;FrWdSeuBAJ!)Tky%E`4Rj; z#;#HAI*0=ezbm{!G&LzW(t5ef# z;h-1H&!8TZTJ~bl-AigLOZ)1S&U;&bVE)nCA@N1srv0we#n-fs(QVpEJDItZa+SB5ytMsmO(j`md;u#yB%d1dmJVG$Z@mzc zerzXCMoapdNJgXb`PKQ7FTbCeSy3|vDqA3X#rfZ{yuD7^%@{mgHFH))*%X~M=+!SJ zzJH(LQ)X6$W(;(WK&K6`!W-X7rVS=X%@}xJ!DYsPnIUqS#f-r$oiSLhZ^mMWP`;CI ziOd*CrbHr)%GNPfu64#hs9n^I!RRT=m>f86VtrZcB{8GDd4th|M$aExzWg|53q~<( zkmBzjKkSral7@_$ST;!}4(3%SS1lS<7nv}SyfI%eOXmwN^786m$GPNH-*mg&6Q_vW zV?BcTf@5^2Y`-+j7oe~jmF8Eb!8>PmBD4amiJ8$ZCT3MD^Gr;E$q4K&^M>1jexx>_ zzjuO}b;9X=-3fj5-V^N1TNt`mUu{-aj~{9$59PT>{0<>;X`wmhpw~iWZrO}59GOCm zyIIEktsi?628}x6H(gF{{g|1EwXF}+!Ss6qa|d4R0hl>ravW_n-3j<|ovH{$xREIY znX52ePu3}gr4l!Q57FXbtnxQ8RVgy@Z*K zP%$lGBb;^E`P1{L9^%eo&~3tjye0yk~2r9m5l9GkR9K5 zNVkR%Q87}2e)4Tr zbV1x7CTNNLH2pNBW$`^0d!kaCN4D_0q$<1P;2!DOsR>;>bnG0L%H^O8x$KhCF)iL^ z4k^v()+r@EwM$a>A*DUKdrob;w%Od^p8xevSU4^#ZD6nNnVk~5c1-P((5daEZ7v+# zJtHM0zEhW^^oUd2GZGE0ik6;Eo1lL%57xc`uQu`5XcLx(%Vhn%Z5ph%pV2hvd@rB> zggz4N>(rL2PI07~+mxY>-)Hc=58Ypi-M5A=(f3y83W60T~)^P)TBF?xnPM)6Kb!fw> zBZrPWrl4R_%=vLub>$Uj9aA)`bVBwJbJDnJr>7*u_3zuS|KQRV8&A+Ly+OI%PMH18bH=W0lZ?%WLYfp0~>FnI$@5-!FB(BWtMWqbs^k(+N z9$nwa4ok>ke5?&U(eCqO$r|&YsQn6g)&uqoX5b{XIJ?8_2#4qEDsyDN694cFoTN|q zkbj%>BjH0ca9-*>)k~$_Q)LEDQV-3*X}MLRm%M~#;Jn&s43*cLfzvq`(Xr0tEl#nU zly7xnikyw+3%!l!JN?UB-!RKt?Vv+ZRyTO79a?+CDZDij5|>f<3H~ODojxq2%qyi&-#lD@U*c_pD!t-Ig}FM8apuxoBz z>#jX}lz!zpH!?q>oKgI3?w&ooP2twqXd^ZG{>9rd-WLte|M`$VwRDfZ&356Du3jge ze!+5OhQt4+*(7VfyuaIzf9PB3W2aqKnKpCx%(TkOPTM^zZC2~|-FptsbXjls(*xL3sdZqL29<=pmW?i+_JYUM}N@#30jPlyM>;;BKnQCy$_=Oql zaN785Xz{<(Z|{TbhuUu#$I%ZiV07(;AB6R@Ps8Caz|LnReR%^CKGfdZ=WvVKzVvo0 zlfLJr9qM~tXz3l2bLo>S{XQvq7p*rzM{-BukTNb`Eq|mx_)Nz^y!nqv3;_#`ebEi zCmvs2Asc5@R3AS%r_Z!LIeMU7MN;f4Jm)ZJvONx)=E#>DO?1+X>65dvE{Mw<_`Q^) z`?O2Aa9UdW{HaIf^r04H{BV+T9O!rSwfliy%Y5=__IXBnufm?iLUqLFHSwXmGEZ9$ zPtty`bFray4)7wz6T+b+Ycgq(Zc2_@QB}3#IPW-UY`=bE2k9}ioZ+0QQ_m?|I-qj! z;K~8sQF>P78DgoyoAEv5DFeJ(@SoN8NrTJ!2R`ncR|hh2w|QFK!0N%hy5FDLX+9WO zHAHu=6KmI}IqTqoySBk`{Rb;cNE; zy%ud1_C`i3drLKRBiGworDps$NNbb*r=@1vx3^f@#!=n~)Kp%ucI^ch-gMK2$Bu9P zAi4S!)4RjO;Z?__Ty^z>S6+MVl@DHhRpa!@YyX&+zwx#F{OObAeTPd$3;3?(q+iHP z{cF%*ejEbc33m*0xB<#xViNG%jJ}!Y7Ab3+@3zdOiiUuM5xo@Av=TRaZ=3$Un|*z- z3*-3**cWe(?~Zpe%_`rW5amvC#+f^P|Kuol2h-Q=^8HhsVmHQfljmf2r01D{%aP9RJTHhIK7p0Evst96>3 zv$&q7*NeHHtFg4KUY&-MhZ>HhQs(T;8_x4xibf;a4sR2;5*-`(>RNG zQavfxXnc82J$IYJIXIg5(K#4+gxr;rVjX*@6!4rp{f~00v9UZVDo)$-9F6qn#W_-$ zLt#!LcT&nl#HpvOk>^BWe^vU`ZOWTPuA)mUZz8(R=Iaxd@vNaotf^3a%9~q6=q$=B zd6V~g0EyOTG4tnCb zXc7)W_hX22H2E1ui_9l(A#`ClTzdo~ky1)D${EcHmrCp$RnyxhuzGb8tz$B~XH6mR z(~frrZdDg(X*%Blx--L_0T=JZ?AkbNd7xv)>}eXh zrynsBnuzWBM;Q-na2|nfJqLa32nEh`?t?Df#eCbHd}aSFGTTGW!_KqLmGp^zx<=xD z=Orw;Kj6$UF(%fTYXZ#e&U3yraZb?0JAX9^e8qd28hX(rIa^G!>0nY!N0aJ&VLF*K z(;17{Tb8u>odYJFnW65chsogE-7eG1^fsBa&p$e^IIpq>cY*0+vP`z~1Lr~L zT0hg@9A$F&uJ~^=z~o~4{&nXKGtgOxzU^Q$#2jsgnqkhzCeQhe$#?dc0_Sf`m=&4f z*!x@rHCf`+nPM}-sb_`LI$A=jDK(B6=`@>B#yDrwRvOJ{r^$>l$C$BZoGEkuWy(#3 zsZ?rqj;V6aHPvRknP4WGN#yvK7rRfPVeZ$i2Fw8Cg+Frn(xzpe&PHK`|y`JKSmC|)%iJ^5I37f(*!5D zmM`(|V@2^g>|FlYdDnRlyRAE%oz6z*ch22rnOV+?qjSv)bDlZhe9v59E;JXJi_InG zQnS)rW>%TY%@yWKv)Ww69zR!`Ys|IgI`e&I?S5cxFh4Xmnw!j8^CR2hFd|L*`+#!8~FfHIJFc z%@gKH^BeP&dD=WVyJm-Z&+Ihsn_cDuzI}XX_L#qzkIY|9v-#NUHGebv%qQkk z^LNu?{$V~dpPT*WpXLkmFV_G3+Z;4snpP7;bJJLBT^nO#u?-!^E?o&&=}%&>vJN)I zcC@Ls6JH=YWACx6O=taiciY2e*q*i*`|D=fK75bOM#k^Q&ZtMBu{8keD+BEyJDAy& zqwP>T%;wpAcH=3;qUmt-6h<&}SITU~DCWP$V3l>O9cRm!C9hzvxyn}C@knG7(S1D@ z3$w>D9-fNTn&bHzbpm@!oQS-9vYly9!B*_4th+kho?*|lvu%x?W9Qm=w${$K3+zI> z$S$^L**d!fMbZYl)Siu1-X^=uF1P2{bJ4mv&z^6;$GGT1EG}GZFR_=}mG&~b%3f}- zuvgmE>IO`?lV0*V#Ml zo%SyKGy8LUxBZ2^$NrK}+V}C<{C?&aeq|rDzqSwAhwTRYh<(&PW*@gt*eC68>{Iq> z`;7gq-DrPjH`!jC%Zr`wP+PCa> z`zQOh{j>8+`;K$3eV6sS@7bO9eY?wkV0YUO?H>CV#?*hc&Guuv*ZvKC?N98d_V2dE z{)4Z_Z`seW`0`Kth5eU3VE=6o+AnRZ4MOIPYhBlkabw+p8|TKm32vgB|5aJSeU;g-0i?nrl(JK7!N9^;O6$GK&0xm)2@x>atqJKmk( zPIM=^$GVf<9kRCk(tygS`J!JXlr=$_=B?9Oygac8-wx~I9PyJxs(y0hIHcaA$3 zTdTG1e0PDn&|Ty%cF%I_+$C;+&#xV*InVB=brC=&%MCC(7njL z*uBKP)LrRb=B{!tcdu}-bXU7qxoh03-D})y-Rs=%yVtuvaBpyb=-%kwnqi+ij4Q};Iac6Xh7hkK`cm-{pK=kDF^FR=9YOZQ&)K6kx)zx#muEB8V7*X~2^ z!|n$65%*E|G52xz3HM3&H||sJ)9y3wZ{3aV@7zu9v+id1Irn+@1@}ewC3lPavb)v& zy}Ql*gZqm6s{5MzNB4F24fjp=EqA;7C--gl&+a?!yY3G6J$I-3zPro)z}@YB=uOgdR?KTyRx@{QZT+%9<=h$xTuGFgWq~TaimURIsL~`7tHQzK zs{GW(N2NACd^>4E#5-ofoSMeK#AS=>=GDec^b(Al=tU1q)P$2JMS=$=*UVkMtTr&& z%fa&cMfp{QB{7ppJ21t&HO0F%)k~|x)P>9I7t}N^Us6}Id|6!3mR){>+5Rj=PjBWsPihf zE~;`1OJnMIP@vwsRj;>_>T8xZG%agvSh}z_uEBdqhlc2i3^eGY0*zj@#;6pBR|cBY zH>oL-kJ!3~`UOpaWg0GKnUrCAZ+LaK1h+ z@%(UwxwX8sMT_GWE!I16G{(Gw;rVhd$S=tA&aCp%>)}NS^e-K@E=*pwkm~Z>O-b_? zofC2=H_;XA{TrHIVOgnnE-gyXr_60wGDm&#%Dp>zUTINW zSyecP`Bi0A;-6ntUhJPWr(A0~oQpKQ{EABNewkhqufo6fMAkR;yEOhqr zRghPZSSHo2SqPL5ZEDK%o={L!m`qOcIEOv+tMW<$mEp_0{E8wC!&yC(JVF$53pEM{ zCT@iUmUFacmG4;<_9WTiezL>Ep7~YzehJI{8mjP1TH%+d!mptUKmT4is4>4L%0o5b z$IB0$i{h)6EsT0lc|~A+xGm@97x?Lx`p@;+PJUH^rpLJ`Fi~O;hdyX3$v&OXi=>HK zvl<}DqnKV`soVUDYQLb>>X|w*(xM}w3i3)c6le8JnK*AzZDVcIqNZ>da^crwf!5>j zjuXQjAu3QsnOE5rW$KwUaY19vIknLN^hxY>leFVq)9<&5wvXGHYPa<5&71Nm>a(0?06 zUc1isib^7yX}{AH4fi4!`JJXH^dvvgB26qWzfwDCepRL4v?~3FRQgS!(vM&1#V^Ry zrkPjZHHo}}!cdb`f5?sB)GDfCr!H)0toL$J>1Vkrl&asrEB%I6rKwQw{`GJ_+;GpI zKA$wTsjjAJVHEi&^3y2NIONF7S^jW8r}=))hWni%Ka{5bjAFlJ#eU6{D4EJD$oFfY z*pFZA$1C=0%)2I>U#4O|AH`li@++16ao>Lvl_R`&UFng@{7QWT)V%+Os{DLZ6~~?! zsj(_Q7u9|)szNpBKds6yb*O8P@b8Z(37o8zlyGvW>jX~Lw(q@(62J7-MS)YIysQ06 zEA$In?LWS>AW$PsGC!#%+_`Zp^HP5=)iUkeye943ev>NKMh#WcMji6Z_xfddepK{w zrN4w!`Wp(Vlo3y*jOs}n*S;@jjh$pRK}5?qSYZxaOm8crrOBmnN5r4)picw)k_Vct)L)J%TG)9%AZmbdP~ud z^6RL~ucMk;O)jY>Ov6IC_v@`JQg5W}GqXI;Guap6H2q{>fYXbVh}08i<^|;yi8aC` zHH7EXhj&z8b$U64-$}ufYQlstKV?qM+_SWsMhaY2=+S-760ay8StxwBAg^5S%31HG z%t6Act*f2yCEigl8)b@JH_A={Vgh@0v z#NGa8p3-mcW?uW7rBOFa+utmVx>=|UY zTHWnqh6*h=+Z0+nLWNeh#_TY+0SR~sg66WC#`FAl;*pp_j}GS*lq(%9$n$7D$#@Ng zb95P1_M>bbF)j4v1J2<<)R<36^QvR!A$NO^%kx-jVcrN0nT(Jyr?#%)+^D8rmQd5! z*l_N=hI5roK~*%1$X~w1S*|Pr89{57v)}J3l*DuG)jVa8XF{sa&UQ_L|q}uwhXF_e$GDcO)YUlX{sEnyv-q_HwcE#Mf znk7;(8rPkF=KNT2=AuM7E*@I9XhDs7)WwG`JIp`R8))gx#QBRBEN`rp2X%}%BkwHF z%WPgjMQMVJJG7?a)Kxd%^O1UrS0763c}Xq#E-ESHTH54AT;|`4sv&(ZRLt;%`3=h( z{fCH49wKM&A>z`9h{Jn`y7VEQOCBOFd5E4PuR)t85Am94K}C5$sb@jbf-svb2rTeM zn%+rhT|sKp=vO`D6(ECqlWedF<;tH3z zU__M;XXLD&F?EX?YrJB4gTaFQd`~XT_wvbXmF6?Au+Ym}VV*a@gxJUxlP^YnyhL~>vH4G)Sm zB?d=6y$^}(zDQq?=e@oHuYL1+wJK>6o_}G1FGW{W$2LSndv5@cUsV-KRYUU>pW9UV zjU~^&_GWJKtA^*t!%&ro1;kM|U$3PmKBRKXj9aKEba z(%QyF4fAATmr?#aZFlMI{G_i{l+EaQcyEmoSXv8T3pIDY_B>w9WKc<`vzH5-Yp4(N zGs!0e!|ROn47sL$%aM1H2Qo|QS8eY#eS}bd#&CV7xSx%{ZbVB=@gd+njo_E^R%!9 z6=jJ!r{{?zo)eM8b4U|P@SHRv-&2}V$Vo#AI}u5|pok>CBhn=`bG+IS@lA_XcuiFt zG*vliQx%6aRXJ-@RfjZH&p}fa2TfH^k+hZ8kalrmyD>&#p(g>Q!7}VQ~kU^F50;KQAm4vO7xrvNM;d4CA#DuRa;h`((aUi%5O@?M!p-)vp;_Ha=IPHVWmwK#k`O2a5pK++x83*69v@qfP+D7l4 zri5KX#TBQQb@P+`vwC`=<&Arx#icjmH59M4L&y;oUK)fKUR=>PXdwO#Ty1VF4c}Pm z-SG0W%+JrV=wyV~c*%$>DjCU(_kK!z!K)-sZ+o62e9sZlo_XaCih~A?#EzV;tcMjX zZL}`Ec)N``4D}Pxd@+?W+lfv*wfpfGw}q5a5Bf!EVi1^062>y z-}^c8wV&fMdk)9*(du{kN`IGQ`OdzPxLfS+@!8JTbC>Vt?{jR%LY(0X`6nEMu5nzx zT@T>M*JX};6XtlOdnL!K+^aaQao0FjzWn0eh*q*eUnv1Cr)iW?bo555dnWz~Ql@Af z0b60Y$@TnW|NJOt(bU4i2HG&jiC4`E(RkR3et_r! zuy(-Ar&dH?;$*22)rY88y$8{G5N!w1C=k5?^aS)vzkEBF@7+=kL&}Nhs!^`W>d7;l zl)9Q_^}Nqx@E_?Ul+CKjaz-Kz$Z@(Hk1d!Xwv$D^2{#*Jy)aP+^?Tb(6&h9=3vjSO@;6kwj=wp3Lj1av^iCIKt&9A-WywE%SGTO} z3GycgZT{T`@$`NAm;AK-uOye`v)j_15A@vLvxVF~?)`i9@o*`v?ils zWl@XNqxbK9FmolfnUkHE`8a8H$)1v3PoCP;)nTqy@IHB?QU<>kzp5_1n&sVVo$A|{ zTIsVIY*E)ka+dndj?p$DR~n1!&^wB*X>sp}`nSL5y@$}d*w^Nb9PX`DW<~zwjQJJ>I=rB75EUAZJt1{FUh=RQ%x>9c3sRI zwCvK&T=eX^!#y%=hN(r@t``(OlQp0V(6-Aq3(>dhXBMGxca&L-&fNfW7Fu@$O&uC{ zL(LL2?($7NI(LPp0j;~?SV9?LN0_rk>&`Tyb$1Lq?3`jxG0V}PJJXzlmRy557fra; z*a}DY&0LD!+xN|7XuYj9tI&13#jJ*ptut4NrW@-}@3r@u>(Fj{z4%6Xpi=+MYH)6umZcqv*AnwW8N%evDq*OXg&T`DdXqn}jr_nCU zH_xD1R%CvMURjCRgjU&T^DKI09gsbyNw(fYuACOTT? zbJ}D~@G~R`$LSW_<@CUv5j?<-IQ!WnZI3e@zZ1X=a3Yuq&cuHl^U#EDG01|`yZ4yWZ z9Y6}`2;K?qvG0Npf_vP{D6e%Ukn1by`_>sx-X{b%Kp|X8I3c*1y6nQe4CkicYtW3G z;BIFC7zpwy--O^h8scwUvs+E@BYZD1aUdQffVp5E`*7U`ZU^hY9pFxISMVbDYH$s> z4%`|PISq|?G@)ZiJwP53L5JXWb`tnZR=?|8&E#HIaIcdM`htF3_vbtS+BAgoP>_e8 zGI-D#O}fW$Jr0zC@yL)1IWGc>!C9aVECKbP0W1Y) z1Kyy+8^i+NXAW=B;SD;MfmPsg@B`AmiEuaLz6IP0ehO{_w}W-y4sa*93$$-dn~3`? z*bJTn&x04hi{K@&1-uNlQr6#tZQu{!74Rx}4g3+j4&DH7g15kS@F(y#_%nD1ybE@K z_rUwq@NVSp55XSr7w}im3_j=ne(+E51^5>@0R9aQf-ga9a4#bNe&Yh(p@{`?ARZ(D zTBV^?8d{~HRT^5Qp;ekx&M-8uK*oWZ##XDFQQ&6#?#eK^xcY&LHC zh^3EM`iMP>a}MVLoO3x3IO+CG&P`wixENdlE(I&WWndM!99#je1gpUs(zucHO<*m!1>A<;?O+|a1KbJj z0vm$c?I!L&2VMlUQ%gIwv{S59O>=2iE_L8$0_wn}4qWJjOC7lV0CnIV1*ij;^1A~8 z<#z`IXoq_=fPT2tg-czy`Jgbk%N@=67(iLwae(r=H9Tt$n2VeC=+=VyU;&`rLEH92 z+xA1-_CwqDySD`oy1(Fj5BMdx7u*NdgZse);8);5@N4i8co=K|kAO!3T*ZAV_^JCe zcm`|?e&qfRYy!^$=!N?nXXu6d0%z!j`x0m9h5It+t(k|HZNWgV+h+J(eh1hAjPx*CU-RSv6b z99E1utgCTYN#h_DI&hNyP{w_%De8j!*TvaH{Aa;t@Emv^yZ~MVFM%!KWv~_e9&7`D z0Iz^o!E4};;C1i@coVz@wu3)`x51ymJK$Zg1H1>`XEoI>%JBi%&1$6&!5;7z@DYB0 z<=hNDfuem*`2FCY;0y3CZ~*)p90Xs2R_ty$%oxb5L3`@ghkMio76=_fT^QX*Y-RK-}F2E9yIS-rwQipRG|f`&;nIxfhx2>6JB3GM>FAkBNgFTuUw zKJvOA+)sWV0KWncf?tD&z{6kzcmzBO9-};uQ=TWllVB6&dKPR3&w=N`3*bfY64(M> z2LJ7z{|ROOraiiSPmT+2MvmEx9J3iYrWrY=89AmIIi?vorWrY=89AmIIi?vorWrY= z89C+)&$x?gG$ydi;KR z{C;};eq@nmWRYfMk!EC(W@M3OWRYfMk!EC(W@M3OWRYfMk!EC(W@M3OWRYfMk!EC( zW@M3OWRYfMk!EC(W@M3OvkQCxc7qSW$K-b}_#5thoIe4dg1>{7;Dd}d9yFhU&%u83 z_)qW!_!l?;{tXU-FF`8^20z8p*{7Bre60m8hyk%60OA;l#DfHo7~H^!WdkFY4UAYe zFk;!jh-E`N%67n~YzKVGcEG1>2YkwQz^7~nqA1$|pRyhBDcb>`vK{a#+X0`l9f+cA z2YkwQz^7~ne9CsA8&B#8Rk$pBJ`)o$`*^KP7Ihxx2pHQ_x8Gi(Io8xuzMpW-oG0Gjh#d68BxvWlkXDU8AE3>3!ll?jZD4|bY}&2 zCSM^k`Tmnho--MNX7bETz7%w4<#r}{@6H#XOeC^gT$#FpJA=Euh}|1`4ckP_-X$_y zhaD--13)hBfgm6Eu}EB#iFX{B0;Ym#08Yiq_wKBN@6L+&?yP9;&Kmpf%-dx;E5Lc+ zeDFPR0k{xc1TF@bfJ?zja2at{fy==aT+2Md4d91hE%*`mG586%nJ~A2TftAkZQypW z4%`9m1b2a-5%%ZcZg3x^Sr6{V{Q&qCco6&=JOmyFa7oszXR>-dlljCnW)l-x$DHUq z34TKzJq4Zy&v5^@U?+9+K69J9xc>q82r#?CY^3AR3RpKSEhLc^l4#;UJV*f8zhNFS zk#A~=d{N3Y)P=c}9Ve*=a~W6#E(g?up&ra?a1~est_IhDYr%Ek``~);13-Nl>dV{+ zZUWSsq23JjW~euFGoap>>+Ei*GjluF>%bl0PH-2X&J1;CegW)bo(9y7p>E7Z@H?;xJPS61=fLye1@Izx32Xr`gRSJ}_h1|N19%0z z3SI+$1h0cPz*}HD_!D>={29Ch-UU0rdtfJcAM64jfZgCju!raT1$+dM>e%Bh)BFwp z7G{e60X_qtgZ=pb6MO;w1rC6JgM;8p&J5q2~<#VN84oNvSbcCZfI0qz8Mfj1#0ZvomElqL~MlL)0rw9Mey z7Vr=7B@p8`$2*BopG5a+a1FQ)+)CR_s2OIDU z$j*q&xjW3Y=d!ZjF~0&2aUGpTuI^eMNyBNA2KL0DoV|kUp>|(5#|GaCoNL(A`|< zZZ32;7rL7Z-OYvW=0bl0(4UdepA6_v2J|Nb`V)Zu1fV|w=uZIplL7q^D;NRVS}tuZ zm$o*Nww6nq3ectkw5bf*R0eG-gEo~xn+njT0<@_BZ7M*U3ecu9Xj2(F%e$Aix!=iV zzEoy!cQaPlBlDun&#{95NB}bLHkb3f;BI>xxE-tmcYr&=UBTV({@w8Y-SGb1@c!NH z<|FpNxA(xe_rSOJz_a(julK;K_rRz3z@zuTpZ8$jse+oQ0^|8|bSz(zzJg1Idm6pW z8Emf#rrT@5b>IeY54PmigZsg+!Nb_XeGEJ6H$>a%fb~Zrdx3&cI8GyF&d^`<&}c2);o5r6}i>R5M9?0=?FI z)|E&KE-_>_znQz;#h>R-pl^FJ(ga3v-YkDE&)DuTGs&C?n_UGrnn3U)(vqDqlviT**OWfx{uk={2-jZjOX_kz)v^6%&?20pt{IZS*nJG| z;E*5VWc~3p?~HsZzaZQ}&j)aS5xVmI_Iu}VdW6D8{elN5dAlEPE?K-d>yI#>F=zLU zf58^s;Me}Bm&5%;(r)t$)kPc6?|4D`jQa7W^cOlu-TE&(<+;)2rOkLxrVfISgN*_& zA6`vM4MHit!4Hn~-G4>jJ?fETN@?4^IldI~3my!&Ds3atwW9a^mVV^aBX_^UyXzZ? z{oi-B%}=NX!sl<$i`w??|56UW&3n%HcF#PNetcca4Yh!8_ndDX`rEwcz_(7_k8o%o z4b?~J{B2Xl#;Z^LzdB~*siFP@uhCy9ZY0E4r}9nj1pgK3%kcd+zeBG>K1dS(`` z{-iBO_u(6nWxoD5>+6FY8bUdb-Xo#VB23vs%oFR zuf9fB{|>*9yb+1omQO|e+q=Fi6$_=)z9b?4|HU~(1HV4)!<9b#F2dBet+e=csUnwa zZ}_@!hu&wTp=~YHKEB;^i5t~h+ov9UJGf8N-@ypU5m&okFYh|Ig^-?vwviF$xBeZb zg)v*8bn{-7ZlP+y&5R>H3vTc!T)F?`4Z#N(NAiQJycT@)u=KC{S7)d#wUz!MZ=!7- zdT%D!>ihQhpRDEe-PF#1^A~Diq5dN|2;SyDYlDC0`BI$p7m&mH2eiMm}7GK++M@^5=O4|lM{8gw%BX@(JGSZ|5Ilt-E z0Itt?`=9!?;kB@M4b#l1T-$&6ZX}_@yW$V;t*J3DscrjlX|3v#^S>l?P&kLkI=$yZJWY(y<+}B`v+%# z{M1gm9qFZvZ@~cm=>l_49V0IGx&`EtnIjO@ZIbDbC`yNBnrYr@dW74 z*5HoFn+l~xsCK>9OV3aBqeI6_p_ty-U#MNE#pkDuK9-%h`6&3maw zS~KtB8&Y_cLkyxI z9$pI2_1);meWCeo$M@Pr_^)0@=Rewgq=$rPOo%p8kIE&&9IJ>gA^alg9ai2w(j)$l z?jg-+tBI84yFd6)x%F@qK9oKq5#+i5%`Z~#k@Tbe4sFGeaNorhDM|3Dwp1q6B77cB zyB432?{|6DSH(FTM~}Q!X5_t52v7gN{rz83$xz8udY5|sFVd02w;ARM_d>aZ&;8dq zai}!^`8m`Nr9Fh|gcfl?QikG2jXvetf8IF%96kjf4LkoMM~F{RZ(E{!f|oO%pC!p7 zNAH39NVJtL;P9F>%RfXL&$yTGyebNL!FanrEFfC3YYSK zmCN>Ai`s1owetVfGre^EUK1`M^IyB7Tk*Gjy3VVIQ~S2D|Gx?!q9Xt6=SON*#}nat ziunKUa}eexG7EL^|L)2E>ymv{`q3p6cZdds=xOLW6#tOxcmB&g(PCF12K{H$YS7Ee z!YV;Nns|lmoHayu&Kic6T{(W0{70f;b|U*|iLHVvwN+4!t%57rNox(7SBucRdYUzB zzr{YmEokU{f(G&5O|0{hiAVQ$huS3A$<9>0ocGy-st>zKiEW*~V2$8b_PDx@U5R?= zu0(^_mFNxjo_fm;GE=b}FxK2<%WRo>L~R2+s*%JcEb53so!VsGuzq!W`p??JK#KO z-d5WHe^%Q7@2YKp_tZAP`)V6tm)Zu{&2Bb-HG9-505$=z3h*g=*szz8>|$eDWX~Gb z(2IqD{a6S{HveRgniO-8U20Ozm+Vo~#k9&UH6|!_0<6JKK)y{9s{pnuRsqUvy6iM# zGsHH4?IpGWY#*@=V6)jV<}91bo-s@8K-nqA4q~U6^X(A!h`GoPll@_Ap6m)^3)mIr zI$Ol9Fh8)v*%#)AwwS$OZnY!W4dymm!hSI8Y$^M`Y_KE6T7VrV)&gvWSPQV@#ae)! zAl3rxM6niNCyBKHdo0!hUbmCk-Q^uS9XkOZ+L_oy>PEYfe=L8|-}H3za2_>qK5GMFSsQRUCBA|_7N>ozBYo_8YVHU8Ggu|CmYVw!D+A(K8E_}D?&2Sd z_W#|~-Y>AJkc{^Kdg|$ZH2W=@{f|*^kE7L}idO&A)YxzN$2l9>i7bQN(6&%}ukjzk zZgGF4mUg0J8;_3d2h`qfR(5tm&-NqSe`RH7C+B1SN27JSmylwIWC)tK`*4djk_zYV z=-|e)e;x0V-TGpk4oU_3C>0n0707VX*_*DH6R)(uRr@3fW*!tF35u}9iDl2V3!NBj zZe4;l;-%gy0kyovo@h=lc16Q#mF$N`I@hA-AItt{Kf&i_bp2yxA2g>2H03sq?1kpU zslBd5v2a4mWluELAKZ=ZcSrLJbo~?A8||04*&hw}dUU}%$}VZR*(HtR!|41c%1&wc zKZ?G;SVwt+be=R%qF>COX}F(e4ZtvVPJ0HQ-?9$CnvJXiNMjGRO<3G|mX!c$?4`Du zoIJ;BfDHCjd!8^auo57Sebu%Q=4Dm`#Ie8H8x^A7 zY`LJ<+|_o)jYSvS-E249*mc3(!}h?9br;+{ZBN|Tc){J<_Qs8+7u@Wvh8ue?xcl0^ zxck|Dxcl4wxUv0$JIChW#sUoPT$_sMnY{hUFe&8etKj3(=y_nVXSJ|ta zfmn}OaG@4#Dv@Rv;9}&S_2pd#Bw&3U{&wA%Q*A?sA3* z&u|KbXEpQ4j_gb3bYNey1svfn>}(5n zajM`h%bh`3o;k-E0gqYXRLhQJ_`qp6Vt>Zz&2D6?oE+gdP9Nbn9N{-kiSQd|yzm=* z;5SaG@Ef$9;Wth{;Wy3z;Wti-@EeZs8;kaD1-lmq2Lhbzck6@OX7c0A=54wRuBC__0=SG6`3P~Kx%x3+>B6Ru+q;Wyn_ zFLNWj=Vo|}jp8m{l)J1v>8%42$A1FI*r z)#Eh1O(y;#d&a8lnFy!3kd-3BT`YTTUIzWW(!gukYx63OSDULj3NNwBORRE{IOQTS z%0=R$xJXQ*7O4E(f^jM|oMrnGi(sZLVJyvPDQJNmBG~Fmok5!s( zl%~fjO*cx@W0j^GrRlLs(~Z*fSf%MkX?m>EbfYvqR%yBsYhO;RQg)-1Jyt2ZQOX{x zl-($0PgBZnl(NSvWj9LMjY$2@0Fn9~qf|aksk~7tZ=mw4@fRwesZ{qDD7PtjY7Q0*)Is@6~ zayXQoy)N+?<&NUW_=7NvKRB|-CH!NWJB<{McaMkbOowO0!!u6DeFmH(-t{=gTz4+v zBm85K@{bbbAKA)31}pz4RsNB!{3BcWM_1(^J(Yj3Mi2gxsQhDy@{f_qKN6LH9IgCg z6#G^_ie1wu+$T5+7wPSDkoUKTF#u=oRPQATh#n$QR z_6)4QiZxW(?NxSq_4ax7c6be0Jq_)U>g{qmKz9xm>!(*@)77(pTEO#S;bn0=F%j81 z89tT*4VH0J8e^Aqcwcwuc`w~(x(|FQ8_p!wZG{I32O0vm@%FbS|i`zg-T!Ax)l_cn2!2Nr{+ z;2iw;<{WncSP52x>)>Y8IeSbyaW{gS3G+7Rb>MDFx|8#I@DO+$JVV*{P2^7&s<5%u{sxv&DzFDNcN{q_O6gp9Cn{glPmw&JrYh_G1PPD zN67x}pwJaD@}k_uZJsdNE$99OcZ!6Fxx}3j3CY#0$dxuZ&bZ9WVfgv zmV>X4Tw+u3)~KsH-FxK9eIVlJx!BwFATnLlk6mLohA!DDR_fhBa+4#q5&5~zF*bDN z?%?k3&=v0Hm`(1dQP)x5nB0Src$EFLn0SsUFJLAJF3r7>dsbo59zH&-mfuIdSiy^k@rVBUaTAq zldJxh=y8JhIMwQpwE^6hOPbDRP5FKeGfeLmtGhst*Qt-R5`$$_N#RQMR~mwRDgJNR zRUAK3|L*p4@{=cS+gIc6R{z!NE|8Va9N(dmHQolHOj>0=@vPRBjANq-4vqHqOzS*{1G~^tfD)SpFeQ zQ19;a?yCDdb)Tp14eEYK-9J(HT6I6B?#I-mBh)p+@>U zgW&tcj1S9M)jP$R;mmSoJM*zC2M@Ima@^`J<@k{mF8q;us=6OlcRk0QcD=f>b?=;~ z?(^IgxVPF{<>;Qo@m>34Il8ksinWh5cBj+ER=Y{Y-?!Cn;?-7ro!DwO@nWmp#H+3L zb!w~qb+OfM;>A`wM{Kp5k$fEqBH>6&78~rI9Mo0<>daU^Bn9=5mQ0Lp8pDQT#M2U^ zrKWEZC*eE9N&E(JywXJS_U&`lUIuJmnw~Xm5a|3&wqtbeojZ5QQs+TEJ}KMJInU2$ z(?n;hY}w|#S;wXj&O5TFjsMi=)dq1_>87 z=exu$-vi{H_kQJCxdIeT+#Y@N=yC`3iPvMA9>t=MWiH2~|Gm{uzHk|6qa6F|@hCkC z)suDZa#TKobS>^qdKAiHkf;YbGt8Y(pEK-MMpU`(b;uNxW9nnp$2=LcEw)SSQ2sMw zH^#1sUC(h>U?~5Efd>Px1wM-#8h3qMOZ<$4ClhlL7xLee)Fo+3lJqV~TlN$9nKPAn z?yywuZe%X|a&v|GHFFI2n8(e1%sM|{9y32TPs!W^qv&wOr0vztRn8jhs$9eO zzw5A;ay{QA+EpeV`e7ZbTirJVrE;eWR~S>=1+df zT*X-%x0uBbCJO;MJ6*6{g{0iz)ZtXW)+4rYjBLMu+@yj|6gV25?fUf z#_>7d*GO{HR&0w@a3PSC5E?^>m|WW07AV%Lw9wv02}bS9TIhn8bWszdZlGP%vM7%b z8!d*=1yv)77!nqkkk&*C6kEkeZDh&9w=4a9bME7mYHoTbch0@@I`jC>%zysh$$VeR zHN6VXyr$#&6+5{H=*=8vzQ@p~yp7yg-?IO8ReVhv!Vt4^@u+9vLb(&q*adD0b~|2W zkJgLzjem44c^+iNdfwZuXz@F^!`Q1B$5zE1R-G616y*iM4*#_~g?9+plLbM!zqgfd z>thZ(GY7|Z1&fwe!{KnT#Yn;g;K;467MCq(OE`lQbi9t*(L_5n?>MJw2(IZ7vtyq)b23lQ3x?!vP(XL0GD-x;>JL z^V{?;wh2119dKDb;iUURa-8$@x9QWAD4@}5O5DylB!;+>WrHnZFg6?Yj1Iuwor8Dk zlA~xvQYq+jcx7vGZ8}WcEpkn9Cja|7h;@MTdPMh0pXR0e1br5%txwwgoj3uX-<&^^ z4u38gUQBol=JA|-iZ*7Wc2R=?YBNcAs{JeeH$_bPuvhSz=24e@)MAhtO{HrQj(jO4 zg)5q&bn9Kt*-Fo0vP$y+bYh|}oWtof7bK=gLwi1V)5;q~=$aQ9?A7mC5`gi$L{$}k!bBKFH zR~jpftJ5ZJW<^00h(r5_d)HW5+-c)taW~9S?*w~_t-RMzQgAjM&&=w~{)kiO6ir@< z4EyU^biek09r|E#Yl2q%5;VcWD$A-s_=ost;CW(>@e6((e~rJUM$~4sIlZno^_EWS zcX}JX?2i7VKda5ixybt@eK|!PXY_mQZy?jmlXr0XGUBdD0vOBs&^I5vt) d{g{NDzw@iH13SqP4AEOd7<+#Sa@cwE`4<52M;-tG literal 0 HcmV?d00001 diff --git a/report_generator/template/fonts/SpaceGrotesk-Medium.ttf b/report_generator/template/fonts/SpaceGrotesk-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1b235f5bdb5642a3330b5e8351fdbf108a62c695 GIT binary patch literal 88328 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 P*B7Dh?Z!U*q(=V-_xb^7 literal 0 HcmV?d00001 diff --git a/report_generator/template/fonts/SpaceGrotesk-Regular.ttf b/report_generator/template/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c7ebdd03415c3d33ce8fd67827698e8b089881db GIT binary patch literal 88300 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

u+NF~9{fC{_ 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

of^bo5Zce-mS`ZNCP^RoqUeue-;SW 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 O#a@p$^!B9uJM~X~{*CMa literal 0 HcmV?d00001 diff --git a/report_generator/template/report.css b/report_generator/template/report.css new file mode 100644 index 0000000..9db88c7 --- /dev/null +++ b/report_generator/template/report.css @@ -0,0 +1,422 @@ +/* The print stylesheet of the SAST/SCA report. + * + * Follows the design sample in `sample_report_template/`. Everything is expressed here rather than + * in the template: the template places content, this decides what it looks like, and the two are + * changed for different reasons. + * + * Space Grotesk throughout, with a monospace face for anything a reader might have to type or + * compare character by character -- identifiers, paths, package URLs, CVSS vectors, VEX + * justifications. That distinction is the one typographic rule the whole report rests on. + */ + +@font-face { + font-family: "Space Grotesk"; + font-weight: 400; + src: url("fonts/SpaceGrotesk-Regular.ttf"); +} + +@font-face { + font-family: "Space Grotesk"; + font-weight: 500; + src: url("fonts/SpaceGrotesk-Medium.ttf"); +} + +@font-face { + font-family: "Space Grotesk"; + font-weight: 700; + src: url("fonts/SpaceGrotesk-Bold.ttf"); +} + +/* Shipped rather than asked of the host. `monospace` resolves to whatever a machine happens to + have -- DejaVu on one, Andale on another -- and a report whose identifiers change shape between + two machines is a report nobody can compare. Space Mono is the companion face to Space Grotesk. */ +@font-face { + font-family: "Space Mono"; + font-weight: 400; + src: url("fonts/SpaceMono-Regular.ttf"); +} + +@font-face { + font-family: "Space Mono"; + font-weight: 700; + src: url("fonts/SpaceMono-Bold.ttf"); +} + +:root { + --ink: #1a1a1a; + --muted: #55585c; + --rule: #1a1a1a; + --border: #c9cbcd; + --header-fill: #e7e8ea; + --label-fill: #f4f5f6; + /* The filtering reason is the one row that argues rather than states, and it is tinted so that + it reads as an aside even when the page is skimmed. */ + --aside-fill: #fdf5ea; + --critical: #d0021b; + --high: #e8710a; + --medium: #f0ad20; + --low: #1f9d55; +} + +@page { + size: A4; + margin: 22mm 18mm 18mm 18mm; + + @bottom-left { + content: string(scan-footer); + font-family: "Space Grotesk", sans-serif; + font-size: 6.5pt; + color: var(--muted); + } + + @bottom-center { + content: counter(page); + font-family: "Space Grotesk", sans-serif; + font-size: 6.5pt; + color: var(--muted); + } + + @bottom-right { + content: string(date-footer); + font-family: "Space Grotesk", sans-serif; + font-size: 6.5pt; + color: var(--muted); + } +} + +html { + font-family: "Space Grotesk", sans-serif; + font-size: 8.5pt; + line-height: 1.45; + color: var(--ink); +} + +body { + margin: 0; +} + +code, +.mono { + font-family: "Space Mono", monospace; + font-size: 0.93em; +} + +/* Carried into the running footer on every page. Set once, on the cover. */ +.footer-scan { + string-set: scan-footer content(); +} + +.footer-date { + string-set: date-footer content(); +} + +.footer-scan, +.footer-date { + position: absolute; + visibility: hidden; +} + +/* --- Cover ------------------------------------------------------------------------------- */ + +.cover-mark { + text-align: right; + margin-bottom: 42mm; +} + +.cover-mark img { + height: 15mm; +} + +.cover-title { + font-size: 19pt; + font-weight: 400; + margin: 0 0 9mm 0; +} + +/* --- Headings ---------------------------------------------------------------------------- */ + +/* A section heading and the rule under it are one thing, so the rule belongs to the heading and + cannot be separated from it by a page break. */ +h1 { + font-size: 12.5pt; + font-weight: 700; + margin: 12mm 0 4mm 0; + padding-bottom: 1.6mm; + border-bottom: 0.8pt solid var(--rule); + break-after: avoid; +} + +h1:first-of-type { + margin-top: 0; +} + +h2 { + font-size: 9pt; + font-weight: 700; + margin: 7mm 0 2.5mm 0; + break-after: avoid; +} + +h3 { + font-size: 8.5pt; + font-weight: 700; + margin: 5mm 0 2mm 0; + break-after: avoid; +} + +p { + margin: 0 0 2.5mm 0; +} + +p.note { + color: var(--ink); + max-width: 155mm; +} + +/* --- Definition rows --------------------------------------------------------------------- */ + +/* Label left, value right, on one grid so that every block of them lines up down the page even + when they are separated by headings. */ +.facts { + width: 100%; + border-collapse: collapse; + margin-bottom: 3mm; +} + +.facts td { + padding: 0.7mm 0; + vertical-align: top; +} + +.facts td:first-child { + width: 46mm; + padding-right: 4mm; +} + +.facts.wide td:first-child { + width: 62mm; +} + +.facts tr.gap td { + padding-top: 4mm; +} + +/* --- Ledger tables (severity counts, languages) ------------------------------------------- */ + +/* Ruled above and below with hairlines between, and a heavier line before the total: the shape a + reader already knows from an invoice, used for the same reason -- the last row is a sum. */ +.ledger { + width: 62%; + border-collapse: collapse; + margin-bottom: 4mm; +} + +.ledger th, +.ledger td { + padding: 1.4mm 1mm; + border-bottom: 0.4pt solid var(--border); +} + +.ledger thead th { + border-bottom: 0.8pt solid var(--rule); + text-align: left; + font-weight: 700; +} + +.ledger td:last-child { + text-align: right; +} + +.ledger tr.total td { + border-top: 0.8pt solid var(--rule); + border-bottom: none; + font-weight: 700; +} + +/* --- Grid tables -------------------------------------------------------------------------- */ + +.grid { + width: 100%; + border-collapse: collapse; + margin-bottom: 4mm; + /* A table that splits across a page keeps its header on the second half. */ + break-inside: auto; +} + +.grid th, +.grid td { + border: 0.4pt solid var(--border); + padding: 1.6mm 2mm; + vertical-align: top; + text-align: left; +} + +.grid thead th { + background: var(--header-fill); + font-weight: 700; +} + +.grid tr.total td { + font-weight: 700; +} + +.grid tr, +.facts tr { + break-inside: avoid; +} + +/* --- Cards -------------------------------------------------------------------------------- */ + +/* One finding, as a titled block of label/value rows. Kept on one page where it fits: a card split + down the middle loses the thing that makes it a card. */ +.card { + width: 100%; + border-collapse: collapse; + margin-bottom: 3.5mm; + break-inside: avoid; +} + +.card caption { + background: var(--header-fill); + border: 0.4pt solid var(--border); + padding: 1.6mm 2mm; + text-align: left; + font-weight: 700; + caption-side: top; +} + +.card th, +.card td { + border: 0.4pt solid var(--border); + padding: 1.6mm 2mm; + vertical-align: top; + text-align: left; +} + +.card th { + width: 34mm; + background: var(--label-fill); + font-weight: 400; +} + +.card tr.aside th, +.card tr.aside td { + background: var(--aside-fill); +} + +/* The justification itself, set as the token it is rather than as prose about a token. */ +.token { + font-family: "Space Mono", monospace; + font-size: 0.93em; + background: #ffffff; + border: 0.4pt solid var(--border); + border-radius: 1mm; + padding: 0.3mm 1.2mm; +} + +/* --- Severity ----------------------------------------------------------------------------- */ + +.sev { + font-weight: 700; +} + +.sev-critical { + color: var(--critical); +} + +.sev-high { + color: var(--high); +} + +.sev-medium { + color: var(--medium); +} + +.sev-low { + color: var(--low); +} + +/* --- Distribution bars --------------------------------------------------------------------- */ + +/* Label, count, then one bar made of stacked severity segments. The bar is scaled against the + largest row rather than against the total, so the shape of the distribution is what is read and + the busiest row fills the width. */ +.bars { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5mm; +} + +.bars td { + border: 0.4pt solid var(--border); + padding: 1.2mm 2mm; + vertical-align: middle; +} + +.bars td.count { + width: 12mm; + text-align: right; +} + +.bars td.track { + width: 50%; + padding: 1.2mm 2mm; +} + +.bar { + height: 3mm; + width: 100%; + /* The segments are inline-block, so the newlines between them in the template would be + rendered as spaces and push a full-width bar onto a second line. */ + font-size: 0; + white-space: nowrap; +} + +.bar span { + display: inline-block; + height: 3mm; + vertical-align: top; +} + +.seg-critical { + background: var(--critical); +} + +.seg-high { + background: var(--high); +} + +.seg-medium { + background: var(--medium); +} + +.seg-low { + background: var(--low); +} + +/* A distribution and its legend travel together. Where the pair does not fit, it moves to the + next page whole rather than leaving a stranded row and an orphaned key behind. */ +.block { + break-inside: avoid; +} + +.legend { + font-size: 7.5pt; + color: var(--ink); + margin-bottom: 5mm; +} + +.legend .key { + display: inline-block; + width: 2.4mm; + height: 2.4mm; + margin-right: 1.2mm; +} + +.legend .item { + margin-right: 4mm; +} + +.legend .caveat { + color: var(--muted); +} diff --git a/report_generator/template/report.html.j2 b/report_generator/template/report.html.j2 new file mode 100644 index 0000000..a636cf1 --- /dev/null +++ b/report_generator/template/report.html.j2 @@ -0,0 +1,182 @@ +{# + The SAST/SCA report, after the design sample in `sample_report_template/`. + + This file places content and nothing else: every dimension, colour and rule lives in `report.css`. + Each section is guarded by the data it needs, so a report of a scan that ran half the pipeline is + the same document with fewer sections rather than a document full of empty tables. + + 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 %}{{ row.token }} — {% endif %} + {% if row.kind == "severity" %} + {{ row.value }} + {% elif row.kind == "mono" %} + {{ row.value }} + {% else %} + {{ row.value }} + {% endif %} +{% endmacro %} + +{% macro facts(rows, wide=false) %} + + {% for row in rows %} + + + + + {% endfor %} +
{{ row.label }}:{{ value(row) }}
+{% endmacro %} + +{% macro card(title, rows) %} + {# 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. #} +

+ + + {% for row in rows %} + + + + + {% endfor %} +
{{ title }}
{{ row.label }}{{ value(row) }}
+
+{% 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. #} +
+ + {% for row in rows %} + + {{ row.label }} + + + + {% endfor %} +
{{ row.count }} +
+ {% for part in row.parts %} + + {% endfor %} +
+
+
+ {% for item in legend.entries %} + {{ item.label }} + {% endfor %} + {% if legend.caveat %}— {{ legend.caveat }}{% endif %} +
+
+{% endmacro %} + +{% macro grid(table) %} + + + {% for column in table.columns %}{% endfor %} + + + {% for row in table.rows %} + + {% for cell in row.cells %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{% if cell is mapping %}{{ value(cell) }}{% else %}{{ cell }}{% endif %}
+{% endmacro %} + +{# ------------------------------------------------------------------------------------------ #} + +Scan: {{ cover.scan_id }} +{{ cover.footer_date }} + +
+ CipherCell +
+ +
{{ cover.title }}
+ +{{ facts(cover.identity) }} + +{% if cover.severity_ledger %} +

{{ cover.severity_ledger.title }}

+ + {% for row in cover.severity_ledger.rows %} + + + + + {% endfor %} +
{{ row.label }}{{ row.count }}
+{% endif %} + +{% if scan %} +

{{ scan.title }}

+ {{ facts(scan.overview, wide=true) }} + + {% for stage in scan.stages %} +

{{ stage.title }}

+ {{ facts(stage.rows, wide=true) }} + {% if stage.table %}{{ grid(stage.table) }}{% endif %} + {% endfor %} +{% endif %} + +{% if project %} +

{{ project.title }}

+ {{ facts(project.overview, wide=true) }} + + {% if project.languages %} +

{{ project.languages.title }}

+ + {% for row in project.languages.rows %} + + + + + {% endfor %} +
{{ row.label }}{{ row.share }}
+ {% endif %} +{% endif %} + +{% if summary %} +

{{ summary.title }}

+ {{ facts(summary.overview, wide=true) }} + + {% for block in summary.distributions %} +

{{ block.title }}

+ {{ distribution(block.rows, block.legend) }} + {% endfor %} +{% endif %} + +{% if critical %} +

{{ critical.title }}

+ {{ grid(critical.table) }} +{% endif %} + +{% for section in sections %} + {{ section.title }} + + {% for group in section.groups %} +

{{ group.title }}

+ {% if group.note %}

{{ group.note }}

{% endif %} + + {% for entry in group.cards %} + {{ card(entry.title, entry.rows) }} + {% endfor %} + + {% if group.table %} + {% if group.table.title %}

{{ group.table.title }}

{% endif %} + {{ grid(group.table) }} + {% endif %} + {% endfor %} +{% endfor %} diff --git a/report_generator/vendor/README.md b/report_generator/vendor/README.md new file mode 100644 index 0000000..e92c3ee --- /dev/null +++ b/report_generator/vendor/README.md @@ -0,0 +1,42 @@ +# vendor + +Copies of everything `report_generator` borrows from the monorepo, so the package can be lifted into +a repository of its own. + +Nothing in here is edited. `sync.py` writes it, under the same import paths the monorepo uses: + +``` +python -m persistent_storage.report_generator.vendor.sync +``` + +Re-run it after changing anything it lists; the diff then shows what drifted. The copied packages +are removed whole first, so dropping an entry from the list actually drops it from the tree. + +`__init__.py` decides whether these copies are used. It asks whether the real packages can be +imported — not a flag, not an environment variable — so the same code runs in the monorepo and +outside it, and the copies can never shadow the real packages while those are present. + +## What is copied, and what for + +| Copied | Used for | +| --- | --- | +| `cc_common` | The CLI framework, the logging context, `PipelineError`. | +| `persistent_storage/shared.py` | The storage configuration and its secrets. | +| `persistent_storage/wrapper.py` | The `Scan` and `Artifact` rows. | +| `persistent_storage/utils/db_utils.py` | Reading a scan and its artifacts. | +| `persistent_storage/utils/s3_utils.py` | Fetching the stored files. | +| `persistent_storage/utils/cvss_utils.py` | Reading a CVSS vector of any generation. | +| `persistent_storage/helper.py` | What `db_utils` prints its tables with. | +| `cipherverifier/core/services/sarif.py` | Summarising a SARIF report. | +| `cipherverifier/core/services/vdr.py` | Summarising a vulnerability report. | + +The two summarisers live in the web application today and are written to be free of it — no Django, +no store. That is what makes copying them honest rather than a workaround: wherever this tool ends +up, they belong beside it rather than inside a web application it does not otherwise use. + +Not copied: the report's own template, stylesheet and fonts, which are this package's own and live +in `template/`. + +## Third-party dependencies + +`pyproject.toml` lists them. WeasyPrint additionally needs the native Pango and cairo libraries. diff --git a/report_generator/vendor/__init__.py b/report_generator/vendor/__init__.py new file mode 100644 index 0000000..e5e3e71 --- /dev/null +++ b/report_generator/vendor/__init__.py @@ -0,0 +1,53 @@ +"""Copies of what this tool borrows, and the switch that makes them the ones it uses. + +In the monorepo the real packages are importable and nothing here is touched. In a repository of +its own they are not, and :func:`activate` puts these copies on the path instead. + +The check is "can it be imported", not a flag or an environment variable, so the same code runs in +both places without being told which one it is in -- and so the copies can never shadow the real +packages while those are present. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +VENDOR_ROOT = Path(__file__).resolve().parent + +# One module out of each copied package. If these resolve, the real thing is installed. +PROBES = ("cc_common", "persistent_storage.shared", "cipherverifier.core.services.sarif") + + +def activate() -> bool: + """Put the vendored copies on the path where the real packages are missing. + + Returns: + Whether the copies were used. ``False`` in the monorepo, where the real + packages are installed and are left to win. + """ + if all(_installed(name) for name in PROBES): + return False + + if str(VENDOR_ROOT) not in sys.path: + sys.path.insert(0, str(VENDOR_ROOT)) + + return True + + +def _installed(name: str) -> bool: + """Whether a module can be imported without importing it. + + Args: + name: A dotted module name. + + Returns: + Whether the import system can find it. Wrapped because ``find_spec`` + raises rather than returning ``None`` when a parent package is itself + missing, which is exactly the case being tested for. + """ + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False diff --git a/report_generator/vendor/cc_common/__init__.py b/report_generator/vendor/cc_common/__init__.py new file mode 100644 index 0000000..4f1e78e --- /dev/null +++ b/report_generator/vendor/cc_common/__init__.py @@ -0,0 +1 @@ +"""Collection of CLI helper functions.""" diff --git a/report_generator/vendor/cc_common/api/__init__.py b/report_generator/vendor/cc_common/api/__init__.py new file mode 100644 index 0000000..9aac80d --- /dev/null +++ b/report_generator/vendor/cc_common/api/__init__.py @@ -0,0 +1,11 @@ +"""API for interacting with CipherCell tools. + +Exports core exception classes and property protocols used throughout +the cc_common package. +""" + +from .context import Context, clear_context, set_context +from .context import _context as context +from .pipeline_error import ExceptionSeverity, PipelineError + +__all__ = ["Context", "context", "set_context", "clear_context", "ExceptionSeverity", "PipelineError"] diff --git a/report_generator/vendor/cc_common/api/cli/__init__.py b/report_generator/vendor/cc_common/api/cli/__init__.py new file mode 100644 index 0000000..fcff0b5 --- /dev/null +++ b/report_generator/vendor/cc_common/api/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI utilities and command management for CipherCell tools.""" + +from .cli_manager import CLIManager + +__all__ = ["CLIManager"] diff --git a/report_generator/vendor/cc_common/api/cli/_utils.py b/report_generator/vendor/cc_common/api/cli/_utils.py new file mode 100644 index 0000000..b3ea093 --- /dev/null +++ b/report_generator/vendor/cc_common/api/cli/_utils.py @@ -0,0 +1,203 @@ +"""Internal utilities for CLI argument flattening and config loading.""" + +import json +from annotationlib import Format +from inspect import isclass +from pathlib import Path +from types import UnionType +from typing import Any, ForwardRef, Union, get_args, get_origin + +import yaml +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from yaml.parser import ParserError as YAMLDecodeError + +FLATTENING_SEPERATOR = "__CC__COMMON__SEPARATOR__" + + +def _unwrap_optional(annotation: Any) -> Any: # noqa: ANN401 + """Return the non-``None`` member of an ``Optional``/union annotation, else the annotation. + + A ``SomeModel | None`` field still has to be recursed into like a plain nested model: Typer + cannot build a CLI option for a union type. + """ + if get_origin(annotation) in (Union, UnionType): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) == 1: + return args[0] + return annotation + + +def flatten_model_fields( + model: type[BaseModel], + parent_path: str = "", + owner_model: type[BaseModel] | None = None, +) -> list[tuple[str, FieldInfo, type[BaseModel] | None]]: + """Flatten nested Pydantic model fields into dot-separated key paths. + + Recursively processes a Pydantic model and produces a flat list of + (field_path, field_info, owner_model) tuples. Nested models are + expanded with a separator between levels. + + Args: + model: The Pydantic model to flatten. + parent_path: Prefix for nested field paths. + owner_model: The top-level model that owns this field. + + Returns: + List of tuples containing the flattened field path, field info, + and the owner model. + + Raises: + ValueError: If the model contains forward references. + """ + result: list[tuple[str, FieldInfo, type[BaseModel] | None]] = [] + + for field_name, field_info in model.model_fields.items(): + if isinstance(field_info.annotation, ForwardRef): + raise ValueError( + f"Class {model.__name__} contains a forward reference to " + f"{field_info.annotation.evaluate(format=Format.STRING)}. " + f"This is not supported. Restructure the model." + ) + annotation = _unwrap_optional(field_info.annotation) + if isclass(annotation) and issubclass(annotation, BaseModel): + result.extend(flatten_model_fields(annotation, f"{parent_path}{field_name}{FLATTENING_SEPERATOR}", model)) + else: + result.append((f"{parent_path}{field_name}", field_info, owner_model)) + return result + + +def build_arg_structure(args: dict[str, Any]) -> dict[str, Any]: + """Rebuild nested dicts from flattened key-value pairs. + + Splits keys by the flattening separator and inserts values into + a nested dictionary structure. + + Args: + args: Flattened key-value pairs with separator-containing keys. + + Returns: + Nested dictionary matching the original model structure. + """ + result: dict[str, Any] = {} + + for key in args: + key_parts = key.split(FLATTENING_SEPERATOR) + _insert_value(result, key_parts, args[key]) + + return result + + +def _insert_value(result: dict[str, Any], key_list: list[str], value: str) -> None: + """Recursively insert a value into a nested dict using a key path. + + Args: + result: The dictionary to insert into. + key_list: Split key path parts. + value: Value to insert at the leaf position. + """ + if len(key_list) == 1: + result[key_list[0]] = value + else: + if key_list[0] not in result: + result[key_list[0]] = {} + _insert_value(result[key_list[0]], key_list[1:], value) + + +def drop_none_leaves(data: dict[str, Any]) -> dict[str, Any]: + """Recursively drop ``None`` values and the empty dicts they leave behind. + + For a command registered with ``validate=False``, whose options all default to ``None``: only + the options someone actually typed may be merged as a config overlay, since a ``None`` would + blank out whatever an earlier layer set for that path. + """ + result: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, dict): + nested = drop_none_leaves(value) + if nested: + result[key] = nested + elif value is not None: + result[key] = value + return result + + +def flatten_config(config: dict[str, Any], parent_key: str = "") -> dict[str, Any]: + """Recursively flatten a nested dict using the flattening separator. + + Keys in nested dictionaries are joined with the separator to produce + a single-level dictionary. + + Args: + config: The dictionary to flatten. + parent_key: Prefix for nested keys. + + Returns: + Flattened single-level dictionary. + """ + items: list[tuple[str, Any]] = [] + for key, value in config.items(): + new_key = parent_key + FLATTENING_SEPERATOR + key if parent_key else key + if isinstance(value, dict): + items.extend(flatten_config(value, new_key).items()) + else: + items.append((new_key, value)) + return dict(items) + + +def load_config(path: Path) -> dict[str, Any] | None: + """Load a configuration file (JSON or YAML) from disk. + + Attempts to parse the file as JSON first, then YAML. Returns None + if neither parser succeeds or the file is invalid. + + Args: + path: Path to the configuration file. + + Returns: + Parsed configuration dict, or None if loading failed. + """ + if json_config := _load_json_config(path): + return json_config + if yaml_config := _load_yaml_config(path): + return yaml_config + return None + + +def _load_json_config(path: Path) -> dict[str, Any] | None: + """Attempt to load a JSON configuration file. + + Args: + path: Path to the JSON file. + + Returns: + Parsed dict if valid JSON, otherwise None. + """ + with path.open("r") as file: + try: + config = json.load(file) + if isinstance(config, dict): + return config + except json.JSONDecodeError: + return None + return None + + +def _load_yaml_config(path: Path) -> dict[str, Any] | None: + """Attempt to load a YAML configuration file. + + Args: + path: Path to the YAML file. + + Returns: + Parsed dict if valid YAML, otherwise None. + """ + with path.open("r") as file: + try: + config = yaml.safe_load(file) + if isinstance(config, dict): + return config + except YAMLDecodeError: + return None + return None diff --git a/report_generator/vendor/cc_common/api/cli/cli_manager.py b/report_generator/vendor/cc_common/api/cli/cli_manager.py new file mode 100644 index 0000000..c0ca2a6 --- /dev/null +++ b/report_generator/vendor/cc_common/api/cli/cli_manager.py @@ -0,0 +1,216 @@ +"""CLI command registration and execution using Typer.""" + +import inspect +import logging +from collections.abc import Callable, Sequence +from inspect import signature +from pathlib import Path +from typing import Any, TypeVar + +import typer +import typer_config +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from rich import print +from rich.console import Console +from rich.logging import RichHandler + +from cc_common.api import Context, PipelineError, set_context + +from ._utils import ( + FLATTENING_SEPERATOR, + _unwrap_optional, + build_arg_structure, + drop_none_leaves, + flatten_config, + flatten_model_fields, + load_config, +) + + +def _result_callback(data: Any | None) -> None: # noqa: ANN401 + """Print the result data if it is not None.""" + if data is not None: + if hasattr(data, "print_as_cc_common_result") and callable(data.print_as_cc_common_result): + data.print_as_cc_common_result() + else: + print(data) + + +class CLIManager: + """Manages Typer-based CLI applications with automatic argument generation. + + Wraps a Typer app and provides a decorator to register commands whose + arguments are derived from Pydantic models. Configuration files (JSON/YAML) + are automatically loaded and flattened to match nested model fields. + + Args: + program_name: Name of the CLI program. + help: Help text displayed in the CLI help. + """ + + def __init__(self, program_name: str, help: str = "") -> None: + """Initialize the CLIManager with a Typer app. + + Args: + program_name: Name of the CLI program. + help: Help text displayed in the CLI help. + """ + self._app = typer.Typer( + name=program_name, + help=help, + add_completion=True, + rich_markup_mode="rich", + no_args_is_help=True, + pretty_exceptions_show_locals=False, + result_callback=_result_callback, + ) + self._logger = logging.getLogger("cc_common_cli") + self._logger.setLevel(logging.INFO) + self._logger.addHandler(RichHandler(show_time=False, show_level=False)) + + # _SOME_MODEL is a generic + _SOME_MODEL = TypeVar("_SOME_MODEL", bound=BaseModel) + + def command( + self, + name: str, + model: type[_SOME_MODEL] | None = None, + help: str = "", + ) -> Callable[[Callable[[_SOME_MODEL], Any]], None] | None: + """Decorator to register a CLI command. + + Args: + name: The command name. + model: Optional Pydantic model whose fields become CLI options. + help: Help text for the command. + + Returns: + Decorator function that registers the command. + """ + + def decorator(fn: Callable[[CLIManager._SOME_MODEL], Any] | Callable[[], Any]) -> None: + self.register_command(name, fn, model, help) + + return decorator + + def register_command( + self, + name: str, + callback: Callable[[_SOME_MODEL], Any] | Callable[[dict[str, Any]], Any] | Callable[[], Any], + model: type[_SOME_MODEL] | None = None, + help: str = "", + *, + validate: bool = True, + ) -> None: + """Register a callback as a CLI command with auto-generated arguments. + + If a Pydantic model is provided, its fields (including nested ones) + are flattened into CLI ``--option`` flags. A ``--config`` flag is + added to load JSON/YAML configuration files. + + Args: + name: The command name. + callback: The function to call when the command is invoked. + model: Optional Pydantic model whose fields become CLI options. + help: Help text for the command. + validate: When ``True`` (the default), every required field becomes a required CLI + option and the callback receives a validated ``model`` instance. When ``False``, + every option defaults to unset and the callback receives the raw ``dict`` of + whichever ones were typed -- for a command whose model is only complete once + merged with layers the CLI does not know about, such as ``orchestrator run``. + + Raises: + ValueError: If the model contains a field named 'config'. + """ + params: list[inspect.Parameter] = [] + + if model is not None: + for field_name, field_info, _ in flatten_model_fields(model): + if field_name == "config": + raise ValueError( + f"You must not have a parameter named 'config' in " + f"your top level Properties Model '{model.__name__}'." + ) + cli_name = field_name.replace(FLATTENING_SEPERATOR, "-") + # A bool gets a paired --x/--no-x declaration so it can be turned off from the CLI + # even when its model default is True -- a single "--x" name could only ever set it + # to True, which is why every boolean field used to have to default to False. + is_bool = _unwrap_optional(field_info.annotation) is bool + param_names = [f"--{cli_name}/--no-{cli_name}"] if is_bool else [f"--{cli_name}"] + field_description = field_info.description or field_info.title or "" + # Every field defaults to unset, the optional ones included: their defaults would + # otherwise override what another layer set for the same path once merged in. + default = None if not validate else _extract_default_value(field_info) + + params.append( + inspect.Parameter( + field_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=typer.Option( + default, + *param_names, + help=field_description, + ), + annotation=field_info.annotation, + ) + ) + + def cmd_wrapper(**kwargs: Any) -> Any: # noqa: ANN401 + logger = self._logger.getChild(name) + context = Context(logger, True, Console()) + set_context(context) + if model is not None: + unflattened_kwargs = build_arg_structure(kwargs) + if not validate: + return callback(drop_none_leaves(unflattened_kwargs)) # type: ignore[call-arg,arg-type] + instance = model.model_validate(unflattened_kwargs) + return callback(instance) # type: ignore[call-arg,arg-type] + else: + return callback() # type: ignore[call-arg] + + cmd_wrapper.__signature__ = inspect.Signature(params) # type: ignore[attr-defined] + cmd_wrapper.__name__ = name + cmd_wrapper.__doc__ = help + + def load_and_flatten_config(path: str) -> dict[str, Any]: + if path: + config = load_config(Path(path)) + if config is None: + raise ValueError(f"Could not parse file: '{path}'") + return flatten_config(config) + return {} + + load_config_callback = typer_config.conf_callback_factory(load_and_flatten_config) + wrapped = typer_config.use_config(load_config_callback)(cmd_wrapper) + self._app.command(name=name)(wrapped) + + def run(self, args: Sequence[str] | list[str] | None = None) -> None: + """Execute the CLI application. + + Parses arguments and runs registered commands. PipelineErrors are + caught and printed in red. + + Args: + args: Optional list of CLI arguments. Defaults to sys.argv. + """ + if args is not None: + args = list(args) + try: + self._app(args=args) + except PipelineError as e: + print(f"[red]{e}[/]") + + +def _extract_default_value(field_info: FieldInfo) -> Any: # noqa: ANN401 + if field_info.is_required(): + default = ... + elif field_info.default_factory is not None: + # TODO: low prio, if possible evaluate this right before the callback is called + default_factory_signature = signature(field_info.default_factory) + if len(default_factory_signature.parameters) != 0: + raise ValueError("Only default factories with zero arguments are supported.") + default = field_info.default_factory() # type: ignore[call-arg] + else: + default = field_info.default + return default diff --git a/report_generator/vendor/cc_common/api/context.py b/report_generator/vendor/cc_common/api/context.py new file mode 100644 index 0000000..10918c3 --- /dev/null +++ b/report_generator/vendor/cc_common/api/context.py @@ -0,0 +1,70 @@ +"""Module handling the application wide context.""" + +import logging +import sys +import threading +from logging import Logger +from typing import Any + +from rich.console import Console + + +class Context: + """Context of the application, containing global settings, state, etc.""" + + def __init__(self, logger: Logger, is_cli: bool, console: Console) -> None: + """Create a new Context Object.""" + self.logger = logger + self.is_cli = is_cli + self.console = console + + +class _ContextWrapper(Context): + def __init__(self, context: Context) -> None: + super().__init__(None, None, None) # type: ignore[arg-type] + self._default_context = context + self._contexts_for_thread: dict[int, Context] = {} + + def __getattribute__(self, attr: str) -> Any: # noqa: ANN401 + if attr in ["_contexts_for_thread", "_default_context", "set_context", "clear_context"]: + return super().__getattribute__(attr) + thread_id = threading.current_thread().ident + if thread_id is not None and thread_id in self._contexts_for_thread: + context_to_use = self._contexts_for_thread[thread_id] + else: + context_to_use = self._default_context + return getattr(context_to_use, attr) + + def set_context(self, context: Context) -> None: + """Set the context for the current thread.""" + thread_id = threading.current_thread().ident + if thread_id is not None: + self._contexts_for_thread[thread_id] = context + + def clear_context(self) -> None: + """Clear the custom set context for the current thread.""" + thread_id = threading.current_thread().ident + if thread_id is not None and thread_id in self._contexts_for_thread: + del self._contexts_for_thread[thread_id] + + +_default_logger = logging.getLogger("cc_common_default") +_default_logger.setLevel(logging.INFO) +_default_logger.addHandler(logging.StreamHandler(sys.stdout)) +_context: Context = _ContextWrapper(Context(logger=_default_logger, is_cli=True, console=Console())) + + +def set_context(context: Context) -> None: + """Overwrite the application context.""" + global _context + if not isinstance(_context, _ContextWrapper): + raise TypeError(f"_context must be a _ContextWrapper, got {type(_context)}") + _context.set_context(context) + + +def clear_context() -> None: + """Clear the custom set application context.""" + global _context + if not isinstance(_context, _ContextWrapper): + raise TypeError(f"_context must be a _ContextWrapper, got {type(_context)}") + _context.clear_context() diff --git a/report_generator/vendor/cc_common/api/pipeline_error.py b/report_generator/vendor/cc_common/api/pipeline_error.py new file mode 100644 index 0000000..ef7fe80 --- /dev/null +++ b/report_generator/vendor/cc_common/api/pipeline_error.py @@ -0,0 +1,70 @@ +"""Custom exception classes for uniform error handling across CipherCell applications.""" + +from enum import Enum, auto + + +class ExceptionSeverity(Enum): + """Severity levels for pipeline exceptions. + + Determines whether a pipeline can continue execution after an error + or must stop. + """ + + RECOVERABLE = auto() # Failure in a stage; pipeline can continue + FATAL = auto() # Critical failure; pipeline cannot continue + + +class PipelineError(Exception): + """Base exception for components within the CipherCell pipeline. + + Attributes: + message: A human-readable description of the error. + severity: The severity level of the exception. + tool: The name of the tool or component where the exception originated. + exit_code: Optional exit code associated with the failure. + """ + + def __init__( + self, + message: str, + severity: ExceptionSeverity = ExceptionSeverity.FATAL, + tool: str | None = None, + exit_code: int | None = None, + ) -> None: + """Initialize a PipelineError instance. + + Args: + message: A human-readable description of the error. + severity: The severity level of the exception. + tool: The name of the tool or component where the exception originated. + exit_code: Optional exit code associated with the failure. + """ + self._severity = severity + self._message = message + self._tool = tool + self._exit_code = exit_code + + def can_continue(self) -> bool: + """Check whether the pipeline can continue after this exception. + + Returns: + True if the severity is RECOVERABLE, False otherwise. + """ + return self._severity == ExceptionSeverity.RECOVERABLE + + def __str__(self) -> str: + """Return a human-readable string representation of the exception. + + Returns: + Formatted string with severity, tool, message, exit code, and + cause information. + """ + parts = [f"[{self._severity.name}]"] + if self._tool: + parts.append(f"[{self._tool}]") + parts.append(self._message) + if self._exit_code: + parts.append(f"(exit code: {self._exit_code})") + if self.__cause__: + parts.append(f"(caused by: {type(self.__cause__).__name__}: {self.__cause__})") + return " ".join(parts) diff --git a/report_generator/vendor/cc_common/config.py b/report_generator/vendor/cc_common/config.py new file mode 100644 index 0000000..80028cf --- /dev/null +++ b/report_generator/vendor/cc_common/config.py @@ -0,0 +1,190 @@ +"""Shared, model-agnostic config-file and environment-override primitives. + +Works on plain nested ``dict``s only, so every layer of a pipeline configuration can be merged with +:func:`merge` before the caller validates the result once, at the end. +""" + +from __future__ import annotations + +import copy +import functools +import json +import os +from collections.abc import Mapping +from pathlib import Path +from types import UnionType +from typing import Any, Union, get_args, get_origin + +import yaml +from dotenv import load_dotenv +from pydantic import BaseModel + +from cc_common.api import PipelineError + +CONFIG_PATH_ENV_VAR = "MONOCIPHER_CONFIG" +DEFAULT_CONFIG_FILENAME = "config.yaml" + + +def load_environment() -> None: + """Load ``.env`` into the process environment. + + Never overrides an already-set variable, so every entrypoint can call it in any order. + """ + load_dotenv() + + +def _default_config_path() -> Path: + """Where the config file is read from: ``$MONOCIPHER_CONFIG``, else ``./config.yaml``.""" + configured = os.environ.get(CONFIG_PATH_ENV_VAR) + return Path(configured) if configured else Path.cwd() / DEFAULT_CONFIG_FILENAME + + +@functools.cache +def _parse_config_file(target: Path, _mtime_ns: int, _size: int) -> dict[str, Any]: + """Parse one config file, cached on its own stamp so an edit is still picked up. + + Callers must not mutate the result -- :func:`load_config_file` hands out a deep copy. + """ + text = target.read_text() + try: + parsed: Any = json.loads(text) + except json.JSONDecodeError as json_error: + try: + parsed = yaml.safe_load(text) + except yaml.YAMLError as yaml_error: + raise PipelineError( + f"Could not parse config file '{target}' as JSON ({json_error}) or YAML ({yaml_error}).", + tool="config", + ) from yaml_error + + if not isinstance(parsed, dict): + raise PipelineError(f"Config file '{target}' must contain a mapping at its top level.", tool="config") + return parsed + + +#: Forget every parsed config file. For tests that write a config file per case. +reset_config_cache = _parse_config_file.cache_clear + + +def load_config_file(path: Path | None = None) -> dict[str, Any]: + """Load the config file as plain data, or ``{}`` if it does not exist. + + A file that exists but fails to parse raises, rather than falling back to the schema defaults. + + Raises: + PipelineError: The file is neither valid JSON nor valid YAML, or is not a mapping. + """ + target = path or _default_config_path() + if not target.is_file(): + return {} + + stat = target.stat() + return copy.deepcopy(_parse_config_file(target.resolve(), stat.st_mtime_ns, stat.st_size)) + + +def merge(base: Mapping[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]: + """Recursively merge ``overlay`` onto ``base``, ``overlay`` winning. Neither is mutated.""" + result = copy.deepcopy(dict(base)) + for key, value in overlay.items(): + existing = result.get(key) + if isinstance(existing, dict) and isinstance(value, Mapping): + result[key] = merge(existing, value) + else: + result[key] = copy.deepcopy(value) + return result + + +def set_path(data: dict[str, Any], dotted: str, value: Any) -> None: # noqa: ANN401 + """Set one dotted path (e.g. ``"polygraph.llm.model"``) in ``data``, creating levels as needed.""" + parts = dotted.split(".") + target = data + for part in parts[:-1]: + target = target.setdefault(part, {}) + target[parts[-1]] = value + + +def get_path(data: Mapping[str, Any], dotted: str) -> Any: # noqa: ANN401 + """Read one dotted path out of ``data``, or ``None`` if any part of it is missing.""" + current: Any = data + for part in dotted.split("."): + if not isinstance(current, Mapping): + return None + current = current.get(part) + return current + + +def env_var_name(dotted: str) -> str: + """The environment variable that overrides one dotted config path: dots to underscores, upper-cased. + + The whole naming convention. No config entry has a hardcoded env var name anywhere else -- a + setting's variable is always derivable from its path alone. + """ + return dotted.replace(".", "_").upper() + + +def _unwrap_optional(annotation: Any) -> Any: # noqa: ANN401 + """Return the non-``None`` member of an ``Optional``/union annotation, else the annotation.""" + if get_origin(annotation) in (Union, UnionType): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) == 1: + return args[0] + return annotation + + +@functools.cache +def overridable_paths(model_cls: type[BaseModel], prefix: str = "") -> tuple[str, ...]: + """Every dotted leaf path of ``model_cls`` that can be set from the environment. + + Descends into nested models; skips ``list``/``dict``/mapping-valued leaves, since a raw string + from the environment can never validate against one of those, so such a field must not claim an + env var name. + """ + paths: list[str] = [] + for name, field in model_cls.model_fields.items(): + path = f"{prefix}.{name}" if prefix else name + annotation = _unwrap_optional(field.annotation) + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + paths.extend(overridable_paths(annotation, path)) + elif get_origin(annotation) in (list, dict, Mapping): + continue + else: + paths.append(path) + return tuple(paths) + + +def env_overlay(model_cls: type[BaseModel], prefix: str = "") -> dict[str, Any]: + """A :func:`merge`-able overlay of every setting of ``model_cls`` that is set in the environment. + + Each of :func:`overridable_paths` is looked up under its derived :func:`env_var_name`; an unset + or blank variable contributes nothing, so a blank placeholder (as ``.env.sample`` ships) never + overrides with an empty string. + """ + overlay: dict[str, Any] = {} + for path in overridable_paths(model_cls, prefix): + value = os.environ.get(env_var_name(path)) + if value: + set_path(overlay, path, value) + return overlay + + +def apply_env_defaults(instance: BaseModel, prefix: str = "", model_cls: type[BaseModel] | None = None) -> None: + """Fill in every field of ``instance`` still unset from its derived environment variable. + + For a standalone tool's own config model, once it is already validated: only ``None`` fields are + touched, so a value already supplied (by CLI flag or config file) is never overridden. + + ``model_cls`` defaults to ``type(instance)``; pass the pipeline-side model explicitly when + ``instance`` is a CLI-only subclass with extra fields that have no place under ``prefix`` in + :class:`orchestrator.commands.run.OrchestratorConfig`. + """ + for path in overridable_paths(model_cls or type(instance), prefix): + relative = path[len(prefix) + 1 :] if prefix else path + target = instance + parts = relative.split(".") + for part in parts[:-1]: + target = getattr(target, part) + leaf = parts[-1] + if getattr(target, leaf) is None: + value = os.environ.get(env_var_name(path)) + if value: + setattr(target, leaf, value) diff --git a/report_generator/vendor/cc_common/defines/__init__.py b/report_generator/vendor/cc_common/defines/__init__.py new file mode 100644 index 0000000..c3bb7c1 --- /dev/null +++ b/report_generator/vendor/cc_common/defines/__init__.py @@ -0,0 +1,5 @@ +"""Collection of CLI helper functions.""" + +from .general_defines import BuildType, CPUArchitecture, FileFormat, OperatingSystem, ProgrammingLanguage + +__all__ = ["BuildType", "CPUArchitecture", "FileFormat", "OperatingSystem", "ProgrammingLanguage"] diff --git a/report_generator/vendor/cc_common/defines/general_defines.py b/report_generator/vendor/cc_common/defines/general_defines.py new file mode 100644 index 0000000..9704019 --- /dev/null +++ b/report_generator/vendor/cc_common/defines/general_defines.py @@ -0,0 +1,49 @@ +"""Collection of general definitions used across all CipherCell products.""" + +from enum import Enum, auto + + +class FileFormat(Enum): + """Definitions of file formats supported by CipherCell products.""" + + SARIF = auto() + SPDX = auto() + CYCLONEDX = auto() + VEX = auto() + + +class ProgrammingLanguage(Enum): + """Supported programming languages for code-related operations.""" + + PYTHON = "python" + C = "c" + CPP = "cpp" + JAVA = "java" + RUST = "rust" + JAVASCRIPT = "javascript" + TYPESCRIPT = "typescript" + UNKNOWN = "unknown" + + +class OperatingSystem(Enum): + """Supported operating systems for code-related operations.""" + + LINUX = "linux" + MACOS = "macos" + WINDOWS = "windows" + + +class CPUArchitecture(Enum): + """Supported CPU architectures.""" + + AMD64 = "amd64" + ARM64 = "arm64" + X86 = "386" + UNKNOWN = "unknown" + + +class BuildType(Enum): + """Supported build types for code-related operations.""" + + NATIVE = "native" + DOCKER = "docker" diff --git a/report_generator/vendor/cc_common/docker/__init__.py b/report_generator/vendor/cc_common/docker/__init__.py new file mode 100644 index 0000000..dea4a24 --- /dev/null +++ b/report_generator/vendor/cc_common/docker/__init__.py @@ -0,0 +1,26 @@ +"""Collection of CLI helper functions.""" + +from .compose_utils import compose_down, compose_up +from .defs import DockerArchitecture +from .docker_utils import ( + abort_container, + check_image_exist, + create_docker_image, + exec_command, + is_docker_running, + run_command, + run_command_with_input, +) + +__all__ = [ + "DockerArchitecture", + "abort_container", + "check_image_exist", + "compose_down", + "compose_up", + "create_docker_image", + "exec_command", + "is_docker_running", + "run_command", + "run_command_with_input", +] diff --git a/report_generator/vendor/cc_common/docker/compose_utils.py b/report_generator/vendor/cc_common/docker/compose_utils.py new file mode 100644 index 0000000..b1aa9f4 --- /dev/null +++ b/report_generator/vendor/cc_common/docker/compose_utils.py @@ -0,0 +1,112 @@ +"""Provide utilities for driving Docker Compose stacks. + +Unlike the rest of this package, Compose is not reachable through the docker-py +SDK. It is a separate binary. These helpers are therefore implemented separately, +while keeping the same logging and error conventions as the SDK-based utilities next to them. +""" + +from pathlib import Path +from shutil import which + +from cc_common.api import PipelineError, context +from cc_common.processes import run_subprocess + + +def compose_up(compose_file: Path, project_name: str, *arguments: str) -> None: + """Start the services of a Compose file. + + Nothing is implied about how they start: pass ``"--detach"`` to return once + the containers exist, ``"--build"`` to rebuild images first, and so on. A + call without arguments runs in the foreground, exactly as Compose would. + + Args: + compose_file: Path to the Compose file describing the stack. + project_name: Name of the Compose project to act on. + arguments: Additional options forwarded to ``compose up``. + + Raises: + PipelineError: If Docker is unavailable, the Compose file does not + exist, or Compose exits with a non-zero status. + """ + run_compose(compose_file, project_name, "up", *arguments) + + +def compose_down(compose_file: Path, project_name: str, *arguments: str) -> None: + """Stop the services of a Compose file and remove their containers. + + Named volumes survive unless ``"--volumes"`` is passed, which is the one + argument here that destroys data — Compose's own default is to keep it, and + this function does not second-guess that either way. + + Args: + compose_file: Path to the Compose file describing the stack. + project_name: Name of the Compose project to act on. + arguments: Additional options forwarded to ``compose down``. + + Raises: + PipelineError: If Docker is unavailable, the Compose file does not + exist, or Compose exits with a non-zero status. + """ + run_compose(compose_file, project_name, "down", *arguments) + + +def run_compose(compose_file: Path, project_name: str, *arguments: str) -> None: + """Run any Docker Compose subcommand against a Compose file. + + Args: + compose_file: Path to the Compose file describing the stack. + project_name: Name of the Compose project to act on. Passing it + explicitly keeps containers and volumes stable no matter which + directory the caller is invoked from — Compose would otherwise + derive the name from the directory holding the Compose file. + arguments: The Compose subcommand and its options, e.g. ``"up", + "--detach"``. + + Raises: + PipelineError: If Docker is unavailable, the Compose file does not + exist, or Compose exits with a non-zero status. + """ + if not compose_file.is_file(): + raise PipelineError(f"Compose file not found at {compose_file}") + + command = compose_command() + subcommand = " ".join(arguments) + + context.logger.info(f"Running 'compose {subcommand}' on {compose_file} ...") + + # Currently, no solution without using subprocesses available. + exit_code = run_subprocess( + [*command, "--project-name", project_name, "--file", str(compose_file), *arguments], + Path.cwd(), + stream_output=True, + on_stdout=context.logger.info, + on_stderr=context.logger.info, + fill_stream_threaded=True, + ) + + if exit_code != 0: + raise PipelineError(f"'compose {subcommand}' failed with exit code {exit_code}") + + +def compose_command() -> list[str]: + """Determine how Docker Compose can be invoked on this machine. + + Returns: + The executable and subcommand to prefix the Compose arguments with, + preferring the ``docker compose`` plugin over the standalone binary. + + Raises: + PipelineError: If neither ``docker`` nor the legacy ``docker-compose`` + executable is on the PATH. + """ + docker_path = which("docker") + + if docker_path is not None: + return [docker_path, "compose"] + + legacy_compose_path = which("docker-compose") + + if legacy_compose_path is None: + raise PipelineError("Docker executable not found") + + return [legacy_compose_path] diff --git a/report_generator/vendor/cc_common/docker/defs.py b/report_generator/vendor/cc_common/docker/defs.py new file mode 100644 index 0000000..4f094a9 --- /dev/null +++ b/report_generator/vendor/cc_common/docker/defs.py @@ -0,0 +1,19 @@ +"""Docker-specific architecture and platform definitions.""" + +from enum import Enum + +from cc_common.defines import CPUArchitecture, OperatingSystem + +CONTAINER_DEFAULT_ABORT_TIMEOUT_IN_S = 5 + + +class DockerArchitecture(Enum): + """Enumeration of supported Docker platforms combining OS and CPU architecture.""" + + LINUX_AMD64 = (OperatingSystem.LINUX, CPUArchitecture.AMD64) + LINUX_ARM64 = (OperatingSystem.LINUX, CPUArchitecture.ARM64) + + @property + def arch_str(self) -> str: + """Return the os/cpu string which can be passed to docker command as plattform.""" + return f"{self.value[0].value}/{self.value[1].value}" diff --git a/report_generator/vendor/cc_common/docker/docker_utils.py b/report_generator/vendor/cc_common/docker/docker_utils.py new file mode 100644 index 0000000..79ccb1e --- /dev/null +++ b/report_generator/vendor/cc_common/docker/docker_utils.py @@ -0,0 +1,266 @@ +"""Provide utility functions for interacting with Docker via the Docker SDK. + +Supports image management, container execution, and environment validation +through the official docker-py client with logging integration. +""" + +import os +import shlex +import socket +from pathlib import Path +from typing import Any + +import docker +import docker.errors +from docker import DockerClient + +from cc_common.api import context + +from .defs import CONTAINER_DEFAULT_ABORT_TIMEOUT_IN_S, DockerArchitecture + + +def _get_client() -> DockerClient: + """Create and return a Docker client from the local environment. + + Raises: + docker.errors.DockerException: If the Docker daemon is unreachable. + """ + return docker.from_env() + + +def _stop_container(container_id: str, timeout_in_s: int) -> None: + """Stops a specific container by ID.""" + client = _get_client() + try: + container = client.containers.get(container_id) + context.logger.info(f"Aborting and removing container: {container_id}") + container.stop(timeout=timeout_in_s) + container.remove(force=True) + except docker.errors.NotFound: + context.logger.debug(f"Container {container_id} already removed.") + except Exception as e: + context.logger.error(f"Failed to abort container {container_id}: {e}") + + +def is_docker_running() -> bool: + """Check whether the Docker daemon is running.""" + context.logger.info("Checking if Docker is running") + try: + _get_client().ping() + return True + except docker.errors.DockerException: + return False + + +def check_image_exist(image_name: str) -> bool: + """Check whether a Docker image exists locally.""" + context.logger.info(f"Checking if image '{image_name}' exists") + try: + _get_client().images.get(image_name) + return True + except docker.errors.ImageNotFound: + return False + + +def create_docker_image( + image_name: str, + project_folder: Path, + docker_architecture: DockerArchitecture | None = None, + build_dargs: dict[str, str] | None = None, +) -> None: + """Build a Docker image from the specified project directory.""" + context.logger.info(f"Creating docker image '{image_name}' from '{project_folder}'") + + client = _get_client() + + try: + for chunk in client.api.build( + path=str(project_folder), + tag=image_name, + buildargs=build_dargs, + decode=True, # Low-level API handles this correctly + rm=True, + platform=docker_architecture.arch_str if docker_architecture else None, + ): + if not isinstance(chunk, dict): + continue + + if "stream" in chunk: + message = str(chunk["stream"]).strip() + if message: + context.logger.debug(message) + + if "error" in chunk: + error_data = chunk.get("errorDetail", {}) + error_msg = error_data.get("message", chunk["error"]) + + raise docker.errors.BuildError(error_msg, iter([chunk])) + + except docker.errors.BuildError as e: + context.logger.error(f"Docker build failed: {e}") + raise e + except Exception as e: + context.logger.error(f"An unexpected error occurred during build: {e}") + raise e + + +def _rewrite_path_in_cmd(cmd: list[str], old_path: str, new_path: str) -> list[str]: + """Replace `old_path` with `new_path` in every argument that points at it.""" + rewritten = [] + for arg in cmd: + if arg == old_path: + rewritten.append(new_path) + elif arg.startswith(f"{old_path}/"): + rewritten.append(f"{new_path}{arg[len(old_path) :]}") + else: + rewritten.append(arg) + return rewritten + + +def _wrap_cmd_with_copy(cmd: list[str], mount_folders: list[tuple[Path, str]], target_folder: str) -> list[str]: + """Wrap `cmd` so the mounted folders are copied into `target_folder` first. + + The copy is created by the container's own user, so it is writable without any + privilege escalation, and the read-only mounts stay untouched. Arguments in `cmd` + that point into a mounted folder are rewritten to the copy. Requires `sh`, `mkdir` + and `cp` in the image. + """ + copy_cmds = [] + for _, container_path in mount_folders: + target_path = f"{target_folder.rstrip('/')}/{Path(container_path).name}" + quoted_target = shlex.quote(target_path) + copy_cmds.append(f"mkdir -p {quoted_target} && cp -a {shlex.quote(container_path)}/. {quoted_target}/") + cmd = _rewrite_path_in_cmd(cmd, container_path, target_path) + + script = " && ".join([*copy_cmds, f"exec {shlex.join(cmd)}"]) + return ["sh", "-c", script] + + +def run_command( + image_name: str, + cmd: list[str], + mount_folders: list[tuple[Path, str]], + remove_after_execution: bool, + copy_mounts_to: str | None = None, +) -> str: + """Run a command inside a Docker container with optional volume mounts. + + Args: + image_name: Image to run the command in. + cmd: Command and arguments to execute. + mount_folders: Pairs of (host path, container path) to mount. + remove_after_execution: Whether the container is removed after it exits. + copy_mounts_to: Optional container folder (for example the user's home) the + mounted folders are copied into before the command runs. The mounts are + then read-only and `cmd` arguments pointing at them are rewritten to the + copy, so the command works on data owned by the container's own user. + """ + mount_mode = "ro" if copy_mounts_to else "rw" + volumes = { + str(local_path.resolve()): {"bind": container_path, "mode": mount_mode} + for local_path, container_path in mount_folders + } + + context.logger.info(f"Running command {cmd} in image '{image_name}'") + + run_cmd = _wrap_cmd_with_copy(cmd, mount_folders, copy_mounts_to) if copy_mounts_to else cmd + + output = _get_client().containers.run( + image=image_name, command=run_cmd, volumes=volumes, remove=remove_after_execution, stdout=True, stderr=True + ) + return output.decode("utf-8") + + +def run_command_with_input( + image_name: str, cmd: list[str], input_data: str, environment: dict[str, Any] | None = None +) -> str: + """Run a command inside a Docker container, streaming a string directly to its stdin.""" + context.logger.info(f"Running command {cmd} in image '{image_name}'") + client = _get_client() + + container = client.containers.create( + image=image_name, command=cmd, stdin_open=True, tty=False, environment=environment + ) + + try: + container.start() + + docker_sock = container.attach_socket(params={"stdin": 1, "stream": 1}) + + try: + fd = docker_sock.fileno() # type: ignore[union-attr] + clean_fd = os.dup(fd) + + if hasattr(docker_sock, "_response"): + try: # noqa: SIM105 + docker_sock._response.close() + except Exception as e: + context.logger.error(f"hasattr returns {e}") + docker_sock._response = None # drop the reference entirely + + family = socket.AF_UNIX if fd < 100 else socket.AF_INET + clean_sock = socket.socket(family=family, fileno=clean_fd) + + finally: + docker_sock.close() + try: + clean_sock.sendall(input_data.encode("utf-8")) + clean_sock.shutdown(socket.SHUT_WR) + finally: + clean_sock.close() + + exit_result = container.wait() + output = container.logs(stdout=True, stderr=True).decode("utf-8") + + if exit_result["StatusCode"] != 0: + raise RuntimeError(f"Container failed ({exit_result['StatusCode']}):\n{output}") + + return output + finally: + container.remove(force=True) + + +def exec_command(container_id: str, cmd: list[str]) -> str: + """Execute a command inside a running container. + + Returns: + The stdout output of the command as a decoded string. + """ + context.logger.info(f"Executing command {cmd} in container '{container_id}'") + + container = _get_client().containers.get(container_id) + exit_code, output = container.exec_run(cmd) + + if exit_code != 0: + raise docker.errors.APIError(f"Command {cmd} failed in container '{container_id}' with exit code {exit_code}") + + if isinstance(output, bytes): + return output.decode("utf-8") + + return b"".join(output).decode("utf-8") + + +def abort_container( + container_id: str | None = None, + image_name: str | None = None, + timeout_in_s: int = CONTAINER_DEFAULT_ABORT_TIMEOUT_IN_S, +) -> None: + """Forcefully stop and remove a specific container by ID.""" + if not container_id and not image_name: + context.logger.error("Neither container nor image specified.") + return + + client = _get_client() + if container_id: + _stop_container(container_id, timeout_in_s) + else: + running_containers = client.containers.list() + for container in running_containers: + if ( + image_name + and container.id + and container.image + and container.image.tags + and any(image_name in tag for tag in container.image.tags) + ): + _stop_container(container.id, timeout_in_s) diff --git a/report_generator/vendor/cc_common/processes/__init__.py b/report_generator/vendor/cc_common/processes/__init__.py new file mode 100644 index 0000000..24aae6f --- /dev/null +++ b/report_generator/vendor/cc_common/processes/__init__.py @@ -0,0 +1,5 @@ +"""Collection of CLI helper functions.""" + +from .subprocess_utils import run_subprocess # nosec B404 + +__all__ = ["run_subprocess"] diff --git a/report_generator/vendor/cc_common/processes/subprocess_utils.py b/report_generator/vendor/cc_common/processes/subprocess_utils.py new file mode 100644 index 0000000..17c9203 --- /dev/null +++ b/report_generator/vendor/cc_common/processes/subprocess_utils.py @@ -0,0 +1,72 @@ +"""Provide utilities for running and managing subprocess execution.""" + +import subprocess # nosec B404 +import threading +from collections.abc import Callable +from pathlib import Path +from typing import IO + +from cc_common.api import context + + +def run_subprocess( + command: list[str], + cwd: Path, + stream_output: bool = False, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + fill_stream_threaded: bool = False, + env: dict[str, str] | None = None, +) -> int: + """Execute a subprocess and return its exit code. + + When stream_output=True stdout and stderr are either forwarded to the on_stdout and on_stderr output. + If these are not set, the results are logged using the logging singleton. + When stream_output=False the output of the subprocess is not printed. + + Args: + command: Command and arguments to execute. + cwd: Working directory for the subprocess. + stream_output: If True, stream output line-by-line instead of capturing. + on_stdout: Callback for each stdout line; falls back to DEBUG logging. + on_stderr: Callback for each stderr line; falls back to DEBUG logging. + fill_stream_threaded: Triggers the stdout and stderr callback in two seperate threads to prevent blocking. + env: Environment variables for the subprocess; inherits current env if None. + + Returns: + Exit code of the subprocess. + """ + try: + if stream_output: + process = subprocess.Popen( # nosec B603 + command, cwd=cwd, text=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + def drain(pipe: IO[str] | None, callback: Callable[[str], None] | None) -> None: + if pipe: + for line in pipe: + if callback: + callback(line.rstrip()) + else: + context.logger.debug(line.rstrip()) + + if fill_stream_threaded: + t_out = threading.Thread(target=drain, args=(process.stdout, on_stdout)) + t_err = threading.Thread(target=drain, args=(process.stderr, on_stderr)) + t_out.start() + t_err.start() + t_out.join() + t_err.join() + else: + drain(process.stdout, on_stdout) + drain(process.stderr, on_stderr) + + process.wait() + return process.returncode + else: + result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=True, env=env) # nosec B603 + return result.returncode + + except subprocess.CalledProcessError as e: + context.logger.warning(f"Command caused an exception the accepted return codes. Message:\n{e.stdout} {e}") + return e.returncode diff --git a/report_generator/vendor/cc_common/py.typed b/report_generator/vendor/cc_common/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/report_generator/vendor/cc_common/utils/__init__.py b/report_generator/vendor/cc_common/utils/__init__.py new file mode 100644 index 0000000..6c487c3 --- /dev/null +++ b/report_generator/vendor/cc_common/utils/__init__.py @@ -0,0 +1,14 @@ +"""Collection of CLI helper functions.""" + +from .progress_manager import linear_progress, spinner +from .rich_utils import print, rich_print_to_string +from .system_detection import get_cpu_architecture, get_operating_system + +__all__ = [ + "linear_progress", + "spinner", + "print", + "rich_print_to_string", + "get_cpu_architecture", + "get_operating_system", +] diff --git a/report_generator/vendor/cc_common/utils/progress_manager.py b/report_generator/vendor/cc_common/utils/progress_manager.py new file mode 100644 index 0000000..6ce5f5b --- /dev/null +++ b/report_generator/vendor/cc_common/utils/progress_manager.py @@ -0,0 +1,57 @@ +"""Provide utilities for displaying progress bars and spinners in CLI applications.""" + +from collections.abc import Generator +from contextlib import contextmanager, nullcontext + +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + TaskID, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) + +from cc_common.api import context + + +def _create_progress() -> Progress: + """Creates a configured Rich Progress instance used for linear processes.""" + return Progress( + TextColumn("[progress.description]{task.description}"), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + BarColumn(), + MofNCompleteColumn(), + TextColumn("•"), + TimeElapsedColumn(), + TextColumn("•"), + TimeRemainingColumn(), + console=context.console, + ) + + +@contextmanager +def spinner(message: str, show_progress: bool = True) -> Generator[None]: + """Optional spinner context manager. + + Args: + message: Text shown next to the spinner. + show_progress: If False, disables spinner output. + """ + ctx = context.console.status(message, spinner="dots") if show_progress else nullcontext() + with ctx: + yield + + +@contextmanager +def linear_progress(description: str, total: int) -> Generator[tuple[Progress, TaskID]]: + """Linear progress bar context manager with a single task. + + Args: + description: Task description shown in the progress bar. + total: Total number of steps for completion tracking. + """ + with _create_progress() as p: + task = p.add_task(description, total=total) + yield p, task diff --git a/report_generator/vendor/cc_common/utils/rich_utils.py b/report_generator/vendor/cc_common/utils/rich_utils.py new file mode 100644 index 0000000..2862f2c --- /dev/null +++ b/report_generator/vendor/cc_common/utils/rich_utils.py @@ -0,0 +1,32 @@ +"""Utilities for interacting with rich.""" + +from typing import IO, Any + +from rich import print as rich_print +from rich.console import Console +from rich.text import Text + +from cc_common.api import context + + +def rich_print_to_string(*objects: Any, width: int = 150) -> str: # noqa: ANN401 + """Render rich output into a string.""" + console = Console(width=width) + with console.capture() as capture: + console.print(objects) + return str(Text.from_ansi(capture.get())) + + +def print( + *objects: Any, # noqa: ANN401 + sep: str = " ", + end: str = "\n", + file: IO[str] | None = None, + flush: bool = False, +) -> None: + """Wrapper for the rich.print function. + + This function will do nothing if the application is not running in cli mode. + """ + if context.is_cli: + rich_print(*objects, sep=sep, end=end, file=file, flush=flush) diff --git a/report_generator/vendor/cc_common/utils/system_detection.py b/report_generator/vendor/cc_common/utils/system_detection.py new file mode 100644 index 0000000..7ba9051 --- /dev/null +++ b/report_generator/vendor/cc_common/utils/system_detection.py @@ -0,0 +1,41 @@ +"""Collection of helper functions, e.g. to detect the operating system or architecture of a system.""" + +import platform + +from cc_common.defines import CPUArchitecture, OperatingSystem + + +def get_operating_system() -> OperatingSystem: + """Returns the OS of the current system. + + :raises NotImplementedError: when the operating system is not supported. + """ + os_name = platform.system().lower() + match os_name: + case "darwin": + return OperatingSystem.MACOS + case "linux": + return OperatingSystem.LINUX + case "windows": + return OperatingSystem.WINDOWS + case _: + raise NotImplementedError(f"Unknown operating system {os_name}") + + +def get_cpu_architecture() -> CPUArchitecture: + """Return the CPU architecture of the current system.""" + machine = platform.machine().lower() + + match machine: + case "x86_64" | "amd64": + return CPUArchitecture.AMD64 + + # 64-bit ARM (Apple M1/M2/M3, modern Raspberry Pis, etc. + case "arm64" | "aarch64": + return CPUArchitecture.ARM64 + + case "i386" | "i686" | "x86" | "x86_32": + return CPUArchitecture.X86 + + case _: + return CPUArchitecture.UNKNOWN diff --git a/report_generator/vendor/cipherverifier/__init__.py b/report_generator/vendor/cipherverifier/__init__.py new file mode 100644 index 0000000..6341923 --- /dev/null +++ b/report_generator/vendor/cipherverifier/__init__.py @@ -0,0 +1 @@ +"""Top-level package for the CipherVerifier Django project.""" diff --git a/report_generator/vendor/cipherverifier/core/__init__.py b/report_generator/vendor/cipherverifier/core/__init__.py new file mode 100644 index 0000000..5a88867 --- /dev/null +++ b/report_generator/vendor/cipherverifier/core/__init__.py @@ -0,0 +1 @@ +"""Core Django app for CipherVerifier.""" diff --git a/report_generator/vendor/cipherverifier/core/services/__init__.py b/report_generator/vendor/cipherverifier/core/services/__init__.py new file mode 100644 index 0000000..9890b06 --- /dev/null +++ b/report_generator/vendor/cipherverifier/core/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for pipeline orchestration.""" diff --git a/report_generator/vendor/cipherverifier/core/services/sarif.py b/report_generator/vendor/cipherverifier/core/services/sarif.py new file mode 100644 index 0000000..645832b --- /dev/null +++ b/report_generator/vendor/cipherverifier/core/services/sarif.py @@ -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, + ) diff --git a/report_generator/vendor/cipherverifier/core/services/vdr.py b/report_generator/vendor/cipherverifier/core/services/vdr.py new file mode 100644 index 0000000..dda6691 --- /dev/null +++ b/report_generator/vendor/cipherverifier/core/services/vdr.py @@ -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-`` strings. + + Args: + vulnerability: A single CycloneDX vulnerability. + + Returns: + The CWE ids, normalised to the ``CWE-`` 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-`` 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-`` 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), + ) diff --git a/report_generator/vendor/persistent_storage/__init__.py b/report_generator/vendor/persistent_storage/__init__.py new file mode 100644 index 0000000..f93fde0 --- /dev/null +++ b/report_generator/vendor/persistent_storage/__init__.py @@ -0,0 +1 @@ +"""persistent_storage - Persistent Metadata and SBOM/SARIF file storage.""" diff --git a/report_generator/vendor/persistent_storage/helper.py b/report_generator/vendor/persistent_storage/helper.py new file mode 100644 index 0000000..4d3ed47 --- /dev/null +++ b/report_generator/vendor/persistent_storage/helper.py @@ -0,0 +1,471 @@ +"""Helper functions for persistent storage.""" + +import json +from pathlib import Path +from shutil import which +from typing import TYPE_CHECKING, Any + +from rich.table import Table +from sqlalchemy import inspect + +from cc_common.api import context +from cc_common.processes import run_subprocess +from cc_common.utils import print +from persistent_storage.wrapper import Artifact, Component, CvssMetric, DependencyFinding, Scan + +if TYPE_CHECKING: + # pandas (~3s) is only needed by the DataFrame-building calls below; keep it off the import + # path (this module loads on every Django boot via orchestrator -> persistent_storage.commands) + # and import it lazily where it is actually used. + import pandas as pd + from pandas import DataFrame + +type JsonData = str | int | float | bool | None | list["JsonData"] | dict[str, "JsonData"] + +TOOL_NAME_KEYS = { + "tool_name", + "toolName", + "name", + "scanner", + "scanner_name", + "generator", + "generated_by", + "generatedBy", + "created_by", + "createdBy", + "producer", + "application", +} + +TOOL_VERSION_KEYS = { + "tool_version", + "toolVersion", + "version", + "scanner_version", + "generator_version", + "generatedByVersion", + "createdByVersion", +} + +TOOL_CONTAINER_KEYS = { + "tool", + "tools", + "scanner", + "generator", + "metadata", + "producer", + "createdBy", + "generatedBy", +} + + +def start_docker_containers() -> None: + """Starts the docker containers for development. + + Raises: + FileNotFoundError: + Raised if the docker-compose.yml file does not exist. + RuntimeError: + Raised if the docker executable cannot be found. + """ + compose_file = Path("src/persistent_storage/docker-compose.yml") + + if not compose_file.exists(): + context.logger.error(f"Compose file not found at {compose_file} ❌") + raise FileNotFoundError(f"Compose file not found at {compose_file}") + + docker_path = which("docker") + + if docker_path is not None: + command = [docker_path, "compose"] + else: + legacy_compose_path = which("docker-compose") + + if legacy_compose_path is None: + context.logger.error("Docker executable not found ❌") + raise RuntimeError("Docker executable not found ❌") + + command = [legacy_compose_path] + + command += ["-f", str(compose_file), "up", "-d", "--build"] + + run_subprocess( + command, + Path.cwd(), + ) + + +def extract_tool_information_from_sarif(file_path: Path) -> tuple[str | None, str | None]: + """Extract tool information from Sarif file.""" + with file_path.open(encoding="utf-8") as file: + data = json.load(file) + + runs = data.get("runs", []) + if not runs: + return "Unknown", "Unknown" + + driver = runs[0].get("tool", {}).get("driver", {}) + + tool_name = driver.get("name") + tool_version = driver.get("version") or driver.get("semanticVersion") or driver.get("dottedQuadFileVersion") + + return tool_name, tool_version + + +def extract_tool_information_from_cyclonedx(file_path: Path) -> tuple[str | None, str | None]: + """Extract tool information from CyclonedX file.""" + with file_path.open(encoding="utf-8") as file: + data = json.load(file) + + tools = data.get("metadata", {}).get("tools") + + if isinstance(tools, list) and tools: + tool = tools[0] + return tool.get("name"), tool.get("version") + + if isinstance(tools, dict): + components = tools.get("components", []) + if components: + tool = components[0] + return tool.get("name"), tool.get("version") + + return "Unknown", "Unknown" + + +def extract_tool_information_from_spdx(file_path: Path) -> tuple[str | None, str | None]: + """Extract tool information from SPDX file.""" + with file_path.open(encoding="utf-8") as file: + data = json.load(file) + + creators = data.get("creationInfo", {}).get("creators", []) + + for creator in creators: + if isinstance(creator, str) and creator.startswith("Tool:"): + value = creator.removeprefix("Tool:").strip() + + if "-" in value: + name, version = value.rsplit("-", 1) + return name, version + + return value, "Unknown" + + return "Unknown", "Unknown" + + +def _first_string_value(data: dict[str, Any], keys: set[str]) -> str | None: + """Return the first non-empty string value for any matching key.""" + for key in keys: + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + return None + + +def extract_tool_information_from_json(data: JsonData) -> tuple[str | None, str | None]: + """Recursively search generic JSON data for tool name and version.""" + if isinstance(data, dict): + direct_name = _first_string_value(data, TOOL_NAME_KEYS) + direct_version = _first_string_value(data, TOOL_VERSION_KEYS) + + if direct_name or direct_version: + return direct_name, direct_version + + for key in TOOL_CONTAINER_KEYS: + value = data.get(key) + tool_name, tool_version = extract_tool_information_from_json(value) + if tool_name or tool_version: + return tool_name, tool_version + + for value in data.values(): + tool_name, tool_version = extract_tool_information_from_json(value) + if tool_name or tool_version: + return tool_name, tool_version + + elif isinstance(data, list): + for item in data: + tool_name, tool_version = extract_tool_information_from_json(item) + if tool_name or tool_version: + return tool_name, tool_version + + return None, None + + +def print_rich_table(df: pd.DataFrame, table_type: str) -> None: + """Print a pandas DataFrame as a rich table.""" + str_df = df.fillna("").astype(str) + + table = Table( + show_header=True, + header_style="bold", + ) + + if table_type == "scan": + _print_rich_table_scan(str_df, table) + elif table_type == "artifact": + _print_rich_table_artifact(str_df, table) + elif table_type in ("component", "dependency_finding", "cvss"): + _print_rich_table_plain(str_df, table, table_type) + else: + raise ValueError(f"Invalid table type: {table_type}") + + print(table) + + +def _print_rich_table_scan(str_df: DataFrame, table: Table) -> None: + for column in str_df.columns: + if column == "meta_file_content": + table.add_column( + str(column), + no_wrap=False, + overflow="fold", + ) + else: + table.add_column( + str(column), + no_wrap=True, + overflow="ignore", + ) + + for row in str_df.itertuples(index=False): + values = [] + + for col, value in zip(str_df.columns, row, strict=False): + text = value + + if col == "meta_file_content": + text = text.replace("\\n", "\n") + + values.append(text) + + table.add_row(*values) + + print("\n[bold]----------Stored scan metadata entries----------[/bold]") + + +def _print_rich_table_artifact(str_df: DataFrame, table: Table) -> None: + for column in str_df.columns: + if column in ("file_name", "created_at", "artifact_metadata"): + table.add_column( + str(column), + max_width=20, + overflow="fold", + ) + else: + table.add_column(str(column)) + + for artifact_row in str_df.itertuples( + index=False, + name=None, + ): + table.add_row(*artifact_row) + + print("\n[bold]----------Stored artifacts metadata entry----------[/bold]") + + +# What each of the dependency tables is called above its rows. +PLAIN_TABLE_HEADINGS = { + "component": "Dependencies found by the scan", + "dependency_finding": "Advisories against those dependencies", + "cvss": "CVSS scorings of those advisories", +} + +# Columns long enough to need folding rather than a column of their own width. +WIDE_COLUMNS = ("purl", "description", "vector", "metrics", "cwes") + + +def _print_rich_table_plain(str_df: DataFrame, table: Table, table_type: str) -> None: + for column in str_df.columns: + if column in WIDE_COLUMNS: + table.add_column(str(column), max_width=32, overflow="fold") + else: + table.add_column(str(column)) + + for row in str_df.itertuples(index=False, name=None): + table.add_row(*row) + + print(f"\n[bold]----------{PLAIN_TABLE_HEADINGS[table_type]}----------[/bold]") + + +def print_artifact_table(artifacts: list[Artifact]) -> None: + """Create a pandas DataFrame containing artifact information for each artifact and call a print function.""" + import pandas as pd + + columns = [column.key for column in Artifact.__mapper__.columns if column.key != "s3_key"] + + df = pd.DataFrame( + [ + { + column: getattr( + artifact, + column, + ) + for column in columns + } + for artifact in artifacts + ] + ) + print_rich_table(df, table_type="artifact") + + +def print_scan_table(scans: list[Scan] | Scan) -> None: + """Create a pandas DataFrame containing scan information for each scan and call a print function.""" + import pandas as pd + + # exclude meta_file_s3_key from output + columns = [column.key for column in Scan.__mapper__.columns if column.key != "meta_file_s3_key"] + + if isinstance(scans, list): + df = pd.DataFrame([{column: getattr(scan, column) for column in columns} for scan in scans]) + else: + df = pd.DataFrame([{column: getattr(scans, column) for column in columns}]) + + print_rich_table(df, table_type="scan") + + if isinstance(scans, list): + for scan in scans: + print_artifact_table(scan.artifacts) + else: + print_artifact_table(scans.artifacts) + + +def find_values_in_file(file_content: bytes, search_key: str | tuple[str, str]) -> list[str] | str: + """Searches a JSON or SARIF byte object recursively for specific keys. + + Returns the values associated with the search key. + """ + data: JsonData = json.loads(file_content) + + found, values = _find_values(data, search_key) + + return values if found else "None" + + +def _find_values(data: JsonData, search_key: str | tuple[str, str]) -> tuple[bool, list[str]]: + """Recursively search JSON data for matching values. + + Args: + data: JSON data to search. + search_key: Either a single key or a ``(parent_key, key)`` pair. + + Returns: + A tuple containing: + - ``True`` if at least one matching value was found, otherwise ``False``. + - A list of all matching values as strings. + """ + results = ( + _find_values_by_tuple(data, search_key) + if isinstance(search_key, tuple) + else _find_values_by_key(data, search_key) + ) + + return bool(results), results + + +def _find_values_by_key(data: JsonData, search_key: str) -> list[str]: + """Recursively collect values matching a single key.""" + results: list[str] = [] + + def _search(node: JsonData) -> None: + if isinstance(node, dict): + value = node.get(search_key) + + if isinstance(value, (str, int, float)): + results.append(str(value)) + + for child in node.values(): + _search(child) + + elif isinstance(node, list): + for item in node: + _search(item) + + _search(data) + + return results + + +def _find_values_by_tuple(data: JsonData, search_key: tuple[str, str]) -> list[str]: + """Recursively collect values from nested parent/key pairs.""" + results: list[str] = [] + parent_key, key = search_key + + def _search(node: JsonData) -> None: + if isinstance(node, dict): + parent = node.get(parent_key) + + if parent is not None: + _append_tuple_matches(parent, key, results) + + for child in node.values(): + _search(child) + + return + + if isinstance(node, list): + for item in node: + _search(item) + + _search(data) + + return results + + +def _append_tuple_matches(parent: JsonData, key: str, results: list[str]) -> None: + """Append matching values from a parent object or list of objects.""" + if isinstance(parent, dict): + value = parent.get(key) + + if isinstance(value, (str, int, float)): + results.append(str(value)) + + return + + if isinstance(parent, list): + for item in parent: + if not isinstance(item, dict): + continue + + value = item.get(key) + + if isinstance(value, (str, int, float)): + results.append(str(value)) + + +def print_component_table(components: list[Component]) -> None: + """Create a pandas DataFrame of components and call a print function.""" + _print_plain_table(components, Component, "component", skipped=("id",)) + + +def print_dependency_finding_table(findings: list[DependencyFinding]) -> None: + """Create a pandas DataFrame of dependency findings and call a print function.""" + _print_plain_table(findings, DependencyFinding, "dependency_finding", skipped=("id",)) + + +def print_cvss_table(scorings: list[CvssMetric]) -> None: + """Create a pandas DataFrame of CVSS scorings and call a print function. + + The columns a scoring leaves empty are dropped rather than printed as a row of blanks: a v3 + vector has no ``authentication`` and a v2 one has no ``scope``, and showing every column of + every version at once makes the one in hand harder to read, not easier. + """ + columns = [column.key for column in inspect(CvssMetric).columns if column.key not in ("id", "cvss_id")] + columns = [column for column in columns if any(getattr(scoring, column) is not None for scoring in scorings)] + + _print_dataframe(scorings, columns, "cvss") + + +def _print_plain_table(rows: list[Any], model: type[Any], table_type: str, skipped: tuple[str, ...]) -> None: + """Render every column of a model except the ones a reader has no use for.""" + columns = [column.key for column in inspect(model).columns if column.key not in skipped] + + _print_dataframe(rows, columns, table_type) + + +def _print_dataframe(rows: list[Any], columns: list[str], table_type: str) -> None: + """Build the frame and hand it to the renderer.""" + import pandas as pd + + df = pd.DataFrame([{column: getattr(row, column) for column in columns} for row in rows]) + + print_rich_table(df, table_type=table_type) diff --git a/report_generator/vendor/persistent_storage/shared.py b/report_generator/vendor/persistent_storage/shared.py new file mode 100644 index 0000000..1435dd9 --- /dev/null +++ b/report_generator/vendor/persistent_storage/shared.py @@ -0,0 +1,101 @@ +"""Shared configuration Classes.""" + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field + +from cc_common.api import PipelineError +from cc_common.config import apply_env_defaults, load_environment + +if TYPE_CHECKING: + from botocore.client import BaseClient + + +def __getattr__(name: str) -> object: + """Lazily resolve ``boto3`` as a module attribute (PEP 562). + + boto3 costs ~2.4s to import and is only needed once an S3 client is actually built + (:attr:`S3Configuration.s3_client`). Resolving it here, rather than importing it inline in the + property, keeps ``persistent_storage.shared.boto3`` patchable the same way it always was + (``monkeypatch.setattr("persistent_storage.shared.boto3.client", ...)``) while every other + consumer of this module avoids the cost. + """ + if name == "boto3": + import boto3 + + return boto3 + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +class DatabaseConfiguration(BaseModel): + """Configuration for the SQL database.""" + + host: str = Field(description="") + port: int = Field(description="") + user: str = Field(description="") + name: str = Field(description="") + password: str | None = Field( + default=None, + description="Database password.", + exclude=True, + ) + + @property + def db_url(self) -> str: + """Construct the database url that should be used to connect to the database.""" + if self.password is None: + raise PipelineError("Database password is not set.") + return f"postgresql+psycopg2://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}" + + +class S3Configuration(BaseModel): + """Configuration for the S3 database.""" + + url: str = Field(description="") + bucket: str = Field(description="") + access_key_id: str = Field(description="") + region: str = Field(description="") + secret_access_key: str | None = Field( + default=None, + description="S3 secret access key.", + exclude=True, + ) + + @property + def s3_client(self) -> BaseClient: + """Construct a S3 client that should be used to connect to the database.""" + import boto3 + from botocore.config import Config + + if self.secret_access_key is None: + raise PipelineError("S3 secret access key is not set.") + return boto3.client( + "s3", + endpoint_url=self.url, + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + region_name=self.region, + # we need when_required, because garage does not support checksums for multipart files (see https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/787) + config=Config(request_checksum_calculation="when_required", response_checksum_validation="when_required"), + ) + + def __hash__(self) -> int: + """Basic hash implementation on self.""" + return hash(repr(self)) + + +class StorageConfiguration(BaseModel): + """Container for both database configurations.""" + + database: DatabaseConfiguration + s3: S3Configuration + + +def inject_secrets(config: StorageConfiguration) -> None: + """Fill in the database password and S3 secret key from their environment variables. + + Only fills in what is unset. For the standalone CLI; the orchestrator injects the same + overrides while assembling the pipeline config. + """ + load_environment() + apply_env_defaults(config, "persistent_storage.storage_config") diff --git a/report_generator/vendor/persistent_storage/utils/__init__.py b/report_generator/vendor/persistent_storage/utils/__init__.py new file mode 100644 index 0000000..59e17b2 --- /dev/null +++ b/report_generator/vendor/persistent_storage/utils/__init__.py @@ -0,0 +1 @@ +"""DB, S3 and Put utils for persistent storage.""" diff --git a/report_generator/vendor/persistent_storage/utils/cvss_utils.py b/report_generator/vendor/persistent_storage/utils/cvss_utils.py new file mode 100644 index 0000000..16d798d --- /dev/null +++ b/report_generator/vendor/persistent_storage/utils/cvss_utils.py @@ -0,0 +1,492 @@ +"""Reads a CVSS vector of any generation and says what it carries. + +Three generations are in circulation at once and a single advisory regularly states two of them, so +nothing here assumes a version -- the vector names its own, and everything after that follows from +the version it named. + +What changed between them, and why one parser cannot pretend they are the same: + +* **v2 (2007)** scores the whole system. It asks how often an attacker has to authenticate (``Au``) + and grades impact as none, partial or complete. +* **v3 (2015, 3.1 in 2019)** scores the vulnerable component instead. ``Au`` becomes ``PR``, which + asks what privileges are needed rather than how many logins; ``UI`` and ``S`` are new; impact + becomes none, low or high; ``AC`` loses its middle value. +* **v4 (2023)** splits impact in two -- the vulnerable system (``VC``/``VI``/``VA``) and whatever + sits behind it (``SC``/``SI``/``SA``) -- which makes ``S`` unnecessary. ``AT`` is new, ``UI`` gains + a passive value, and the temporal group becomes the threat group. + +This module holds no database and no framework, so that the same reading serves persistent_storage +today and archon when the tables move there. Everything that touches a session lives next to the +model instead. + + >>> reading = parse_vector("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + >>> reading.version, reading.reachability, reading.remotely_reachable + ('3.1', 4, True) +""" + +from __future__ import annotations + +from typing import Any, NamedTuple + +# A vector that names no version is a v2 one: v2 is the only generation that did not prefix itself. +DEFAULT_VERSION = "2.0" + +# Which generation a version string belongs to, which is what decides how its metrics are read. +GENERATIONS = {"2.0": 2, "3.0": 3, "3.1": 3, "4.0": 4} + +# The metric that says where an attacker has to be, and the value that puts them anywhere. +FROM_ANYWHERE = "N" + +# What each generation calls "no obstacle at all" for the four metrics an attacker has to get past. +# Answering all four with these is an attack that needs nothing: no position, no effort, no account +# and nobody to fool. v2 has no user interaction metric, so it is scored over three. +# TODO das sollte ich noch refaktoren das ist unübersichtlich +# AV: Access Vector, AC: Access Complexity, Au: Authentication +EASIEST: dict[int, dict[str, str]] = { + 2: {"AV": "N", "AC": "L", "Au": "N"}, + 3: {"AV": "N", "AC": "L", "PR": "N", "UI": "N"}, + 4: {"AV": "N", "AC": "L", "PR": "N", "UI": "N"}, +} + +# What every abbreviation means, per generation, for anything that puts a reading before a person. +# Taken from the specifications; the weights behind them are the scoring formula's business and not +# this module's, which reads a vector rather than recomputing the score from it. +VOCABULARY: dict[int, dict[str, dict[str, str]]] = { + 2: { + "AV": {"L": "Local", "A": "Adjacent network", "N": "Network"}, + "AC": {"H": "High", "M": "Medium", "L": "Low"}, + "Au": {"M": "Multiple", "S": "Single", "N": "None"}, + "C": {"N": "None", "P": "Partial", "C": "Complete"}, + "I": {"N": "None", "P": "Partial", "C": "Complete"}, + "A": {"N": "None", "P": "Partial", "C": "Complete"}, + "E": {"U": "Unproven", "POC": "Proof of concept", "F": "Functional", "H": "High", "ND": "Not defined"}, + "RL": {"OF": "Official fix", "TF": "Temporary fix", "W": "Workaround", "U": "Unavailable", "ND": "Not defined"}, + "RC": {"UC": "Unconfirmed", "UR": "Uncorroborated", "C": "Confirmed", "ND": "Not defined"}, + }, + 3: { + "AV": {"N": "Network", "A": "Adjacent network", "L": "Local", "P": "Physical"}, + "AC": {"L": "Low", "H": "High"}, + "PR": {"N": "None", "L": "Low", "H": "High"}, + "UI": {"N": "None", "R": "Required"}, + "S": {"U": "Unchanged", "C": "Changed"}, + "C": {"N": "None", "L": "Low", "H": "High"}, + "I": {"N": "None", "L": "Low", "H": "High"}, + "A": {"N": "None", "L": "Low", "H": "High"}, + "E": {"X": "Not defined", "U": "Unproven", "P": "Proof of concept", "F": "Functional", "H": "High"}, + "RL": {"X": "Not defined", "O": "Official fix", "T": "Temporary fix", "W": "Workaround", "U": "Unavailable"}, + "RC": {"X": "Not defined", "U": "Unknown", "R": "Reasonable", "C": "Confirmed"}, + }, + 4: { + "AV": {"N": "Network", "A": "Adjacent network", "L": "Local", "P": "Physical"}, + "AC": {"L": "Low", "H": "High"}, + "AT": {"N": "None", "P": "Present"}, + "PR": {"N": "None", "L": "Low", "H": "High"}, + "UI": {"N": "None", "P": "Passive", "A": "Active"}, + "VC": {"H": "High", "L": "Low", "N": "None"}, + "VI": {"H": "High", "L": "Low", "N": "None"}, + "VA": {"H": "High", "L": "Low", "N": "None"}, + "SC": {"H": "High", "L": "Low", "N": "None"}, + "SI": {"H": "High", "L": "Low", "N": "None"}, + "SA": {"H": "High", "L": "Low", "N": "None"}, + "E": {"X": "Not defined", "A": "Attacked", "P": "Proof of concept", "U": "Unreported"}, + }, +} + +# Which key carries the impact on the vulnerable system, per generation. v4 moved it aside to make +# room for a second set covering whatever sits behind that system. +IMPACT_KEYS = {2: ("C", "I", "A"), 3: ("C", "I", "A"), 4: ("VC", "VI", "VA")} + +# The second set, which only v4 has. +SUBSEQUENT_KEYS = ("SC", "SI", "SA") + +# What the base score is called once it is a word. v2 stops at high; v3 added critical, and pushed +# the same vulnerability further up the scale than v2 would have put it. +SEVERITY_BANDS = { + 2: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (10.0, "High")), + 3: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (9.0, "High"), (10.0, "Critical")), + 4: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (9.0, "High"), (10.0, "Critical")), +} + +# What each value is worth when a set of findings has to be averaged into one number per axis. +# +# The base metrics carry the specification's own constants. They are not comparable between +# generations as written -- v2 runs its access vector to 1.0 where v3 stops at 0.85 -- so +# :func:`weight` divides by the largest value of that metric in that generation. What comes back is +# "how far towards the worst case this value sits", 0 to 1, which does compare. +# +# The threat metrics do not carry them. ``E``, ``RL`` and ``RC`` are multipliers in the +# specification, deliberately clustered between 0.91 and 1.0 so that they can only nudge a base +# score. Averaging those would produce a number that is always 0.95 and says nothing, so they are +# ranked by what they actually mean instead: an unproven exploit at 0, one under active attack at 1; +# an official fix at 0, nothing available at 1. Both directions agree with the base metrics -- +# larger is always worse. +WEIGHTS: dict[int, dict[str, dict[str, float]]] = { + 2: { + "AV": {"L": 0.395, "A": 0.646, "N": 1.0}, + "AC": {"H": 0.35, "M": 0.61, "L": 0.71}, + "Au": {"M": 0.45, "S": 0.56, "N": 0.704}, + "C": {"N": 0.0, "P": 0.275, "C": 0.660}, + "I": {"N": 0.0, "P": 0.275, "C": 0.660}, + "A": {"N": 0.0, "P": 0.275, "C": 0.660}, + "E": {"U": 0.0, "POC": 0.5, "F": 0.85, "H": 1.0}, + "RL": {"OF": 0.0, "TF": 0.33, "W": 0.67, "U": 1.0}, + "RC": {"UC": 1.0, "UR": 0.5, "C": 0.0}, + }, + 3: { + "AV": {"P": 0.2, "L": 0.55, "A": 0.62, "N": 0.85}, + "AC": {"H": 0.44, "L": 0.77}, + "PR": {"H": 0.27, "L": 0.62, "N": 0.85}, + "UI": {"R": 0.62, "N": 0.85}, + "C": {"N": 0.0, "L": 0.22, "H": 0.56}, + "I": {"N": 0.0, "L": 0.22, "H": 0.56}, + "A": {"N": 0.0, "L": 0.22, "H": 0.56}, + "E": {"U": 0.0, "P": 0.5, "F": 0.85, "H": 1.0}, + "RL": {"O": 0.0, "T": 0.33, "W": 0.67, "U": 1.0}, + "RC": {"U": 1.0, "R": 0.5, "C": 0.0}, + }, + # v4 scores by lookup table rather than per-metric constants, so there are none to quote. The + # metrics it shares with v3 keep v3's, which preserves their ordering and their spacing; the two + # values v4 added are placed by where the specification ranks them -- ``AT:P`` is an extra + # condition to meet, like a high complexity, and a passive interaction sits between none and an + # active one. + 4: { + "AV": {"P": 0.2, "L": 0.55, "A": 0.62, "N": 0.85}, + "AC": {"H": 0.44, "L": 0.77}, + "AT": {"P": 0.44, "N": 0.85}, + "PR": {"H": 0.27, "L": 0.62, "N": 0.85}, + "UI": {"A": 0.62, "P": 0.73, "N": 0.85}, + "VC": {"N": 0.0, "L": 0.22, "H": 0.56}, + "VI": {"N": 0.0, "L": 0.22, "H": 0.56}, + "VA": {"N": 0.0, "L": 0.22, "H": 0.56}, + "SC": {"N": 0.0, "L": 0.22, "H": 0.56}, + "SI": {"N": 0.0, "L": 0.22, "H": 0.56}, + "SA": {"N": 0.0, "L": 0.22, "H": 0.56}, + "E": {"U": 0.0, "P": 0.5, "A": 1.0}, + }, +} + +# The value every generation uses for "not stated", which is not a value and must not be averaged. +NOT_DEFINED = ("X", "ND") + +# Widths a reading is stored in, so a malformed vector cannot fail an insert. +MAX_VECTOR_LENGTH = 512 +MAX_METRIC_LENGTH = 16 +MAX_VERSION_LENGTH = 8 + + +class CvssReading(NamedTuple): + """What one CVSS vector states, and the two things that follow from it. + + Only the metrics a generation actually has are filled; the rest stay ``None``, because a metric + a version does not define is not the same as one it defines as absent. + + Attributes: + version: The version the vector named, e.g. ``"3.1"``. + generation: ``2``, ``3`` or ``4`` -- which set of metrics applies. + vector: The vector as the report wrote it. + metrics: Every metric of the vector by its abbreviation, including the + environmental and supplemental ones this class has no field for. + attack_vector: ``AV``, in every generation. + attack_complexity: ``AC``, in every generation. + attack_requirements: ``AT``, v4 only. + authentication: ``Au``, v2 only -- how often an attacker has to log in. + privileges_required: ``PR``, v3 and v4 -- what account they need instead. + user_interaction: ``UI``, v3 and v4. + scope: ``S``, v3 only; v4 replaced it with a second impact set. + confidentiality_impact: ``C`` in v2 and v3, ``VC`` in v4. + integrity_impact: ``I`` in v2 and v3, ``VI`` in v4. + availability_impact: ``A`` in v2 and v3, ``VA`` in v4. + subsequent_confidentiality_impact: ``SC``, v4 only. + subsequent_integrity_impact: ``SI``, v4 only. + subsequent_availability_impact: ``SA``, v4 only. + exploit_maturity: ``E`` -- temporal in v2 and v3, threat in v4. + remediation_level: ``RL``, v2 and v3. + report_confidence: ``RC``, v2 and v3. + reachability: How many of the metrics an attacker has to get past sit at + the value that costs them nothing, out of :func:`max_reachability`. + remotely_reachable: Over the network and without an account, which is the + pair that decides whether the internet is in a position to try. + """ + + version: str + generation: int + vector: str + metrics: dict[str, str] + attack_vector: str | None + attack_complexity: str | None + attack_requirements: str | None + authentication: str | None + privileges_required: str | None + user_interaction: str | None + scope: str | None + confidentiality_impact: str | None + integrity_impact: str | None + availability_impact: str | None + subsequent_confidentiality_impact: str | None + subsequent_integrity_impact: str | None + subsequent_availability_impact: str | None + exploit_maturity: str | None + remediation_level: str | None + report_confidence: str | None + reachability: int + remotely_reachable: bool + + +def parse_vector(vector: str | None) -> CvssReading | None: + """Read a CVSS vector of any generation. + + Args: + vector: A vector string, e.g. ``CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`` + or a v2 one, which carries no prefix: ``AV:N/AC:L/Au:N/C:P/I:P/A:P``. + + Returns: + What the vector states, or ``None`` when there is no vector or it names + none of the metrics its generation defines. ``None`` means "not stated", + which is not the same as "poses no risk", so the two are kept apart. + """ + if not vector: + return None + + version = _version(vector) + generation = GENERATIONS.get(version, 3) + metrics = _metrics(vector, generation) + + if not metrics: + return None + + confidentiality, integrity, availability = IMPACT_KEYS[generation] + + return CvssReading( + version=version, + generation=generation, + vector=vector[:MAX_VECTOR_LENGTH], + metrics=metrics, + attack_vector=metrics.get("AV"), + attack_complexity=metrics.get("AC"), + attack_requirements=metrics.get("AT"), + authentication=metrics.get("Au"), + privileges_required=metrics.get("PR"), + user_interaction=metrics.get("UI"), + scope=metrics.get("S") if generation == 3 else None, + confidentiality_impact=metrics.get(confidentiality), + integrity_impact=metrics.get(integrity), + availability_impact=metrics.get(availability), + subsequent_confidentiality_impact=metrics.get("SC"), + subsequent_integrity_impact=metrics.get("SI"), + subsequent_availability_impact=metrics.get("SA"), + exploit_maturity=metrics.get("E"), + remediation_level=metrics.get("RL"), + report_confidence=metrics.get("RC"), + reachability=sum(metrics.get(key) == value for key, value in EASIEST[generation].items()), + # A metric the vector does not state is not counted as an obstacle either, so an absent one + # leaves the claim unmade rather than making it falsely. + remotely_reachable=_remotely_reachable(metrics, generation), + ) + + +def max_reachability(generation: int) -> int: + """How many obstacles a generation knows about. + + Args: + generation: ``2``, ``3`` or ``4``. + + Returns: + Three for v2, which has no user interaction metric, four otherwise. A + reading is only comparable with another of the same generation, which is + why this is asked for rather than assumed. + """ + return len(EASIEST.get(generation, EASIEST[3])) + + +def severity_band(score: float | None, generation: int) -> str | None: + """Return the word a base score falls under. + + Args: + score: The base score, if the report stated one. + generation: Which scale to read it against -- v2 stops at high, v3 and v4 + added critical, so the same number is not the same word. + + Returns: + The band, or ``None`` when there is no score. + """ + if score is None: + return None + + for limit, name in SEVERITY_BANDS.get(generation, SEVERITY_BANDS[3]): + if score <= limit: + return name + + return SEVERITY_BANDS[generation][-1][1] + + +def weight(metric: str, value: str | None, generation: int = 3) -> float | None: + """Return how far towards the worst case one metric value sits, 0 to 1. + + # TODO hier nochmal das Gewichten explizit prüfen. + This is what makes a set of findings averageable along one axis, and what makes a v2 reading + averageable together with a v4 one: each value is placed between the best and the worst its own + generation allows for that metric, so the result says "how far towards the worst case" rather + than quoting a constant that only means something inside its own formula. + + Stretched across the axis rather than divided by its top, because the specification's constants + are multipliers and do not start at zero: an attack that needs physical access is worth 0.2 of + the 0.85 a network one is worth, and reported as 0.24 it would say a quarter of the worst case + for what is in fact the best case the metric has. Zero here means "as hard as this metric can + make it", one means "as easy". + + Args: + metric: An abbreviation such as ``AV``, ``PR`` or ``VC``. + value: The value the vector carried, if any. + generation: Which generation's scale to read it against. + + Returns: + The position between best and worst case, or ``None`` when the vector + stated nothing, stated "not defined", or stated something the + specification does not list. All three mean the same thing here -- there + is nothing to average -- and are kept apart from a stated zero, which is + a measurement. + """ + if value is None or value in NOT_DEFINED: + return None + + scale = WEIGHTS.get(generation, {}).get(metric, {}) + stated = scale.get(value) + + if stated is None or not scale: + return None + + best, worst = min(scale.values()), max(scale.values()) + + # A metric with a single value says nothing about degree, so it reads as the worst case it has. + return (stated - best) / (worst - best) if worst > best else 1.0 + + +def describe(metric: str, value: str | None, generation: int = 3) -> str: + """Return the word behind one abbreviation. + + Args: + metric: An abbreviation such as ``AV``, ``PR`` or ``VC``. + value: The value the vector carried, if any. + generation: Which vocabulary to read it in. The same letter means + different things across generations -- ``C`` under v2 is "complete", + under v3 it is a metric name -- so this is not optional in practice. + + Returns: + The name the specification gives it, the raw value when the vocabulary + has no entry, or ``"Not stated"`` when the vector did not carry it. + """ + if value is None: + return "Not stated" + + return VOCABULARY.get(generation, {}).get(metric, {}).get(value, value) + + +def _version(vector: str) -> str: + """Return the version a vector names. + + Args: + vector: The vector string. v3 and v4 begin with ``CVSS:``; v2 + begins with its first metric and is recognised by that absence. + + Returns: + The version, defaulting to :data:`DEFAULT_VERSION` for an unprefixed + vector and to the string as written for one this module does not know -- + a version nobody has implemented yet is still worth recording. + """ + head = vector.split("/", 1)[0] + + if not head.upper().startswith("CVSS:"): + return DEFAULT_VERSION + + return head.split(":", 1)[1].strip()[:MAX_VERSION_LENGTH] or DEFAULT_VERSION + + +def _metrics(vector: str, generation: int) -> dict[str, str]: + """Split a vector into its metrics. + + Args: + vector: The vector string. + generation: Which generation it belongs to, which decides whether the + first segment is a version prefix to skip. + + Returns: + The metrics by their abbreviation, spelled as the specification spells + them -- ``Au`` keeps its lower-case second letter, which is the one + abbreviation that is not upper case. A segment that is not a + ``key:value`` pair is ignored rather than raising: a report this store + cannot fully read is still a report worth keeping. + """ + segments = vector.split("/") + metrics: dict[str, str] = {} + + for segment in segments if generation == 2 else segments[1:]: + key, separator, value = segment.partition(":") + + if not separator or not key or not value: + continue + + name = key.strip()[:MAX_METRIC_LENGTH] + metrics["Au" if name.upper() == "AU" and generation == 2 else name.upper()] = value.strip().upper()[ + :MAX_METRIC_LENGTH + ] + + return metrics + + +def _remotely_reachable(metrics: dict[str, str], generation: int) -> bool: + """Whether the vector says an attacker needs neither a position nor an account. + + Args: + metrics: The metrics of the vector. + generation: Which generation states the account requirement, since v2 + asks how often an attacker logs in and v3 onwards what they log in + as. + + Returns: + ``True`` only when the vector states both and both are open. + """ + account = "Au" if generation == 2 else "PR" + + return metrics.get("AV") == FROM_ANYWHERE and metrics.get(account) == "N" + + +def as_row(reading: CvssReading, base_score: float | None = None) -> dict[str, Any]: + """Return a reading shaped like the columns it is stored in. + + Kept here rather than next to the model so that the move to archon is a change of table and not + a change of mapping: whatever holds these columns can be filled from this. + + Args: + reading: What a vector stated. + base_score: The score the report stated alongside the vector, if any. + Not recomputed from the vector -- the report's own number is the one + that has to come back out of the store. + + Returns: + The reading by column name, ready to be handed to a model. + """ + return { + "version": reading.version, + "vector": reading.vector, + "base_score": base_score, + "base_severity": severity_band(base_score, reading.generation), + "attack_vector": reading.attack_vector, + "attack_complexity": reading.attack_complexity, + "attack_requirements": reading.attack_requirements, + "authentication": reading.authentication, + "privileges_required": reading.privileges_required, + "user_interaction": reading.user_interaction, + "scope": reading.scope, + "confidentiality_impact": reading.confidentiality_impact, + "integrity_impact": reading.integrity_impact, + "availability_impact": reading.availability_impact, + "subsequent_confidentiality_impact": reading.subsequent_confidentiality_impact, + "subsequent_integrity_impact": reading.subsequent_integrity_impact, + "subsequent_availability_impact": reading.subsequent_availability_impact, + "exploit_maturity": reading.exploit_maturity, + "remediation_level": reading.remediation_level, + "report_confidence": reading.report_confidence, + "metrics": reading.metrics, + "reachability": reading.reachability, + "remotely_reachable": reading.remotely_reachable, + } diff --git a/report_generator/vendor/persistent_storage/utils/db_utils.py b/report_generator/vendor/persistent_storage/utils/db_utils.py new file mode 100644 index 0000000..1693e5b --- /dev/null +++ b/report_generator/vendor/persistent_storage/utils/db_utils.py @@ -0,0 +1,324 @@ +"""Utilities for configuring and interacting with the artifact metadata database. + +This module defines the database schema for artifact records, initializes the +database connection and tables, and provides functionality for storing and +retrieving artifact metadata. +""" + +from uuid import UUID + +import sqlalchemy +from sqlalchemy import create_engine, delete, select +from sqlalchemy.orm import Session, selectinload, sessionmaker + +from cc_common.api import context +from persistent_storage.helper import print_scan_table +from persistent_storage.shared import DatabaseConfiguration +from persistent_storage.wrapper import Artifact, Base, Scan + + +def get_session(configuration: DatabaseConfiguration) -> sqlalchemy.orm.Session: + """Create and return a new SQLAlchemy session bound to the application's engine. + + The session is configured using the engine returned by + `create_engine()` and can be used to interact with the database. + + Returns: + sqlalchemy.orm.Session: A new SQLAlchemy session instance. + """ + engine = create_engine(configuration.db_url) + session = sessionmaker(bind=engine, expire_on_commit=False) + + return session() + + +def init_db(configuration: DatabaseConfiguration) -> None: + """Initialize the application's database schema. + + Creates all tables defined in the SQLAlchemy metadata using the + engine returned by `create_engine()`. Existing tables are + left unchanged. + + This function should typically be called during application startup + or initial setup. + """ + engine = create_engine(configuration.db_url) + Base.metadata.create_all(engine) + + +def save_object_to_db(instance: Base, configuration: DatabaseConfiguration) -> None: + """Persist artifact metadata to the database. + + Creates a new Artifact record from validated metadata, + stores it in the database, and commits the transaction. + + Args: + instance (Base): + Generic Base Object for Scan and Artifact Records. + + configuration: + The configuration parameters of the db. + + Side Effects: + - Inserts a new artifact entry into the database. + - Commits transaction. + """ + session: Session = get_session(configuration) + + try: + session.add(instance) + session.commit() + + except Exception as e: + session.rollback() + context.logger.error(f"Failed to save artifact: {e}") + raise + + finally: + session.close() + + +def get_scan(configuration: DatabaseConfiguration, scan_id: UUID) -> Scan: + """Retrieve a scan and its associated artifacts from the database. + + Args: + configuration: Database configuration containing the connection URL. + scan_id: UUID of the scan to retrieve. + + Returns: + The matching ``Scan`` instance with its artifacts eagerly loaded. + + Raises: + sqlalchemy.exc.NoResultFound: If no scan with the given UUID exists. + sqlalchemy.exc.MultipleResultsFound: If multiple scans with the given + UUID are found. + """ + engine = create_engine(configuration.db_url) + + try: + with Session(engine) as session: + stmt = select(Scan).where(Scan.scan_id == scan_id).options(selectinload(Scan.artifacts)) + return session.scalars(stmt).one() + finally: + engine.dispose() + + +class ScanList(list[Scan]): + """A list of ``Scan`` objects with convenience methods for presentation.""" + + def print_as_cc_common_result(self) -> None: + """Print the scans as a rich table.""" + print_scan_table(self) + + +def get_all_scans(configuration: DatabaseConfiguration) -> list[Scan]: + """Retrieve all scans and their associated artifacts from the database. + + Args: + configuration: Database configuration containing the connection URL. + + Returns: + A ``ScanList`` containing all scans with their artifacts eagerly loaded. + """ + engine = create_engine(configuration.db_url) + + try: + with Session(engine) as session: + stmt = select(Scan).options(selectinload(Scan.artifacts)) + scans = session.scalars(stmt).all() + return ScanList(scans) + finally: + engine.dispose() + + +# DROP DB ENTRY +def drop_database_entry(configuration: DatabaseConfiguration, database_entry_uuid: UUID) -> bool: + """Delete a specific database entry by UUID. + + Removes only the matching database entry from the artifact table. + + Args: + database_entry_uuid: + UUID of the database entry to delete. + + configuration: + The configuration parameters of the db. + + Returns: + bool: + The value shows if the deletion was successful. + + Side Effects: + - Permanently deletes a single database row + - Prints deletion progress information to stdout + """ + engine = create_engine(configuration.db_url) + success = False + + try: + with engine.begin() as conn: + stmt = delete(Scan).where(Scan.scan_id == database_entry_uuid) + + result = conn.execute(stmt) + + if result.rowcount == 0: + context.logger.warning( + f"No entry found for UUID {database_entry_uuid}", + ) + + else: + context.logger.warning( + f"Scan Entry {database_entry_uuid} deleted ⚠", + ) + + success = True + + finally: + engine.dispose() + + return success + + +# WAIT FOR POSTGRES +def wait_for_postgres(configuration: DatabaseConfiguration, max_retries: int = 10, delay: int = 1) -> None: + """Wait until the PostgreSQL server becomes available. + + Repeatedly attempts to establish a connection to the PostgreSQL + instance using the admin credentials. If the connection succeeds, + the function returns immediately. Otherwise, retries are performed + until the maximum retry limit is reached. + + Args: + max_retries (int): + Maximum number of connection attempts before failing. + + delay (int): + Delay in seconds between retry attempts. + + configuration: + The configuration parameters of the db. + + Raises: + RuntimeError: + Raised if PostgreSQL does not become available within the + configured retry limit. + """ + import time + + from sqlalchemy import create_engine + from sqlalchemy.exc import OperationalError + + context.logger.info("Waiting for PostgreSQL to become available...") + + engine = create_engine( + configuration.db_url, + isolation_level="AUTOCOMMIT", + ) + + try: + for attempt in range(max_retries): + try: + with engine.connect(): + context.logger.info("PostgreSQL is ready ✔") + + return + + except OperationalError: + context.logger.warning( + f"PostgreSQL not ready (attempt {attempt + 1}/{max_retries})", + ) + + time.sleep(delay) + + finally: + engine.dispose() + + context.logger.error( + "PostgreSQL did not become ready in time", + ) + + raise RuntimeError( + "PostgreSQL did not become ready in time", + ) + + +def get_s3_key(configuration: DatabaseConfiguration, target_artifact_id: UUID) -> str: + """Retrieves the S3 object key for a specific artifact. + + Queries the database for the artifact identified by the given UUID and + returns the corresponding S3 key used to locate the artifact file in + the configured S3 bucket. + + Args: + target_artifact_id: UUID of the artifact whose S3 key should be + retrieved. + + configuration: The configuration parameters of the db. + + Returns: + The S3 object key associated with the artifact. + + Raises: + ValueError: If no artifact with the specified UUID exists or no + valid S3 key is found. + """ + from sqlalchemy import create_engine, select + from sqlalchemy.orm import Session + + engine = create_engine(configuration.db_url) + + try: + with Session(engine) as session: + s3_key = session.scalar(select(Artifact.s3_key).where(Artifact.artifact_id == target_artifact_id)) + + if s3_key is None or not isinstance(s3_key, str): + context.logger.error(f"Artifact {target_artifact_id} not found ❌") + raise ValueError(f"Artifact {target_artifact_id} not found ❌") + else: + return str(s3_key) + finally: + engine.dispose() + + +def get_s3_keys(configuration: DatabaseConfiguration, target_scan_id: UUID) -> list[str]: + """Retrieves the S3 object keys for all artifacts belonging to a scan. + + Queries the database for all artifacts associated with the specified + scan UUID and returns their corresponding S3 object keys. + + Args: + target_scan_id: UUID of the scan whose artifact S3 keys should be + retrieved. + + configuration: The configuration parameters of the db. + + Returns: + A list containing the S3 object keys of all artifacts associated + with the scan. + + Raises: + ValueError: If no artifact files are associated with the specified + scan UUID or if an invalid S3 key is encountered. + """ + from sqlalchemy import create_engine, select + from sqlalchemy.orm import Session + + engine = create_engine(configuration.db_url) + + try: + with Session(engine) as session: + s3_keys: list[str] = list( + session.scalars(select(Artifact.s3_key).where(Artifact.scan_id == target_scan_id)) + ) + if len(s3_keys) == 0: + context.logger.error(f"No artifact files for scan UUID '{target_scan_id}' found ❌") + raise ValueError(f"No artifact files for scan UUID '{target_scan_id}' found ❌") + + for s3_key in s3_keys: + if not isinstance(s3_key, str): + context.logger.error(f"Invalid S3 key for scan UUID '{target_scan_id}' ❌") + raise ValueError(f"Invalid S3 key for scan UUID '{target_scan_id}' ❌") + + return s3_keys + finally: + engine.dispose() diff --git a/report_generator/vendor/persistent_storage/utils/s3_utils.py b/report_generator/vendor/persistent_storage/utils/s3_utils.py new file mode 100644 index 0000000..93866d3 --- /dev/null +++ b/report_generator/vendor/persistent_storage/utils/s3_utils.py @@ -0,0 +1,170 @@ +"""Utilities for managing artifact storage in an S3-compatible object store. + +This module provides helper functions for: + +- Ensuring the configured S3 bucket exists +- Uploading artifacts to object storage +""" + +from typing import TYPE_CHECKING, cast +from uuid import UUID + +from botocore.exceptions import ClientError + +from cc_common.api import context +from persistent_storage.shared import S3Configuration, StorageConfiguration +from persistent_storage.utils.db_utils import get_s3_key + +if TYPE_CHECKING: + # mypy_boto3_s3 drags in boto3 (~1.2s) purely for these type stubs; the runtime object it + # names is never used (typing.cast() is a no-op that just returns its second argument), so + # the callers below reference it as a string forward-ref instead. + from mypy_boto3_s3 import S3Client + from mypy_boto3_s3.type_defs import ObjectIdentifierTypeDef + + +def ensure_bucket_exists(configuration: S3Configuration) -> None: + """Ensure that the configured S3 bucket exists. + + Checks whether the configured bucket is available in the connected + S3-compatible storage service. If the bucket does not exist, it is + created automatically. + + Side Effects: + - May create a new S3 bucket + - Prints bucket status information to stdout + """ + try: + s3_client = cast("S3Client", configuration.s3_client) + s3_client.create_bucket(Bucket=configuration.bucket) + + context.logger.warning(f"Bucket '{configuration.bucket}' created ✔") + + except ClientError as e: + code = e.response["Error"].get("Code") + + if code in ("BucketAlreadyOwnedByYou", "BucketAlreadyExists"): + context.logger.warning(f"Bucket '{configuration.bucket}' already exists") + return + + raise + + +def upload_file(configuration: S3Configuration, path: str, s3_key: str) -> None: + """Upload a local artifact file to the configured S3 bucket. + + Args: + path (str): + Local filesystem path to the artifact or meta file. + + s3_key (str): + Object key under which the artifact or meta file will be stored + in the S3 bucket. + + configuration (StorageConfiguration): + The configuration parameters for the S3 storage. + + Side Effects: + - Uploads a file to S3-compatible object storage + - Prints upload status information to Logger + """ + s3_client = cast("S3Client", configuration.s3_client) + s3_client.upload_file(str(path), configuration.bucket, s3_key) + + context.logger.info(f"Uploaded {path} -> s3://{configuration.bucket}/{s3_key}") + + +def open_rustfs_bucket(configuration: S3Configuration) -> None: + """Open the RustFS bucket in the RustFS web console.""" + import webbrowser + + webbrowser.open(configuration.url[:-1] + "1") # Port 9001 + + +def fetch_artifact_file(configuration: StorageConfiguration, artifact_id: UUID) -> bytes: + """Retrieves one artifact files from the configured S3 bucket. + + The corresponding artifact, associated with artifact_id is fetched and + returned as a single bytes object. + + Args: + artifact_id: Unique identifier of the artifact to retrieve. + configuration: The configuration parameters for the S3 storage. + + Returns: + The artifact as a bytes-like object. + """ + s3_client = cast("S3Client", configuration.s3.s3_client) + + response = s3_client.get_object( + Bucket=configuration.s3.bucket, + Key=get_s3_key(configuration=configuration.database, target_artifact_id=artifact_id), + ) + return response["Body"].read() + + +def fetch_artifact_files( + configuration: StorageConfiguration, artifact_ids: list[UUID] +) -> tuple[list[bytes], list[str]]: # noqa: E501 + """Retrieves multiple artifact files from the configured S3 bucket. + + All files associated with the scan are fetched and returned as a list of byte + objects and a list of file names. + + Args: + artifact_ids: All unique UUIDs of the scan to retrieve. + configuration: The configuration parameters for the S3 storage. + + Returns: + A list[bytes] with all artifacts of a scan as bytes-like objects. + A list[str] with all artifact file names. + """ + s3_client = cast("S3Client", configuration.s3.s3_client) + files = [] + file_names = [] + + for artifact_id in artifact_ids: + s3_key = get_s3_key(configuration=configuration.database, target_artifact_id=artifact_id) + response = s3_client.get_object( + Bucket=configuration.s3.bucket, + Key=s3_key, + ) + file = response["Body"].read() + files.append(file) + file_name = s3_key.split("/")[-1] + file_names.append(file_name) + + return files, file_names + + +def drop_storage_files(configuration: S3Configuration, target_scan_id: UUID) -> bool: + """Delete all S3 objects associated with a scan. + + Deletes every object in the configured S3 bucket whose key has the prefix + ``/``. + + Args: + configuration: S3 configuration containing the bucket name and client. + target_scan_id: Identifier of the scan whose stored files should be removed. + + Returns: + ``True`` if all matching objects were deleted successfully, otherwise + ``False`` if an AWS ``ClientError`` was raised. + """ + s3_client = cast("S3Client", configuration.s3_client) + prefix = f"{target_scan_id}/" + paginator = s3_client.get_paginator("list_objects_v2") + + try: + for page in paginator.paginate(Bucket=configuration.bucket, Prefix=prefix): + if "Contents" not in page: + continue + + objects: list[ObjectIdentifierTypeDef] = [{"Key": obj["Key"]} for obj in page["Contents"]] + + s3_client.delete_objects(Bucket=configuration.bucket, Delete={"Objects": objects}) + return True + + except ClientError as e: + context.logger.error(e) + return False diff --git a/report_generator/vendor/persistent_storage/wrapper.py b/report_generator/vendor/persistent_storage/wrapper.py new file mode 100644 index 0000000..a8726d1 --- /dev/null +++ b/report_generator/vendor/persistent_storage/wrapper.py @@ -0,0 +1,744 @@ +"""Wrapper for database storage.""" + +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import ( + JSON, + TIMESTAMP, + Boolean, + Float, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + """Base model for SQLAlchemy models.""" + + pass + + +class Artifact(Base): + """SQLAlchemy model representing a stored artifact. + + This table stores metadata about files that are generated or uploaded + as part of a project. Each artifact is linked to a scan and stored + in S3-compatible object storage. + + Attributes: + id (int): + Primary key of the artifact record. + + artifact_id (UUID): + Unique identifier of the artifact. + + artifact_type (str): + Type/category of the artifact (e.g. SARIF, SBOM). + + s3_key (str): + Object storage key used to locate the file in S3-compatible storage. + + file_name (str | None): + Original filename of the uploaded artifact, if available. + + created_at (datetime): + Timestamp when the artifact record was created (UTC). + + artifact_metadata (dict | None): + Optional JSON metadata associated with the artifact, + such as file size or additional computed attributes. + + scan_id (UUID): + Foreign key linked to the unique identifier of the scan record. + """ + + __tablename__ = "artifacts" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + ) + + artifact_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + unique=True, + nullable=False, + ) + + artifact_type: Mapped[str] = mapped_column( + String, + nullable=False, + ) + + s3_key: Mapped[str] = mapped_column( + String, + nullable=False, + ) + + file_name: Mapped[str | None] = mapped_column( + String, + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP, + nullable=False, + ) + + artifact_metadata: Mapped[str | None] = mapped_column( + JSON, + nullable=True, + ) + + # On delete cascade + scan_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("scans.scan_id", ondelete="CASCADE"), + nullable=False, + ) + + # ORM relationship to scans + scan: Mapped[Scan] = relationship( + "Scan", + back_populates="artifacts", + ) + + def print_as_cc_common_result(self) -> None: + """Prints the artifact objects content.""" + from .helper import print_artifact_table + + print_artifact_table([self]) + + +class Scan(Base): + """SQLAlchemy model representing a scan execution. + + This table stores metadata about a single scan run, including + information about the tool that produced the scan, execution time, + and the associated meta file stored in S3-compatible + object storage. + + A scan can be associated with one or more artifacts, which reference + the scan through its unique scan identifier. + + Attributes: + id (int): + Internal database primary key. + + scan_id (UUID): + Unique identifier of the scan execution. Used as a stable + reference for related artifacts. + + run_at (datetime): + Timestamp when the scan was executed (UTC). + + tool_name (str): + Name of the tool that generated the scan. + + tool_version (int | None): + Version of the tool that generated the scan, if available. + + meta_file_s3_key (str): + Object storage key of the scan meta file stored in + S3-compatible storage. + + meta_file_content (dict[str, Any]): + Parsed content of the scan meta file stored as JSON. + """ + + __tablename__ = "scans" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + scan_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + unique=True, + nullable=False, + ) + + run_at: Mapped[datetime] = mapped_column(TIMESTAMP, default=lambda: datetime.now(UTC), nullable=False) + + tool_name: Mapped[str] = mapped_column(String, nullable=False) + + tool_version: Mapped[str] = mapped_column(String, nullable=True) + + meta_file_s3_key: Mapped[str] = mapped_column(String, unique=True, nullable=False) + + meta_file_content: Mapped[str] = mapped_column(String, nullable=False) + + # ORM relationship to artifacts + artifacts: Mapped[list[Artifact]] = relationship( + "Artifact", + back_populates="scan", + ) + + # ORM relationships to what this scan found + component_scans: Mapped[list[ComponentScan]] = relationship( + "ComponentScan", + back_populates="scan", + cascade="all, delete-orphan", + ) + + dep_finding_scans: Mapped[list[DepFindingScan]] = relationship( + "DepFindingScan", + back_populates="scan", + cascade="all, delete-orphan", + ) + + def print_as_cc_common_result(self) -> None: + """Prints the scan objects content.""" + from .helper import print_scan_table + + print_scan_table([self]) + + +class Component(Base): + """A third-party package, identified by what it is rather than by who found it. + + ``mypy 1.0`` is the same package in every scan that depends on it, so it is one row. Which scans + depend on it is a different fact, and it gets its own table -- archon calls that one + ``component_scans`` and this package will call it the same, so that the two line up when these + tables move. Until it exists, a component says what it is and nothing about where it came from. + + Mirrors archon's ``components`` table without the three columns that belong to a document rather + than to the package: ``scope`` and ``bom_ref`` are what one SBOM said about it in one place, and + the timestamp is when the row was written. + + Attributes: + id (int): + Internal database primary key, as everything in this package has. + + component_id (UUID): + Stable handle of the component. A UUID so that archon, whose tables + are keyed by one, can adopt it as the primary key unchanged. + + component_name (str): + Name of the package. + + version (str): + Version of the package as the document stated it, empty when it + stated none -- which CycloneDX allows for a range nobody pinned. + + latest_version (str | None): + The newest version the reporting tool knew of, refreshed by every + export that states one. What makes a finding answerable: without it a + report says what is wrong and never what to do about it. A newer + version existing is not proof that it fixes this advisory, so + anything read off it is "there is somewhere to upgrade to", never + "this is fixed". + + purl (str | None): + Package URL, which already encodes ecosystem, name and version + (``pkg:pypi/requests@2.31.0``) and is therefore the identity where it + exists. NULL for something no package manager knows -- an installed + application rather than a library -- which name and version identify + instead. + """ + + __tablename__ = "components" + __table_args__ = ( + # The package URL is the identity where there is one: it carries the ecosystem, which is + # what keeps pkg:npm/requests apart from pkg:pypi/requests. Both are called "requests" at + # the same version and are unrelated packages, so name and version alone cannot be unique. + UniqueConstraint("purl", name="uq_components_purl"), + # Where there is no package URL there is no ecosystem either, so name and version are all + # there is to go on. Partial, so it constrains only those rows and leaves the others to the + # constraint above. + Index( + "uq_components_unregistered", + "component_name", + "version", + unique=True, + postgresql_where=text("purl IS NULL"), + sqlite_where=text("purl IS NULL"), + ), + Index("ix_components_name_version", "component_name", "version"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + component_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + + component_name: Mapped[str] = mapped_column(String, nullable=False) + + version: Mapped[str] = mapped_column(String, nullable=False) + + latest_version: Mapped[str | None] = mapped_column(String, nullable=True) + + purl: Mapped[str | None] = mapped_column(String, nullable=True) + + # ORM relationship to the advisories recorded against this component + findings: Mapped[list[DependencyFinding]] = relationship( + "DependencyFinding", + back_populates="component", + cascade="all, delete-orphan", + ) + + # ORM relationship to the scans that found it + scans: Mapped[list[ComponentScan]] = relationship( + "ComponentScan", + back_populates="component", + cascade="all, delete-orphan", + ) + + def print_as_cc_common_result(self) -> None: + """Prints the component objects content.""" + from .helper import print_component_table + + print_component_table([self]) + + +class DependencyFinding(Base): + """A known vulnerability affecting one component. + + Attributes: + id (int): + Internal database primary key. + + finding_dep_id (UUID): + Stable handle of the finding, and archon's primary key once moved. + + component_id (UUID): + The component the advisory affects. + + cve (str | None): + The identifier the advisory carries, e.g. ``CVE-2024-35195``. Not + every advisory has a CVE -- GitHub and OSV assign their own, and + those are stored here too. + + cwes (list[str] | None): + The weakness classes the advisory names, as ``CWE-79`` strings. + + description (str): + What the advisory says about it. + + default_level (str): + The severity the report headlined: ``critical``, ``high``, + ``medium``, ``low``, ``info`` or ``unassigned``. Kept as the report + wrote it rather than derived from a score, because the report is what + is being stored. + + cwe_name (str | None): + What the weakness class is called, e.g. ``Protection Mechanism + Failure``. The identifier alone means nothing to a reader. + + source (str | None): + Who published the advisory: ``NVD``, ``GITHUB``, ``OSV``. + + published (datetime | None): + When the advisory was made public. The **age** of a finding is read + off this rather than stored: an age is a number that is wrong again + tomorrow, a date is not. + + references (str | None): + Links to the advisory and its fix, as the export wrote them -- one + markdown list rather than a structured field, which is what + Dependency-Track hands over. + """ + + __tablename__ = "dependency_findings" + __table_args__ = ( + # One advisory per component. A second report naming it again finds this row rather than + # writing another one. + UniqueConstraint("component_id", "cve", name="uq_dependency_findings_advisory"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + finding_dep_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + + component_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("components.component_id", ondelete="CASCADE"), + nullable=False, + ) + + cve: Mapped[str | None] = mapped_column(String, nullable=True) + + cwes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + + description: Mapped[str] = mapped_column(String, nullable=False) + + default_level: Mapped[str] = mapped_column(String, nullable=False) + + cwe_name: Mapped[str | None] = mapped_column(String, nullable=True) + + source: Mapped[str | None] = mapped_column(String, nullable=True) + + published: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True) + + references: Mapped[str | None] = mapped_column(String, nullable=True) + + # ORM relationship to components + component: Mapped[Component] = relationship("Component", back_populates="findings") + + # ORM relationship to the CVSS scorings of this advisory, one per version of the standard + cvss: Mapped[list[CvssMetric]] = relationship( + "CvssMetric", + back_populates="finding", + cascade="all, delete-orphan", + ) + + # ORM relationship to the scans that reported it + scans: Mapped[list[DepFindingScan]] = relationship( + "DepFindingScan", + back_populates="finding", + cascade="all, delete-orphan", + ) + + def print_as_cc_common_result(self) -> None: + """Prints the dependency finding objects content.""" + from .helper import print_dependency_finding_table + + print_dependency_finding_table([self]) + + +class CvssMetric(Base): + """One CVSS scoring of one advisory, in one version of the standard. + + An advisory is regularly scored more than once -- a Dependency-Track export routinely carries a + v3 vector and a v4 one for the same CVE, and occasionally a v2 one as well -- and the versions + do not agree with each other. So this is one row per version rather than one per advisory, and + nothing is folded together: the columns hold what the vector stated, ``metrics`` holds all of it + verbatim, and the score is the report's own rather than one recomputed here. + + Not every column applies to every version, which is why nearly all of them are nullable: + + * ``authentication`` is v2 only -- how often an attacker has to log in. + * ``privileges_required`` and ``user_interaction`` are v3 and v4, which ask what account is + needed rather than how many logins. + * ``scope`` is v3 only; v4 replaced it with the ``subsequent_*`` impact columns. + * ``attack_requirements`` and the ``subsequent_*`` columns are v4 only. + + A NULL therefore means one of two things -- the version has no such metric, or the report did + not state it -- and ``version`` is what tells the two apart. + + **Built to be moved.** The row points at ``dependency_findings`` and at nothing else of this + package, and that table is archon's under the same name with the same handle. The reading comes + from :mod:`persistent_storage.utils.cvss_utils`, which imports nothing from here. + + Attributes: + id (int): + Internal database primary key. + + cvss_id (UUID): + Stable handle of the scoring. + + finding_dep_id (UUID): + The advisory this scores. + + version (str): + The version of the standard: ``2.0``, ``3.0``, ``3.1`` or ``4.0``. + + vector (str): + The vector as the report wrote it, kept verbatim so the columns + beside it can be checked or re-derived without the report at hand. + + base_score (float | None): + The base score the report stated, 0 to 10. + + base_severity (str | None): + The word that score falls under, read against the scale of its own + version: v2 stops at high, v3 and v4 added critical, so the same + number is not the same word. + + attack_vector (str | None): + ``AV`` -- where an attacker has to be. + + attack_complexity (str | None): + ``AC`` -- how much has to fall into place. + + attack_requirements (str | None): + ``AT`` -- what the target environment has to look like. v4 only. + + authentication (str | None): + ``Au`` -- how often an attacker has to authenticate. v2 only. + + privileges_required (str | None): + ``PR`` -- what account is needed. v3 and v4. + + user_interaction (str | None): + ``UI`` -- whether somebody has to be fooled into helping. v3 and v4. + + scope (str | None): + ``S`` -- whether the blast reaches past the component. v3 only. + + confidentiality_impact (str | None): + What an attacker gets to read. ``C`` in v2 and v3, ``VC`` in v4. + + integrity_impact (str | None): + What an attacker gets to change. ``I`` in v2 and v3, ``VI`` in v4. + + availability_impact (str | None): + How far the service can be taken down. ``A`` in v2 and v3, ``VA`` in + v4. + + subsequent_confidentiality_impact (str | None): + ``SC`` -- the same, for whatever sits behind the vulnerable system. + v4 only, and the reason v4 needs no scope metric. + + subsequent_integrity_impact (str | None): + ``SI``. v4 only. + + subsequent_availability_impact (str | None): + ``SA``. v4 only. + + exploit_maturity (str | None): + ``E`` -- how far along a working exploit is. Temporal in v2 and v3, + threat in v4, and spelled differently in each. + + remediation_level (str | None): + ``RL`` -- whether there is a fix. v2 and v3. + + report_confidence (str | None): + ``RC`` -- how sure anyone is that this is real. v2 and v3. + + metrics (dict[str, str] | None): + Every metric of the vector by its abbreviation, environmental and + supplemental ones included. Those are filled in by whoever operates + the system rather than by a scanner, so they get no columns of their + own -- but a report that does state them does not lose them here. + + reachability (int): + How many of the metrics an attacker has to get past sit at the value + that costs them nothing. Out of three under v2, which has no user + interaction metric, and four otherwise -- so it compares only within + one version. + + remotely_reachable (bool): + Over the network and without an account. The pair that decides + whether the internet is in a position to try at all. + """ + + __tablename__ = "cvss_metrics" + __table_args__ = ( + # One scoring per advisory per version of the standard. + UniqueConstraint("finding_dep_id", "version", name="uq_cvss_metrics_version"), + # What the portfolio question runs on: how much of this can be reached from outside. + Index("ix_cvss_metrics_reach", "remotely_reachable", "reachability"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + cvss_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + + finding_dep_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("dependency_findings.finding_dep_id", ondelete="CASCADE"), + nullable=False, + ) + + version: Mapped[str] = mapped_column(String, nullable=False) + + vector: Mapped[str] = mapped_column(String, nullable=False) + + base_score: Mapped[float | None] = mapped_column(Float, nullable=True) + + base_severity: Mapped[str | None] = mapped_column(String, nullable=True) + + attack_vector: Mapped[str | None] = mapped_column(String, nullable=True) + + attack_complexity: Mapped[str | None] = mapped_column(String, nullable=True) + + attack_requirements: Mapped[str | None] = mapped_column(String, nullable=True) + + authentication: Mapped[str | None] = mapped_column(String, nullable=True) + + privileges_required: Mapped[str | None] = mapped_column(String, nullable=True) + + user_interaction: Mapped[str | None] = mapped_column(String, nullable=True) + + scope: Mapped[str | None] = mapped_column(String, nullable=True) + + confidentiality_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + integrity_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + availability_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + subsequent_confidentiality_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + subsequent_integrity_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + subsequent_availability_impact: Mapped[str | None] = mapped_column(String, nullable=True) + + exploit_maturity: Mapped[str | None] = mapped_column(String, nullable=True) + + remediation_level: Mapped[str | None] = mapped_column(String, nullable=True) + + report_confidence: Mapped[str | None] = mapped_column(String, nullable=True) + + metrics: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True) + + reachability: Mapped[int] = mapped_column(Integer, nullable=False) + + remotely_reachable: Mapped[bool] = mapped_column(Boolean, nullable=False) + + # ORM relationship to dependency findings + finding: Mapped[DependencyFinding] = relationship("DependencyFinding", back_populates="cvss") + + def print_as_cc_common_result(self) -> None: + """Prints the CVSS scoring objects content.""" + from .helper import print_cvss_table + + print_cvss_table([self]) + + +class ComponentScan(Base): + """Records that one scan found the project depending on one package. + + The table the component table stopped carrying when a package became a package rather than a + per-scan row. A component says what it is; this says who ran into it and when. + + Attributes: + id (int): + Internal database primary key. + + component_scan_id (UUID): + Stable handle of the record. + + scan_id (UUID): + The scan that found the dependency. + + component_id (UUID): + The package it found. + + artifact_id (UUID | None): + The document it read it out of, when there was one. + """ + + __tablename__ = "component_scans" + __table_args__ = ( + # A scan depends on a package once, however many documents of that scan mention it. + UniqueConstraint("scan_id", "component_id", name="uq_component_scans_dependency"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + component_scan_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + + scan_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("scans.scan_id", ondelete="CASCADE"), + nullable=False, + ) + + component_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("components.component_id", ondelete="CASCADE"), + nullable=False, + ) + + artifact_id: Mapped[UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("artifacts.artifact_id", ondelete="SET NULL"), + nullable=True, + ) + + # ORM relationships + scan: Mapped[Scan] = relationship("Scan", back_populates="component_scans") + component: Mapped[Component] = relationship("Component", back_populates="scans") + + +class DepFindingScan(Base): + """Records that one scan reported one advisory. + + Separate from :class:`ComponentScan` because the two say different things, and a later scan can + say one without the other: it still depends on the package, and the advisory against it has + since been withdrawn or fixed. Depending on something is not the same as being told about it. + + Attributes: + id (int): + Internal database primary key. + + dep_finding_scan_id (UUID): + Stable handle of the record. + + scan_id (UUID): + The scan that reported the advisory. + + finding_dep_id (UUID): + The advisory it reported. + + artifact_id (UUID | None): + The document it read it out of, when there was one. + + dependency_track_id (UUID | None): + The Dependency-Track project the export was taken of. A notification + arriving weeks later names that project and nothing else, so this is + what matches it back to a scan. + + epss_score (float | None): + The probability that this advisory is exploited somewhere in the next + thirty days, 0 to 1, **as it stood when this scan ran**. EPSS is + recomputed daily, so it belongs to the occurrence and not to the + advisory -- and keeping it here means a series of scans carries the + history of that probability for free. + + epss_percentile (float | None): + Where that probability ranked among all scored vulnerabilities at the + same moment. Uniform by construction, which is what makes it the + comparable half of the pair. + + analyzer_identity (str | None): + Which Dependency-Track analyzer reported it, e.g. + ``TRIVY_ANALYZER``. Two analyzers disagree about the same project, so + this belongs to the run rather than to the advisory. + + is_suppressed (bool | None): + Whether Dependency-Track was told to suppress it at the time of the + export. Somebody's judgement, recorded as what it is: a statement of + the exporting system, not of this store. + """ + + __tablename__ = "dep_finding_scans" + __table_args__ = ( + # A scan reports an advisory once, however many documents of that scan carry it. + UniqueConstraint("scan_id", "finding_dep_id", name="uq_dep_finding_scans_occurrence"), + Index("ix_dep_finding_scans_project", "dependency_track_id"), + # What "show me what is actually likely to be exploited" runs on. + Index("ix_dep_finding_scans_epss", "epss_score"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + dep_finding_scan_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + + scan_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("scans.scan_id", ondelete="CASCADE"), + nullable=False, + ) + + finding_dep_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("dependency_findings.finding_dep_id", ondelete="CASCADE"), + nullable=False, + ) + + artifact_id: Mapped[UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("artifacts.artifact_id", ondelete="SET NULL"), + nullable=True, + ) + + dependency_track_id: Mapped[UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + + epss_score: Mapped[float | None] = mapped_column(Float, nullable=True) + + epss_percentile: Mapped[float | None] = mapped_column(Float, nullable=True) + + analyzer_identity: Mapped[str | None] = mapped_column(String, nullable=True) + + is_suppressed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + + # ORM relationships + scan: Mapped[Scan] = relationship("Scan", back_populates="dep_finding_scans") + finding: Mapped[DependencyFinding] = relationship("DependencyFinding", back_populates="scans") diff --git a/report_generator/vendor/sync.py b/report_generator/vendor/sync.py new file mode 100644 index 0000000..b24b78f --- /dev/null +++ b/report_generator/vendor/sync.py @@ -0,0 +1,103 @@ +"""Copies everything this tool borrows from the monorepo into ``vendor/``. + +The package is meant to be liftable into a repository of its own, where none of ``cc_common`` or +``persistent_storage`` exists. The copies here are what it would need to take along. + +They are produced by a script rather than by hand for one reason: a copy made once goes stale +without saying so. Re-run this after touching anything it lists and the diff shows what drifted. + + python -m persistent_storage.report_generator.vendor.sync + +Copied under their own import paths, so ``vendor/`` on ``sys.path`` makes the package import +unchanged in the new repository. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +# Where the monorepo's sources sit, relative to this file. +SOURCE_ROOT = Path(__file__).resolve().parents[4] + +VENDOR_ROOT = Path(__file__).resolve().parent + +# What the tool reaches for, and nothing beyond it. A whole package is copied where the tool uses +# enough of it that picking modules would only mean chasing imports on the next change. +COPIED = ( + # The CLI framework, the logging context and the error type every command raises. + "cc_common", + # The scan and artifact rows, the storage configuration, and the readers over them. + "persistent_storage/__init__.py", + "persistent_storage/shared.py", + "persistent_storage/wrapper.py", + "persistent_storage/helper.py", + "persistent_storage/utils/__init__.py", + "persistent_storage/utils/db_utils.py", + "persistent_storage/utils/s3_utils.py", + # The CVSS reader the vulnerability summariser works through. + "persistent_storage/utils/cvss_utils.py", + # The two summarisers the report is built from. They live in the web application today and are + # written to be free of it -- no Django, no store -- which is what makes copying them honest + # rather than a workaround. Wherever this package ends up, they belong beside it. + "cipherverifier/__init__.py", + "cipherverifier/core/__init__.py", + "cipherverifier/core/services/__init__.py", + "cipherverifier/core/services/sarif.py", + "cipherverifier/core/services/vdr.py", +) + +# Never copied: caches and the compiled leftovers of whoever ran a tool over the source tree last. +# They live inside the packages being copied, so leaving any of them out puts megabytes of someone +# else's machine into the vendor tree. +SKIPPED = shutil.ignore_patterns( + "__pycache__", "*.pyc", ".DS_Store", ".mypy_cache", ".ruff_cache", ".pytest_cache", "*.egg-info" +) + + +def sync() -> list[Path]: + """Refresh every copy, replacing what is there. + + The copied packages are removed whole before anything is written, so that dropping an entry + from :data:`COPIED` actually drops it from the tree. Replacing only what is listed leaves what + used to be listed behind, and the copy then carries files nothing refers to any more -- which is + the same staleness this script exists to prevent, one level up. + + Returns: + What was written, relative to ``vendor/``. + """ + for root in {entry.split("/")[0] for entry in COPIED}: + stale = VENDOR_ROOT / root + + if stale.is_dir(): + shutil.rmtree(stale) + elif stale.exists(): + stale.unlink() + + written = [] + + for entry in COPIED: + source = SOURCE_ROOT / "src" / entry + target = VENDOR_ROOT / entry + + if not source.exists(): + message = f"{source} does not exist -- the monorepo moved it, and this list has not followed." + + raise FileNotFoundError(message) + + target.parent.mkdir(parents=True, exist_ok=True) + + if source.is_dir(): + shutil.copytree(source, target, ignore=SKIPPED) + else: + shutil.copy2(source, target) + + written.append(Path(entry)) + + return written + + +if __name__ == "__main__": + for path in sync(): + sys.stdout.write(f"{path}\n")