mirror of
https://github.com/usestrix/strix.git
synced 2026-08-19 01:55:46 +02:00
Restyle the report PDF to match the cloud pentest report
This commit is contained in:
+341
-147
@@ -1,5 +1,10 @@
|
||||
"""Build and encrypt a branded PDF report for a run.
|
||||
|
||||
The layout mirrors the Strix cloud pentest report (cover page, executive
|
||||
severity grid, per-finding detail with colored severity badges) but is rendered
|
||||
entirely locally with reportlab, so it ships without a browser or heavy system
|
||||
deps and keeps the report on the user's machine.
|
||||
|
||||
The PDF carries FULL finding detail, including proof-of-concept scripts, so it
|
||||
is encrypted end to end with AES-256. The password is generated locally with a
|
||||
CSPRNG, shown only to the local browser, and never leaves the machine except in
|
||||
@@ -16,16 +21,20 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas as pdfcanvas
|
||||
from reportlab.platypus import (
|
||||
HRFlowable,
|
||||
Flowable,
|
||||
KeepTogether,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
from strix.viewer.transcript import (
|
||||
@@ -39,86 +48,136 @@ from strix.viewer.transcript import (
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.platypus import Flowable
|
||||
|
||||
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
|
||||
_INK = colors.HexColor("#000000")
|
||||
_TEXT = colors.HexColor("#1a1a1a")
|
||||
_MUTED = colors.HexColor("#666666")
|
||||
_FAINT = colors.HexColor("#999999")
|
||||
_BORDER = colors.HexColor("#e5e5e5")
|
||||
_LIGHT_BG = colors.HexColor("#f7f7f7")
|
||||
|
||||
_BRAND = colors.HexColor("#6d28d9")
|
||||
_INK = colors.HexColor("#111827")
|
||||
_MUTED = colors.HexColor("#6b7280")
|
||||
|
||||
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
|
||||
_SEVERITY_COLORS = {
|
||||
"critical": colors.HexColor("#b91c1c"),
|
||||
"critical": colors.HexColor("#dc2626"),
|
||||
"high": colors.HexColor("#ea580c"),
|
||||
"medium": colors.HexColor("#ca8a04"),
|
||||
"low": colors.HexColor("#2563eb"),
|
||||
}
|
||||
|
||||
# Helvetica stands in for Geist: a clean sans with no font file to ship.
|
||||
_SANS = "Helvetica"
|
||||
_SANS_BOLD = "Helvetica-Bold"
|
||||
_MONO = "Courier"
|
||||
|
||||
_PAGE_W, _PAGE_H = A4
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape a value for reportlab's Paragraph markup."""
|
||||
return html.escape(str(value)).replace("\n", "<br/>")
|
||||
|
||||
|
||||
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
|
||||
"""Two-pass canvas that prints 'Page X of Y' on every page after the cover."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_states: list[dict[str, Any]] = []
|
||||
|
||||
def showPage(self) -> None: # noqa: N802 - reportlab API
|
||||
self._saved_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self) -> None:
|
||||
total = len(self._saved_states)
|
||||
for index, state in enumerate(self._saved_states):
|
||||
self.__dict__.update(state)
|
||||
if index > 0: # skip the cover page
|
||||
self._draw_footer(index + 1, total)
|
||||
super().showPage()
|
||||
super().save()
|
||||
|
||||
def _draw_footer(self, page: int, total: int) -> None:
|
||||
self.setFont(_SANS, 8)
|
||||
self.setFillColor(_FAINT)
|
||||
self.drawCentredString(_PAGE_W / 2, 14 * mm, f"Page {page} of {total}")
|
||||
|
||||
|
||||
class _LogoMark(Flowable): # type: ignore[misc] # reportlab base is untyped
|
||||
"""The rounded-square Strix mark drawn inline (no raster asset to ship)."""
|
||||
|
||||
def __init__(self, size: float = 30) -> None:
|
||||
super().__init__()
|
||||
self.size = size
|
||||
self.width = size
|
||||
self.height = size
|
||||
|
||||
def draw(self) -> None:
|
||||
c = self.canv
|
||||
s = self.size
|
||||
c.setFillColor(_INK)
|
||||
c.roundRect(0, 0, s, s, s * 0.28, fill=1, stroke=0)
|
||||
c.setFillColor(colors.white)
|
||||
c.setFont(_SANS_BOLD, s * 0.56)
|
||||
c.drawCentredString(s / 2, s * 0.27, "S")
|
||||
|
||||
|
||||
def _styles() -> dict[str, ParagraphStyle]:
|
||||
base = getSampleStyleSheet()
|
||||
styles: dict[str, ParagraphStyle] = {}
|
||||
styles["title"] = ParagraphStyle(
|
||||
"StrixTitle",
|
||||
parent=base["Title"],
|
||||
textColor=_BRAND,
|
||||
fontSize=26,
|
||||
leading=30,
|
||||
alignment=TA_LEFT,
|
||||
styles["wordmark"] = ParagraphStyle(
|
||||
"Wordmark", fontName=_SANS_BOLD, fontSize=17, leading=20, textColor=_INK
|
||||
)
|
||||
styles["subtitle"] = ParagraphStyle(
|
||||
"StrixSubtitle",
|
||||
parent=base["Normal"],
|
||||
textColor=_MUTED,
|
||||
fontSize=11,
|
||||
leading=15,
|
||||
styles["badge_label"] = ParagraphStyle(
|
||||
"BadgeLabel", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["h2"] = ParagraphStyle(
|
||||
"StrixH2",
|
||||
parent=base["Heading2"],
|
||||
textColor=_INK,
|
||||
fontSize=16,
|
||||
leading=20,
|
||||
spaceBefore=16,
|
||||
spaceAfter=6,
|
||||
styles["cover_title"] = ParagraphStyle(
|
||||
"CoverTitle", fontName=_SANS_BOLD, fontSize=34, leading=38, textColor=_INK
|
||||
)
|
||||
styles["cover_org"] = ParagraphStyle(
|
||||
"CoverOrg", fontName=_SANS, fontSize=13, leading=18, textColor=_MUTED
|
||||
)
|
||||
styles["meta_label"] = ParagraphStyle(
|
||||
"MetaLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["meta_value"] = ParagraphStyle(
|
||||
"MetaValue", fontName=_SANS, fontSize=10.5, leading=14, textColor=_TEXT
|
||||
)
|
||||
styles["section"] = ParagraphStyle(
|
||||
"Section", fontName=_SANS_BOLD, fontSize=18, leading=22, textColor=_INK, spaceAfter=6
|
||||
)
|
||||
styles["finding"] = ParagraphStyle(
|
||||
"StrixFinding",
|
||||
parent=base["Heading3"],
|
||||
textColor=_INK,
|
||||
fontSize=13,
|
||||
leading=17,
|
||||
spaceBefore=14,
|
||||
spaceAfter=2,
|
||||
"Finding", fontName=_SANS_BOLD, fontSize=13, leading=17, textColor=_INK, spaceBefore=6
|
||||
)
|
||||
styles["label"] = ParagraphStyle(
|
||||
"StrixLabel",
|
||||
parent=base["Normal"],
|
||||
textColor=_BRAND,
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
spaceBefore=8,
|
||||
styles["field_label"] = ParagraphStyle(
|
||||
"FieldLabel", fontName=_SANS_BOLD, fontSize=8.5, leading=12, textColor=_MUTED,
|
||||
spaceBefore=10, spaceAfter=2,
|
||||
)
|
||||
styles["body"] = ParagraphStyle(
|
||||
"StrixBody",
|
||||
parent=base["Normal"],
|
||||
textColor=_INK,
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
"Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT
|
||||
)
|
||||
styles["meta_inline"] = ParagraphStyle(
|
||||
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
|
||||
)
|
||||
styles["code"] = ParagraphStyle(
|
||||
"StrixCode",
|
||||
parent=base["Code"],
|
||||
textColor=_INK,
|
||||
backColor=colors.HexColor("#f3f4f6"),
|
||||
fontSize=8,
|
||||
leading=11,
|
||||
borderPadding=6,
|
||||
leftIndent=6,
|
||||
"Code", fontName=_MONO, fontSize=8, leading=11, textColor=_TEXT,
|
||||
backColor=_LIGHT_BG, borderColor=_BORDER, borderWidth=0.5, borderPadding=8,
|
||||
leftIndent=2, spaceBefore=2,
|
||||
)
|
||||
styles["count"] = ParagraphStyle(
|
||||
"Count", fontName=_SANS_BOLD, fontSize=30, leading=32, alignment=TA_CENTER
|
||||
)
|
||||
styles["count_label"] = ParagraphStyle(
|
||||
"CountLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED,
|
||||
alignment=TA_CENTER, spaceBefore=4,
|
||||
)
|
||||
styles["badge"] = ParagraphStyle(
|
||||
"Badge", fontName=_SANS_BOLD, fontSize=9, leading=11, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
styles["confidential"] = ParagraphStyle(
|
||||
"Confidential", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
return styles
|
||||
|
||||
@@ -135,6 +194,11 @@ def _parse_time(raw: Any) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_time(raw: Any) -> str:
|
||||
parsed = _parse_time(raw)
|
||||
return parsed.strftime("%Y-%m-%d %H:%M UTC") if parsed else "n/a"
|
||||
|
||||
|
||||
def _duration(start: Any, end: Any) -> str:
|
||||
start_dt = _parse_time(start)
|
||||
end_dt = _parse_time(end)
|
||||
@@ -152,110 +216,177 @@ def _duration(start: Any, end: Any) -> str:
|
||||
return f"{secs}s"
|
||||
|
||||
|
||||
def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table:
|
||||
"""A colored pill matching .severity-badge in the cloud report."""
|
||||
color = _SEVERITY_COLORS.get(severity, _MUTED)
|
||||
cell = Paragraph(severity.upper(), styles["badge"])
|
||||
table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), color),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
table.hAlign = "LEFT"
|
||||
return table
|
||||
|
||||
|
||||
def _severity_grid(styles: dict[str, ParagraphStyle], counts: dict[str, int]) -> Table:
|
||||
"""The four-card severity grid from the executive summary."""
|
||||
cells: list[list[Flowable]] = []
|
||||
for name in _SEVERITY_ORDER:
|
||||
color = _SEVERITY_COLORS[name]
|
||||
count_style = ParagraphStyle(f"Count{name}", parent=styles["count"], textColor=color)
|
||||
cells.append(
|
||||
[Paragraph(str(counts.get(name, 0)), count_style),
|
||||
Paragraph(name.upper(), styles["count_label"])]
|
||||
)
|
||||
col = (_PAGE_W - 40 * mm) / 4
|
||||
table = Table([cells], colWidths=[col] * 4)
|
||||
style = [
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 16),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 16),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, _BORDER),
|
||||
]
|
||||
for index, name in enumerate(_SEVERITY_ORDER):
|
||||
style.append(("LINEABOVE", (index, 0), (index, 0), 3, _SEVERITY_COLORS[name]))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def _section(styles: dict[str, ParagraphStyle], title: str) -> Table:
|
||||
"""Section title with the underline rule from h2.section-title."""
|
||||
table = Table([[Paragraph(_esc(title), styles["section"])]], colWidths=[_PAGE_W - 40 * mm])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("LINEBELOW", (0, 0), (-1, -1), 1, _BORDER),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _cover(
|
||||
styles: dict[str, ParagraphStyle], record: dict[str, Any], run_name: str
|
||||
) -> list[Flowable]:
|
||||
header = Table(
|
||||
[[_LogoMark(30), Paragraph("Strix", styles["wordmark"])]],
|
||||
colWidths=[38, _PAGE_W - 40 * mm - 38],
|
||||
)
|
||||
header.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
target = primary_target(record) or "Target"
|
||||
meta_rows = [
|
||||
("TARGET", primary_target(record) or "unknown target"),
|
||||
("RUN", run_name),
|
||||
("SCAN MODE", str(record.get("scan_mode") or "n/a")),
|
||||
("STATUS", str(record.get("status") or "n/a")),
|
||||
("STARTED", _fmt_time(record.get("start_time"))),
|
||||
("COMPLETED", _fmt_time(record.get("end_time"))),
|
||||
("DURATION", _duration(record.get("start_time"), record.get("end_time"))),
|
||||
]
|
||||
meta_table = Table(
|
||||
[[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
|
||||
for label, value in meta_rows],
|
||||
colWidths=[38 * mm, _PAGE_W - 40 * mm - 38 * mm],
|
||||
)
|
||||
meta_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("LINEBELOW", (0, 0), (-1, -2), 0.5, _BORDER),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
confidential = Table([[Paragraph("CONFIDENTIAL", styles["confidential"])]], colWidths=[120])
|
||||
confidential.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), _INK),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
confidential.hAlign = "CENTER"
|
||||
|
||||
return [
|
||||
header,
|
||||
Spacer(1, 150),
|
||||
Paragraph("PENETRATION TEST REPORT", styles["badge_label"]),
|
||||
Spacer(1, 20),
|
||||
Paragraph("Security Assessment", styles["cover_title"]),
|
||||
Paragraph(_esc(target), styles["cover_org"]),
|
||||
Spacer(1, 28),
|
||||
meta_table,
|
||||
Spacer(1, 90),
|
||||
confidential,
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
|
||||
def _field_block(
|
||||
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
|
||||
) -> list[Flowable]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return []
|
||||
flowables: list[Flowable] = [Paragraph(label.upper(), styles["label"])]
|
||||
flowables.append(Paragraph(_esc(value), styles["code"] if code else styles["body"]))
|
||||
return flowables
|
||||
|
||||
|
||||
def generate_report_pdf(run_dir: Path) -> bytes:
|
||||
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
|
||||
record = read_run_summary(run_dir)
|
||||
vulns = read_vulnerabilities(run_dir)
|
||||
counts = severity_counts(vulns)
|
||||
|
||||
styles = _styles()
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=letter,
|
||||
title="Strix Security Report",
|
||||
author="Strix",
|
||||
leftMargin=0.9 * inch,
|
||||
rightMargin=0.9 * inch,
|
||||
topMargin=0.9 * inch,
|
||||
bottomMargin=0.9 * inch,
|
||||
)
|
||||
|
||||
story: list[Flowable] = []
|
||||
story.append(Paragraph("Strix Security Report", styles["title"]))
|
||||
story.append(Spacer(1, 4))
|
||||
run_name = record.get("run_name") or run_dir.name
|
||||
story.append(Paragraph(f"Run {_esc(run_name)}", styles["subtitle"]))
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(HRFlowable(width="100%", thickness=1, color=_BRAND))
|
||||
story.append(Spacer(1, 10))
|
||||
|
||||
meta_lines = [
|
||||
f"<b>Target:</b> {_esc(primary_target(record) or 'unknown target')}",
|
||||
f"<b>Scan mode:</b> {_esc(record.get('scan_mode') or 'n/a')}",
|
||||
f"<b>Status:</b> {_esc(record.get('status') or 'n/a')}",
|
||||
f"<b>Started:</b> {_esc(record.get('start_time') or 'n/a')}",
|
||||
f"<b>Ended:</b> {_esc(record.get('end_time') or 'n/a')}",
|
||||
f"<b>Duration:</b> {_esc(_duration(record.get('start_time'), record.get('end_time')))}",
|
||||
return [
|
||||
Paragraph(label.upper(), styles["field_label"]),
|
||||
Paragraph(_esc(value), styles["code"] if code else styles["body"]),
|
||||
]
|
||||
story.extend(Paragraph(line, styles["body"]) for line in meta_lines)
|
||||
|
||||
story.append(Paragraph("Findings by severity", styles["h2"]))
|
||||
severity_line = " ".join(
|
||||
f'<font color="{_SEVERITY_COLORS[name].hexval()}"><b>{name.title()}:</b> '
|
||||
f"{counts[name]}</font>"
|
||||
for name in ("critical", "high", "medium", "low")
|
||||
)
|
||||
story.append(Paragraph(severity_line, styles["body"]))
|
||||
story.append(Paragraph(f"Total findings: {len(vulns)}", styles["body"]))
|
||||
|
||||
scan_results = record.get("scan_results")
|
||||
if isinstance(scan_results, dict):
|
||||
summary = scan_results.get("executive_summary")
|
||||
if isinstance(summary, str) and summary.strip():
|
||||
story.append(Paragraph("Executive summary", styles["h2"]))
|
||||
story.append(Paragraph(_esc(summary), styles["body"]))
|
||||
for label, key in (
|
||||
("Methodology", "methodology"),
|
||||
("Technical analysis", "technical_analysis"),
|
||||
("Recommendations", "recommendations"),
|
||||
):
|
||||
value = scan_results.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
story.append(Paragraph(label, styles["h2"]))
|
||||
story.append(Paragraph(_esc(value), styles["body"]))
|
||||
|
||||
if vulns:
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("Detailed findings", styles["h2"]))
|
||||
for index, vuln in enumerate(vulns, start=1):
|
||||
if not isinstance(vuln, dict):
|
||||
continue
|
||||
story.extend(_finding_flowables(styles, index, vuln))
|
||||
else:
|
||||
story.append(Paragraph("No findings were recorded for this run.", styles["body"]))
|
||||
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _finding_flowables(
|
||||
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
|
||||
) -> list[Flowable]:
|
||||
title = vuln.get("title") or "Untitled finding"
|
||||
severity = str(vuln.get("severity") or "").lower().strip() or "unknown"
|
||||
story: list[Flowable] = [Paragraph(f"{index}. {_esc(title)}", styles["finding"])]
|
||||
severity = str(vuln.get("severity") or "").lower().strip() or "low"
|
||||
|
||||
meta_bits = [f"<b>Severity:</b> {_esc(severity)}"]
|
||||
meta_bits = []
|
||||
if vuln.get("cvss") is not None:
|
||||
meta_bits.append(f"<b>CVSS:</b> {_esc(vuln.get('cvss'))}")
|
||||
meta_bits.append(f"<b>CVSS</b> {_esc(vuln.get('cvss'))}")
|
||||
meta_bits.extend(
|
||||
f"<b>{key.title()}:</b> {_esc(vuln.get(key))}"
|
||||
f"<b>{key.title()}</b> {_esc(vuln.get(key))}"
|
||||
for key in ("target", "endpoint", "method")
|
||||
if vuln.get(key)
|
||||
)
|
||||
story.append(Paragraph(" ".join(meta_bits), styles["body"]))
|
||||
|
||||
header: list[Flowable] = [
|
||||
Paragraph(f"{index}. {_esc(title)}", styles["finding"]),
|
||||
Spacer(1, 4),
|
||||
_severity_badge(styles, severity),
|
||||
]
|
||||
if meta_bits:
|
||||
header.append(Paragraph(" ".join(meta_bits), styles["meta_inline"]))
|
||||
|
||||
story: list[Flowable] = [KeepTogether(header)]
|
||||
story.extend(_field_block(styles, "Description", vuln.get("description")))
|
||||
story.extend(_field_block(styles, "Impact", vuln.get("impact")))
|
||||
story.extend(_field_block(styles, "Technical analysis", vuln.get("technical_analysis")))
|
||||
@@ -268,11 +399,74 @@ def _finding_flowables(
|
||||
remediation = "\n".join(str(step) for step in remediation)
|
||||
story.extend(_field_block(styles, "Remediation", remediation))
|
||||
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(HRFlowable(width="100%", thickness=0.5, color=_MUTED))
|
||||
story.append(Spacer(1, 22))
|
||||
return story
|
||||
|
||||
|
||||
def generate_report_pdf(run_dir: Path) -> bytes:
|
||||
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
|
||||
record = read_run_summary(run_dir)
|
||||
vulns = [v for v in read_vulnerabilities(run_dir) if isinstance(v, dict)]
|
||||
counts = severity_counts(vulns)
|
||||
run_name = str(record.get("run_name") or run_dir.name)
|
||||
|
||||
styles = _styles()
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
title="Strix Security Report",
|
||||
author="Strix",
|
||||
leftMargin=20 * mm,
|
||||
rightMargin=20 * mm,
|
||||
topMargin=22 * mm,
|
||||
bottomMargin=24 * mm,
|
||||
)
|
||||
|
||||
story: list[Flowable] = []
|
||||
story.extend(_cover(styles, record, run_name))
|
||||
|
||||
# Executive summary + severity grid.
|
||||
story.append(_section(styles, "Executive Summary"))
|
||||
story.append(Spacer(1, 16))
|
||||
story.append(_severity_grid(styles, counts))
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(
|
||||
Paragraph(f"<b>{len(vulns)}</b> total findings across this assessment.", styles["body"])
|
||||
)
|
||||
|
||||
scan_results = record.get("scan_results")
|
||||
if isinstance(scan_results, dict):
|
||||
summary = scan_results.get("executive_summary")
|
||||
if isinstance(summary, str) and summary.strip():
|
||||
story.append(Spacer(1, 16))
|
||||
story.append(Paragraph(_esc(summary), styles["body"]))
|
||||
for label, key in (
|
||||
("Methodology", "methodology"),
|
||||
("Technical Analysis", "technical_analysis"),
|
||||
("Recommendations", "recommendations"),
|
||||
):
|
||||
value = scan_results.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
story.append(Spacer(1, 20))
|
||||
story.append(_section(styles, label))
|
||||
story.append(Spacer(1, 12))
|
||||
story.append(Paragraph(_esc(value), styles["body"]))
|
||||
|
||||
# Findings.
|
||||
story.append(PageBreak())
|
||||
story.append(_section(styles, "Findings"))
|
||||
story.append(Spacer(1, 16))
|
||||
if vulns:
|
||||
for index, vuln in enumerate(vulns, start=1):
|
||||
story.extend(_finding_flowables(styles, index, vuln))
|
||||
else:
|
||||
story.append(Paragraph("No findings were recorded for this run.", styles["body"]))
|
||||
|
||||
doc.build(story, canvasmaker=_NumberedCanvas)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Return a >=20 character URL-safe password from a CSPRNG."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
Reference in New Issue
Block a user