""" pdf.py — PDF Report Generator ================================ Builds a clean, branded PDF from a scan result dict. Uses ReportLab Platypus — no external services, no cloud storage. Called from app.py's GET /report/{id}/pdf endpoint. """ import logging from datetime import datetime, timezone from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable ) from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.pagesizes import letter from reportlab.lib import colors logger = logging.getLogger("secretscan.pdf") # Brand colours _DARK = colors.HexColor("#0f172a") _ACCENT = colors.HexColor("#5b7bfe") _HIGH = colors.HexColor("#f43f5e") _MEDIUM = colors.HexColor("#fb923c") _LOW = colors.HexColor("#facc15") _NONE = colors.HexColor("#22c55e") _MUTED = colors.HexColor("#64748b") _BG_LIGHT = colors.HexColor("#f8fafc") _SEV_COLOR = {"HIGH": _HIGH, "MEDIUM": _MEDIUM, "LOW": _LOW, "NONE": _NONE} def generate_pdf(scan_id: str, result: dict, out_path: str) -> None: """ Write a PDF report to out_path. Args: scan_id: UUID of the scan (shown in header). result: The full build_result() dict from scanner.py. out_path: Filesystem path to write the .pdf file. Raises: Exception: propagates ReportLab errors to the caller. """ styles = getSampleStyleSheet() doc = SimpleDocTemplate( out_path, pagesize = letter, topMargin = 36, bottomMargin = 36, leftMargin = 48, rightMargin = 48, ) findings = result.get("findings", []) summary = result.get("summary", {}) risk = result.get("risk_level", "NONE") total = result.get("total_secrets", 0) source = result.get("source", "") truncated= result.get("truncated", False) story: list = [] # ── Header ──────────────────────────────────────────────── story.append(Paragraph( "🔐 SecretScan Security Report", ParagraphStyle("Title", parent=styles["Title"], fontSize=22, textColor=_DARK, spaceAfter=2), )) story.append(Paragraph( f"Scan ID: {scan_id[:8]}… · " f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", ParagraphStyle("Sub", parent=styles["Normal"], fontSize=9, textColor=_MUTED, spaceAfter=10), )) story.append(HRFlowable(width="100%", thickness=1, color=_ACCENT, spaceAfter=12)) # ── Risk banner ─────────────────────────────────────────── risk_color = _SEV_COLOR.get(risk, _NONE) story.append(Paragraph( f"Overall Risk Level: {risk}", ParagraphStyle("Risk", parent=styles["Normal"], fontSize=16, textColor=risk_color, spaceAfter=6), )) # ── Summary table ───────────────────────────────────────── table_data = [ ["Total Secrets", "HIGH", "MEDIUM", "LOW"], [ str(total), str(summary.get("high", 0)), str(summary.get("medium", 0)), str(summary.get("low", 0)), ], ] tbl = Table(table_data, colWidths=[120, 80, 80, 80]) tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), _DARK), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 10), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [_BG_LIGHT, colors.white]), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), # Colour the HIGH/MEDIUM/LOW header cells ("TEXTCOLOR", (1, 0), (1, 0), _HIGH), ("TEXTCOLOR", (2, 0), (2, 0), _MEDIUM), ("TEXTCOLOR", (3, 0), (3, 0), _LOW), ])) story.append(tbl) story.append(Spacer(1, 14)) # ── Source ──────────────────────────────────────────────── if source: story.append(Paragraph( f"Source: {source}", ParagraphStyle("Src", parent=styles["Normal"], fontSize=9, textColor=_MUTED, spaceAfter=14), )) # ── Free-tier truncation notice ─────────────────────────── if truncated: story.append(Paragraph( "⚠ This report shows a partial view. " "Upgrade to SecretScan Pro to see all findings and download the full PDF.", ParagraphStyle("Warn", parent=styles["Normal"], fontSize=10, textColor=_MEDIUM, backColor=colors.HexColor("#fff7ed"), borderPadding=8, spaceAfter=14), )) # ── Findings ────────────────────────────────────────────── story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) if not findings: story.append(Paragraph( "✓ No secrets detected in this scan.", ParagraphStyle("Good", parent=styles["Normal"], fontSize=12, textColor=_NONE), )) else: story.append(Paragraph( f"Findings ({len(findings)} shown)", ParagraphStyle("H2", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) for idx, f in enumerate(findings, 1): sev = f.get("severity", "LOW") color = _SEV_COLOR.get(sev, _LOW) # Finding header: index + type + severity badge story.append(Paragraph( f"{idx}.  {f.get('type', 'Unknown')}" f" [{sev}]", ParagraphStyle(f"FH{idx}", parent=styles["Normal"], fontSize=11, textColor=_DARK, spaceAfter=2), )) # File + line story.append(Paragraph( f"" f"📄 {f.get('file', '?')} · Line {f.get('line', '?')}" f"", styles["Normal"], )) # Redacted match if f.get("match"): story.append(Paragraph( f"Match: {f['match']}", styles["Normal"], )) # Description story.append(Paragraph( f.get("description", ""), ParagraphStyle(f"FD{idx}", parent=styles["Normal"], fontSize=9, textColor=_MUTED, spaceAfter=2), )) # Fix recommendation story.append(Paragraph( f"Fix: {f.get('fix', '')}", ParagraphStyle(f"FF{idx}", parent=styles["Normal"], fontSize=9, textColor=_DARK, spaceAfter=10), )) story.append(HRFlowable( width="100%", thickness=0.3, color=colors.HexColor("#e2e8f0"), spaceAfter=8, )) # ── Footer ──────────────────────────────────────────────── story.append(Spacer(1, 20)) story.append(Paragraph( "Generated by SecretScan — secretscan.io", ParagraphStyle("Footer", parent=styles["Normal"], fontSize=8, textColor=_MUTED, alignment=1), )) doc.build(story) logger.info(f"PDF generated: {out_path} ({len(findings)} findings)") # ══════════════════════════════════════════════════════════════ # PHASE 1 — EXECUTIVE PDF REPORTS (item 5) # New function. generate_pdf() above is UNCHANGED and still used # by any existing callers — this is a richer, additive report type # for Pro/Enterprise users. # ══════════════════════════════════════════════════════════════ from reportlab.platypus import PageBreak, Image as RLImage from reportlab.lib.units import inch _GOOD = colors.HexColor("#22c55e") _BRAND = colors.HexColor("#5b7bfe") _SECONDARY = colors.HexColor("#38bdf8") _SCORE_BAND_COLOR = { "Excellent": _GOOD, "Good": _GOOD, "Moderate": _MEDIUM, "High Risk": _HIGH, "Critical": colors.HexColor("#c026d3"), } def generate_executive_pdf(scan_id: str, result: dict, out_path: str, org_name: str = "", user_email: str = "") -> None: """ Generate a multi-section "Executive" PDF security assessment report. Sections: 1. Cover / header — branding, org name, scan timestamp 2. Executive Summary — security score, risk level, key stats 3. Repository Health (if present in result['repo_health']) 4. Critical & High Findings — detailed, with OWASP/NIST mapping 5. Detected Secrets (Secrets Exposure category findings) 6. Dependency Risks (if result['dependency_findings'] present) 7. Compliance Mapping summary table (OWASP Top 10 coverage) 8. Recommendations 9. Footer — branding, scan ID, timestamp Args: scan_id: UUID of the scan (shown in header). result: The full build_result() dict from scanner.py — may include Phase 1 fields (security_score, repo_health, dependency_findings, and per-finding owasp/nist/auto_fix). out_path: Filesystem path to write the .pdf file. org_name: Organization name for branding (optional). user_email: Requesting user's email, shown in report metadata (optional). Raises: Exception: propagates ReportLab errors to the caller. """ styles = getSampleStyleSheet() doc = SimpleDocTemplate( out_path, pagesize=letter, topMargin=40, bottomMargin=40, leftMargin=48, rightMargin=48, ) findings = result.get("findings", []) dep_findings = result.get("dependency_findings", []) summary = result.get("summary", {}) risk = result.get("risk_level", "NONE") total = result.get("total_secrets", len(findings)) source = result.get("source", "") truncated = result.get("truncated", False) security_score = result.get("security_score", 0) score_risk_level = result.get("score_risk_level", "Moderate") repo_health = result.get("repo_health") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") story: list = [] # ── Title / Cover ──────────────────────────────────────── story.append(Paragraph( "SafeAIScan Security Assessment", ParagraphStyle("Title", parent=styles["Title"], fontSize=24, textColor=_DARK, spaceAfter=4), )) story.append(Paragraph( "Executive Security Report", ParagraphStyle("Subtitle", parent=styles["Normal"], fontSize=13, textColor=_ACCENT, spaceAfter=12), )) meta_lines = [f"Scan ID: {scan_id[:8]}…", f"Generated: {now_str}"] if org_name: meta_lines.append(f"Organization: {org_name}") if user_email: meta_lines.append(f"Requested by: {user_email}") if source: meta_lines.append(f"Source: {source}") story.append(Paragraph( "  ·  ".join(meta_lines), ParagraphStyle("Meta", parent=styles["Normal"], fontSize=9, textColor=_MUTED, spaceAfter=10), )) story.append(HRFlowable(width="100%", thickness=1.5, color=_ACCENT, spaceAfter=16)) # ── Executive Summary ──────────────────────────────────── story.append(Paragraph( "Executive Summary", ParagraphStyle("H1", parent=styles["Heading1"], fontSize=15, textColor=_DARK, spaceAfter=8), )) score_color = _SCORE_BAND_COLOR.get(score_risk_level, _MEDIUM) summary_table_data = [ ["Security Score", "Risk Level", "Total Findings", "Critical", "High"], [ f"{security_score} / 100", score_risk_level, str(total), str(summary.get("critical", 0)), str(summary.get("high", 0)), ], ] summary_tbl = Table(summary_table_data, colWidths=[100, 95, 95, 70, 70]) summary_tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), _DARK), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 10), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")), ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("BACKGROUND", (0, 1), (0, 1), colors.HexColor("#f8fafc")), ("TEXTCOLOR", (0, 1), (0, 1), score_color), ("FONTNAME", (0, 1), (0, 1), "Helvetica-Bold"), ("FONTSIZE", (0, 1), (0, 1), 13), ("TEXTCOLOR", (1, 1), (1, 1), score_color), ("FONTNAME", (1, 1), (1, 1), "Helvetica-Bold"), ("TEXTCOLOR", (3, 1), (3, 1), colors.HexColor("#c026d3")), ("TEXTCOLOR", (4, 1), (4, 1), _HIGH), ])) story.append(summary_tbl) story.append(Spacer(1, 14)) exec_summary_text = ( f"This assessment identified {total} finding(s) across the scanned " f"{'repository' if source.startswith('http') else 'submission'}, resulting in an overall " f"security score of {security_score}/100 " f"({score_risk_level}). " ) if summary.get("critical", 0) > 0: exec_summary_text += ( f"{summary['critical']} CRITICAL finding(s) require immediate attention " f"as they represent direct exposure of credentials or code-execution risks. " ) if not findings: exec_summary_text = ( "No security findings were detected in this scan. The codebase appears to " "follow secure coding practices for the patterns checked." ) story.append(Paragraph( exec_summary_text, ParagraphStyle("ExecSum", parent=styles["Normal"], fontSize=10, textColor=_DARK, spaceAfter=14, leading=15), )) if truncated: story.append(Paragraph( "⚠ This report shows a partial view based on your current plan. " "Upgrade to SafeAIScan Pro to see all findings and unlock full executive reports.", ParagraphStyle("Warn", parent=styles["Normal"], fontSize=9, textColor=_MEDIUM, backColor=colors.HexColor("#fff7ed"), borderPadding=8, spaceAfter=14), )) # ── Repository Health ───────────────────────────────────── if repo_health: story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) story.append(Paragraph( "Repository Health", ParagraphStyle("H2RH", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) health_data = [ ["Critical", "High", "Medium", "Low", "Secrets", "Dependencies", "Outdated"], [ str(repo_health.get("critical_count", 0)), str(repo_health.get("high_count", 0)), str(repo_health.get("medium_count", 0)), str(repo_health.get("low_count", 0)), str(repo_health.get("secret_count", 0)), str(repo_health.get("dependency_count", 0)), str(repo_health.get("outdated_packages", 0)), ], ] health_tbl = Table(health_data, colWidths=[58]*7) health_tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("TEXTCOLOR", (0, 1), (0, 1), colors.HexColor("#c026d3")), ("TEXTCOLOR", (1, 1), (1, 1), _HIGH), ("TEXTCOLOR", (2, 1), (2, 1), _MEDIUM), ("TEXTCOLOR", (3, 1), (3, 1), _GOOD), ])) story.append(health_tbl) story.append(Spacer(1, 14)) # ── Critical & High Findings (detailed) ────────────────── critical_high = [f for f in findings if (f.get("severity") or "").upper() in ("CRITICAL", "HIGH")] if critical_high: story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) story.append(Paragraph( f"Critical & High Findings ({len(critical_high)})", ParagraphStyle("H2CH", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) for idx, f in enumerate(critical_high, 1): _render_finding(story, styles, f, idx, show_compliance=True) # ── Detected Secrets (Secrets Exposure category) ────────── secrets = [f for f in findings if str(f.get("category", "")).lower() == "secrets exposure"] if secrets: story.append(PageBreak()) story.append(Paragraph( f"Detected Secrets ({len(secrets)})", ParagraphStyle("H2Sec", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) for idx, f in enumerate(secrets, 1): _render_finding(story, styles, f, idx, show_compliance=False, compact=True) # ── Dependency Risks ────────────────────────────────────── if dep_findings: story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) story.append(Paragraph( f"Dependency Risks ({len(dep_findings)})", ParagraphStyle("H2Dep", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) dep_table_data = [["Package", "Version", "Severity", "CVE", "Issue"]] for d in dep_findings[:20]: dep_table_data.append([ d.get("package", "?"), d.get("version", "?"), d.get("severity", "LOW"), d.get("cve", "N/A"), (d.get("title", "") or d.get("description", ""))[:50], ]) dep_tbl = Table(dep_table_data, colWidths=[90, 60, 60, 90, 175]) dep_tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("ROWBACKGROUNDS",(0, 1), (-1, -1), [_BG_LIGHT, colors.white]), ])) story.append(dep_tbl) story.append(Spacer(1, 14)) # ── Compliance Mapping Summary ─────────────────────────── owasp_counts: dict = {} for f in findings: owasp = f.get("owasp") if owasp: owasp_counts[owasp] = owasp_counts.get(owasp, 0) + 1 if owasp_counts: story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) story.append(Paragraph( "Compliance Mapping — OWASP Top 10 Coverage", ParagraphStyle("H2Comp", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) comp_data = [["OWASP Category", "Findings"]] for owasp, count in sorted(owasp_counts.items(), key=lambda x: -x[1]): comp_data.append([owasp, str(count)]) comp_tbl = Table(comp_data, colWidths=[380, 80]) comp_tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("ALIGN", (1, 0), (1, -1), "CENTER"), ("ROWBACKGROUNDS",(0, 1), (-1, -1), [_BG_LIGHT, colors.white]), ])) story.append(comp_tbl) story.append(Spacer(1, 14)) # ── Recommendations ─────────────────────────────────────── story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=8)) story.append(Paragraph( "Recommendations", ParagraphStyle("H2Rec", parent=styles["Heading2"], fontSize=13, textColor=_DARK, spaceAfter=8), )) recs = [] if summary.get("critical", 0) > 0: recs.append("Rotate all exposed credentials immediately — treat CRITICAL findings as already compromised.") if secrets: recs.append("Move all secrets to environment variables or a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager, or your platform's secret store).") if dep_findings: recs.append("Update vulnerable dependencies to the versions specified in this report, prioritising CRITICAL and HIGH severity packages.") if summary.get("high", 0) > 0: recs.append("Review and remediate HIGH severity findings — these represent significant exploitable risk.") recs.append("Add automated SafeAIScan checks to your CI/CD pipeline to catch new issues before merge.") recs.append("Re-run this scan after remediation to confirm your security score has improved.") for r in recs: story.append(Paragraph(f"• {r}", ParagraphStyle( "Rec", parent=styles["Normal"], fontSize=9.5, textColor=_DARK, spaceAfter=4, leading=14, ))) # ── Footer ──────────────────────────────────────────────── story.append(Spacer(1, 24)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2e8f0"), spaceAfter=6)) story.append(Paragraph( f"Generated by SafeAIScan · {now_str} · Scan ID {scan_id[:8]}… · safeaiscan.io", ParagraphStyle("Footer", parent=styles["Normal"], fontSize=8, textColor=_MUTED, alignment=1), )) doc.build(story) logger.info(f"Executive PDF generated: {out_path} ({len(findings)} findings, score={security_score})") def _render_finding(story: list, styles, f: dict, idx: int, show_compliance: bool = False, compact: bool = False) -> None: """Helper: render a single finding block into the PDF story.""" sev = (f.get("severity") or "LOW").upper() color = _SEV_COLOR.get(sev, _LOW) if sev != "CRITICAL" else colors.HexColor("#c026d3") header = ( f"{idx}.  {f.get('type', f.get('title', 'Issue'))}" f" [{sev}]" ) story.append(Paragraph(header, ParagraphStyle( f"FH{idx}_{id(f)}", parent=styles["Normal"], fontSize=11, textColor=_DARK, spaceAfter=2, ))) if f.get("file"): story.append(Paragraph( f"File: {f.get('file', '?')}" f"{' · Line ' + str(f.get('line')) if f.get('line') else ''}", styles["Normal"], )) if f.get("match"): story.append(Paragraph( f"Match: {f['match']}", styles["Normal"], )) if f.get("description"): story.append(Paragraph( f.get("description", ""), ParagraphStyle(f"FD{idx}_{id(f)}", parent=styles["Normal"], fontSize=9, textColor=_MUTED, spaceAfter=2), )) if f.get("fix"): story.append(Paragraph( f"Fix: {f.get('fix', '')}", ParagraphStyle(f"FF{idx}_{id(f)}", parent=styles["Normal"], fontSize=9, textColor=_DARK, spaceAfter=2), )) if show_compliance and (f.get("owasp") or f.get("nist")): story.append(Paragraph( f"Compliance: " f"OWASP {f.get('owasp','')} · NIST {f.get('nist','')}", styles["Normal"], )) if f.get("auto_fix") and not compact: af = f["auto_fix"] story.append(Paragraph( f"Suggested fix (confidence {af.get('confidence',0)}%): " f"{af.get('after','')}", styles["Normal"], )) story.append(Spacer(1, 4)) story.append(HRFlowable( width="100%", thickness=0.3, color=colors.HexColor("#e2e8f0"), spaceAfter=8, ))