From 05e8848332c3e841646cfc015b7103d4d83c4137 Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Mon, 20 Jul 2026 14:38:17 -0400 Subject: [PATCH] Restyle the report PDF to match the cloud pentest report --- strix/viewer/report_pdf.py | 488 ++++++++++++++++++++++++++----------- 1 file changed, 341 insertions(+), 147 deletions(-) diff --git a/strix/viewer/report_pdf.py b/strix/viewer/report_pdf.py index 5dc6a623..4d9fe3c8 100644 --- a/strix/viewer/report_pdf.py +++ b/strix/viewer/report_pdf.py @@ -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", "
") +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"Target: {_esc(primary_target(record) or 'unknown target')}", - f"Scan mode: {_esc(record.get('scan_mode') or 'n/a')}", - f"Status: {_esc(record.get('status') or 'n/a')}", - f"Started: {_esc(record.get('start_time') or 'n/a')}", - f"Ended: {_esc(record.get('end_time') or 'n/a')}", - f"Duration: {_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'{name.title()}: ' - f"{counts[name]}" - 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"Severity: {_esc(severity)}"] + meta_bits = [] if vuln.get("cvss") is not None: - meta_bits.append(f"CVSS: {_esc(vuln.get('cvss'))}") + meta_bits.append(f"CVSS {_esc(vuln.get('cvss'))}") meta_bits.extend( - f"{key.title()}: {_esc(vuln.get(key))}" + f"{key.title()} {_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"{len(vulns)} 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)