""" sources/helpers/reporting.py NOX Enterprise Reporting — Executive Summary, Pivot Chain, Data Sanitization. """ import hashlib import html as _html import json import re from datetime import datetime from pathlib import Path from typing import Any, Dict, List # ── Noise patterns stripped from all report output ──────────────────── _NOISE_RE = re.compile( r"(Traceback \(most recent|File \".*\.py\"|TimeoutError|ProxyError" r"|ConnectionError|aiohttp\.|ClientConnector|ssl\.|asyncio\." r"|Task exception|NoneType|Object of type)", re.I, ) _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") def _nox_ver() -> str: try: from nox import VERSION # type: ignore return VERSION except ImportError: return "1.0.0" def _clean(v: Any, maxlen: int = 200) -> str: """Strip control chars, technical noise, HTML-escape, truncate.""" s = str(v) if v is not None else "" s = _CTRL_RE.sub("", s) if _NOISE_RE.search(s): return "" return _html.escape(s[:maxlen]) def _raw(v: Any, maxlen: int = 200) -> str: """Strip control chars only — no HTML escaping (PDF / plain-text paths).""" s = str(v) if v is not None else "" s = _CTRL_RE.sub("", s) if _NOISE_RE.search(s): return "" return s[:maxlen] def _pdf_safe(s: str, maxlen: int = 180) -> str: # Sanitise for fpdf2 core fonts (latin-1 subset). # NFKD normalization decomposes accented chars (é→e + combining accent) # so common accented Latin characters survive as their base letter. # Truly non-latin-1 chars (Cyrillic, CJK, etc.) become '?' — fpdf2 core # fonts cannot render them and would raise UnicodeEncodeError. s = _raw(s, maxlen) try: import unicodedata normalized = unicodedata.normalize("NFKD", s) return normalized.encode("ascii", errors="replace").decode("ascii") except Exception: return s.encode("latin-1", errors="replace").decode("latin-1") def _rget(r: Any, k: str) -> str: if isinstance(r, dict): return str(r.get(k, "") or "") return str(getattr(r, k, "") or "") # ── Executive summary builder ───────────────────────────────────────── def build_exec_summary(data: dict) -> dict: """ Returns a dict with all dashboard KPIs needed by every format. Expects data keys: records, analysis, scan_meta (optional). """ records = data.get("records", []) meta = data.get("scan_meta", {}) or {} analysis = data.get("analysis", {}) or {} cleartext = sum(1 for r in records if _rget(r, "password")) nodes = len({_rget(r, "email") or _rget(r, "username") for r in records} - {""}) elapsed = meta.get("elapsed_seconds") depth = meta.get("pivot_depth", len(data.get("pivot_chain", []))) buckets: Dict[str, int] = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0, "Info": 0} for r in records: rs = float(_rget(r, "risk_score") or 0) if rs >= 90: buckets["Critical"] += 1 elif rs >= 70: buckets["High"] += 1 elif rs >= 40: buckets["Medium"] += 1 elif rs >= 10: buckets["Low"] += 1 else: buckets["Info"] += 1 return { "total_records": len(records), "nodes_discovered": nodes, "cleartext_passwords": cleartext, "pivot_depth": depth, "elapsed": f"{elapsed:.1f}s" if elapsed is not None else "N/A", "buckets": buckets, "hvt_count": analysis.get("hvt_count", sum(1 for r in records if getattr(r, "is_hvt", False))), } # ── Pivot chain renderer ────────────────────────────────────────────── def render_pivot_chain(data: dict) -> List[str]: """ Build a human-readable pivot chain. D2: check pivot_log first before falling back to record-based reconstruction. """ chain = data.get("pivot_chain") or [] target = _raw(data.get("target", "?")) # Build chain from pivot_log when available — it carries the full tree with depth and provenance. pivot_log = data.get("pivot_log") or [] if pivot_log: lines: List[str] = [] for e in pivot_log: depth = e.get("depth", 0) asset = _raw(e.get("asset", "")) phase = _raw(e.get("found_in", e.get("source", "?"))) parent = _raw(e.get("parent") or "") prefix = " " * depth if depth == 0: lines.append(f"[SEED] {asset}") else: lines.append(f"{prefix}└─ [{phase}] {asset} ← {parent}") return lines if lines else [f"[SEED] {target} (no pivot data)"] if len(chain) <= 1: # No pivot data — reconstruct best-effort from records records = data.get("records", []) lines = [f"[SEED] {target}"] seen: set = {target.lower()} for r in records[:40]: src = _raw(_rget(r, "source")) em = _raw(_rget(r, "email")) usr = _raw(_rget(r, "username")) ident = em or usr if not ident or ident.lower() in seen: continue seen.add(ident.lower()) lines.append(f" └─ [{src}] → {ident}") dork_results = data.get("dork_results") or [] for d in dork_results[:5]: url = _raw(d.get("url", "")) if url and url.lower() not in seen: seen.add(url.lower()) lines.append(f" └─ [Dork] → {url[:80]}") return lines if len(lines) > 1 else [f"[SEED] {target} (no pivot data)"] # Ordered pivot chain from AvalancheScanner lines = [f"[SEED] {_raw(chain[0])}"] for node in chain[1:]: lines.append(f" └─ [Pivot] → {_raw(node)}") return lines # ── JSON report ─────────────────────────────────────────────────────── def to_json(data: dict, path: str) -> None: summary = build_exec_summary(data) chain = render_pivot_chain(data) records = data.get("records", []) def _ser(o): try: from enum import Enum if isinstance(o, Enum): return o.name except ImportError: pass if hasattr(o, "to_dict"): return o.to_dict() return str(o) clean_records = [] for r in records: d = r.to_dict() if hasattr(r, "to_dict") else (r if isinstance(r, dict) else {}) # drop noise fields clean_records.append({ k: v for k, v in d.items() if k not in ("raw_data", "metadata") and not _NOISE_RE.search(str(v or "")) }) try: from nox import VERSION as _NOX_VERSION # type: ignore except ImportError: _NOX_VERSION = "1.0.0" # Include dork and scrape results in JSON output dork_results = data.get("dork_results", []) or [] scrape_results = data.get("scrape_results", {}) or {} _RECORD_CAP = 1000 out_data = { "framework": f"NOX v{_NOX_VERSION}", "generated": datetime.now().isoformat(), "target": data.get("target", ""), "_meta": { "scan_id": hashlib.sha256( f"{data.get('target','')}{datetime.now().isoformat()}".encode() ).hexdigest()[:16], "target": data.get("target", ""), "timestamp": datetime.now().isoformat(), "nox_version": _NOX_VERSION, "sources_queried": summary.get("total_records", 0), "pivot_depth_reached": summary.get("pivot_depth", 0), "record_cap": _RECORD_CAP, "truncated": len(clean_records) > _RECORD_CAP, }, "executive_summary": summary, "pivot_chain": chain, "records": clean_records[:_RECORD_CAP], "dork_results": dork_results, "scrape_results": scrape_results, } Path(path).write_text(json.dumps(out_data, indent=2, default=_ser), encoding="utf-8") print(f"[+] JSON report saved: {path}") # ── HTML report ─────────────────────────────────────────────────────── _CSS = ( "*{margin:0;padding:0;box-sizing:border-box}" "body{font-family:'Courier New',monospace;background:#0a0a0a;color:#e0e0e0;padding:20px}" ".hdr{text-align:center;padding:28px;border:1px solid #333;margin-bottom:18px;background:#111}" ".hdr h1{color:#00ff41;font-size:26px;letter-spacing:4px}" ".hdr p{color:#888;margin-top:5px;font-size:12px}" ".kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin:14px 0}" ".kpi{background:#111;border:1px solid #333;padding:16px;text-align:center}" ".kpi .n{font-size:30px;font-weight:bold;color:#00ff41}" ".kpi .l{color:#888;font-size:10px;margin-top:3px}" ".kpi.warn .n{color:#ff6600} .kpi.crit .n{color:#ff0040}" ".sec{margin:18px 0} .sec h2{color:#00ff41;border-bottom:1px solid #333;padding-bottom:5px;margin-bottom:10px}" ".chain{background:#0d1a0d;border:1px solid #1a3a1a;padding:12px;font-size:11px;color:#00cc33;word-break:break-all;margin:8px 0}" "table{width:100%;border-collapse:collapse} th,td{padding:7px;border:1px solid #222;font-size:11px;word-break:break-all}" "th{background:#1a1a1a;color:#00ff41;text-transform:uppercase;font-size:10px} td{background:#0d0d0d}" "tr.c td{background:#1a0005} tr.h td{background:#1a0a00} tr.m td{background:#1a1500}" ".pw{color:#ff0040;font-weight:bold}" ) def to_html(data: dict, path: str) -> None: summary = build_exec_summary(data) chain = render_pivot_chain(data) target = _clean(data.get("target", "Unknown")) records = data.get("records", []) ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC") # KPI dashboard kpis = ( f'
{summary["total_records"]}
TOTAL RECORDS
' f'
{summary["nodes_discovered"]}
NODES DISCOVERED
' f'
{summary["cleartext_passwords"]}
CLEARTEXT PASSWORDS
' f'
{summary["hvt_count"]}
HIGH-VALUE TARGETS
' f'
{summary["pivot_depth"]}
PIVOT DEPTH
' f'
{summary["elapsed"]}
TOTAL TIME
' ) # Severity table sev_rows = "".join( f"{lvl}{cnt}" for lvl, cnt in summary["buckets"].items() if cnt ) # Pivot chain chain_html = "".join(f'
{_clean(c)}
' for c in chain) # Credential rows (top 500, noise-free) cred_rows = "" for r in records[:500]: rs = float(_rget(r, "risk_score") or 0) cls = "c" if rs >= 90 else "h" if rs >= 70 else "m" if rs >= 40 else "" em = _clean(_rget(r, "email") or _rget(r, "username")) pw = _clean(_rget(r, "password")) src = _clean(_rget(r, "source")) bd = _clean(_rget(r, "breach_date")) hvt = " ⚑" if getattr(r, "is_hvt", False) or (isinstance(r, dict) and r.get("is_hvt")) else "" cred_rows += ( f"{em}{hvt}" f"{pw}{src}{bd}{rs:.0f}" ) # Dork results section dork_results = data.get("dork_results", []) or [] dork_rows = "" for h in dork_results: url = h.get("url", "") title = h.get("title", "") or h.get("dork", "") snippet = h.get("snippet", "") engine = h.get("engine", "") link = (f'{_clean(url[:80])}' if url else _clean(title[:80])) dork_rows += ( f"{link}{_clean(snippet[:120])}" f"{_clean(h.get('dork','')[:80])}{_clean(engine)}" ) dork_section = ( f'

Dork Results ({len(dork_results)} hits)

' f'' f'{dork_rows if dork_rows else ""}
URL / TitleSnippetDork QueryEngine
No dork hits
' ) # Scrape results section scrape_results = data.get("scrape_results", {}) or {} pastes = scrape_results.get("pastes", []) creds_sc = scrape_results.get("credentials", []) tg_hits = scrape_results.get("telegram", []) mc_hits = scrape_results.get("dork_misconfigs", []) paste_rows = "" for p in pastes: site = _clean(p.get("site", "")) pid = p.get("id", "") pats = _clean(", ".join(f"{k}({len(v)})" for k, v in (p.get("patterns") or {}).items())) paste_rows += f"{site}{_clean(pid)}{pats}" cred_sc_rows = "" for c in creds_sc: cred_sc_rows += ( f"{_clean(c.get('raw','')[:120])}" f"{_clean(c.get('source',''))}{_clean(c.get('paste_id',''))}" ) tg_rows = "" for t in tg_hits: ch = _clean(t.get("channel", "")) text = _clean(t.get("text", "")[:200]) pats = _clean(", ".join(f"{k}({len(v)})" for k, v in (t.get("patterns") or {}).items())) link = f't.me/s/{ch}' tg_rows += f"{link}{text}{pats}" mc_rows = "" for m in mc_hits: url_m = m.get("url", "") title_m = _clean(m.get("title", "")[:80]) dork_m = _clean(m.get("dork", "")[:80]) link_m = (f'{_clean(url_m[:80])}' if url_m else title_m) mc_rows += f"{link_m}{title_m}{dork_m}" scrape_section = ( f'

Scrape Results

' f'

Pastes ({len(pastes)})

' f'' f'{paste_rows or ""}
SitePaste IDPatterns
None
' f'

Extracted Credentials ({len(creds_sc)})

' f'' f'{cred_sc_rows or ""}
Raw CredentialSourcePaste ID
None
' f'

Telegram CTI ({len(tg_hits)})

' f'' f'{tg_rows or ""}
ChannelMessagePatterns
None
' f'

Misconfigurations ({len(mc_hits)})

' f'' f'{mc_rows or ""}
URLTitleDork
None
' f'
' ) page = ( f'' f'NOX — {target}' f'

[ NOX ]

' f'

Target: {target}  |  {ts}  |  NOX v{_nox_ver()}

' f'

Executive Summary

' f'
{kpis}
' f'' f'{sev_rows}
SeverityCount
' f'

Pivot Chain

{chain_html}
' f'{dork_section}' f'{scrape_section}' f'

Credential Records (top 500)

' f'' f'{cred_rows}
IdentityPasswordSourceDateRisk
' f'' ) Path(path).write_text(page, encoding="utf-8") print(f"[+] HTML report saved: {path}") # ── PDF report (fpdf2) ──────────────────────────────────────────────── def to_pdf(data: dict, path: str, investigator_id: str = "NOX-AUTO") -> None: try: from fpdf import FPDF # type: ignore except ImportError: msg = "[!] fpdf2 not installed — PDF report cannot be generated. Run: pip install fpdf2" print(msg) raise RuntimeError(msg) summary = build_exec_summary(data) chain = render_pivot_chain(data) target = _raw(data.get("target", "Unknown")) records = data.get("records", []) ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC") class _PDF(FPDF): def header(self): self.set_font("Helvetica", "B", 8) self.set_text_color(120, 120, 120) self.cell(0, 5, "NOX - FORENSIC INTELLIGENCE REPORT - CONFIDENTIAL", align="R") self.ln(3) def footer(self): self.set_y(-12) self.set_font("Helvetica", "", 8) self.set_text_color(150, 150, 150) self.cell(0, 5, _pdf_safe(f"Page {self.page_no()} | {target[:50]}"), align="C") pdf = _PDF(orientation="P", unit="mm", format="A4") pdf.set_auto_page_break(auto=True, margin=15) pdf.set_margins(15, 15, 15) # ── Cover page ──────────────────────────────────────────────────── pdf.add_page() pdf.set_fill_color(15, 15, 15) pdf.rect(0, 0, 210, 297, "F") pdf.set_y(65) pdf.set_font("Helvetica", "B", 26) pdf.set_text_color(0, 220, 60) pdf.cell(0, 12, "FORENSIC INTELLIGENCE REPORT", align="C") pdf.ln(8) pdf.set_font("Helvetica", "B", 13) pdf.set_text_color(200, 200, 200) pdf.cell(0, 8, _pdf_safe(f"Target: {target}"), align="C") pdf.ln(6) pdf.set_font("Helvetica", "", 10) pdf.set_text_color(140, 140, 140) for line in [f"Generated: {ts}", f"Investigator: {investigator_id}", f"Framework: NOX v{_nox_ver()}", "Classification: RESTRICTED"]: pdf.cell(0, 6, _pdf_safe(line), align="C") pdf.ln(5) # ── Executive Summary ───────────────────────────────────────────── pdf.add_page() pdf.set_fill_color(255, 255, 255) pdf.set_text_color(0, 0, 0) pdf.set_font("Helvetica", "B", 15) pdf.cell(0, 10, "Executive Summary", ln=True) pdf.set_draw_color(0, 180, 50) pdf.set_line_width(0.4) pdf.line(15, pdf.get_y(), 195, pdf.get_y()) pdf.ln(4) kpis = [ ("Total Time", summary["elapsed"]), ("Nodes Discovered", str(summary["nodes_discovered"])), ("Cleartext Passwords Found", str(summary["cleartext_passwords"])), ("Pivot Depth", str(summary["pivot_depth"])), ("Total Records", str(summary["total_records"])), ("High-Value Targets", str(summary["hvt_count"])), ] pdf.set_font("Helvetica", "B", 10) for label, value in kpis: pdf.set_fill_color(245, 245, 245) pdf.cell(95, 7, _pdf_safe(label), border=1, fill=True) pdf.set_font("Helvetica", "", 10) pdf.cell(80, 7, _pdf_safe(value), border=1, ln=True) pdf.set_font("Helvetica", "B", 10) pdf.ln(4) # Severity breakdown pdf.set_font("Helvetica", "B", 11) pdf.cell(0, 7, "Severity Breakdown", ln=True) _sev_c = {"Critical": (220,0,30), "High": (220,100,0), "Medium": (200,180,0), "Low": (0,150,50), "Info": (100,100,100)} total_b = max(sum(summary["buckets"].values()), 1) for level, count in summary["buckets"].items(): pdf.set_font("Helvetica", "", 9) pdf.cell(35, 6, _pdf_safe(level), border=1) pdf.cell(20, 6, str(count), border=1) bar_w = int(count / total_b * 120) x, y = pdf.get_x(), pdf.get_y() pdf.cell(125, 6, "", border=1) if bar_w: rc, gc, bc = _sev_c.get(level, (100, 100, 100)) pdf.set_fill_color(rc, gc, bc) pdf.rect(x + 1, y + 1, bar_w, 4, "F") pdf.ln() # ── Pivot Chain ─────────────────────────────────────────────────── pdf.ln(5) pdf.set_font("Helvetica", "B", 11) pdf.cell(0, 7, "Pivot Chain Visualization", ln=True) pdf.line(15, pdf.get_y(), 195, pdf.get_y()) pdf.ln(3) pdf.set_font("Courier", "", 8) pdf.set_fill_color(240, 255, 240) for c_line in chain: # Word-wrap long chains at 100 chars for chunk in [c_line[i:i+100] for i in range(0, max(len(c_line), 1), 100)]: pdf.set_x(15) pdf.cell(180, 5, _pdf_safe(chunk), border=0, ln=True, fill=True) pdf.ln(3) # ── Credential Findings ─────────────────────────────────────────── pdf.add_page() pdf.set_font("Helvetica", "B", 13) pdf.set_text_color(0, 0, 0) pdf.cell(0, 9, "Credential Findings", ln=True) pdf.line(15, pdf.get_y(), 195, pdf.get_y()) pdf.ln(3) cols = [("Identity", 60), ("Password", 45), ("Source", 35), ("Date", 25), ("Risk", 15)] def _write_col_headers(): pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30) pdf.set_text_color(255, 255, 255) for col_name, col_w in cols: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln() pdf.set_text_color(0, 0, 0) _write_col_headers() for r in records[:500]: pw = _rget(r, "password") if not pw and not _rget(r, "email") and not _rget(r, "username"): continue # skip noise rows with no actionable data rs = float(_rget(r, "risk_score") or 0) if rs >= 90: pdf.set_fill_color(255, 220, 220) elif rs >= 70: pdf.set_fill_color(255, 240, 220) else: pdf.set_fill_color(255, 255, 255) pdf.set_font("Helvetica", "", 7) # Auto page-break with repeated column headers (§5.1) if pdf.get_y() > pdf.h - 25: pdf.add_page() _write_col_headers() vals = [ _pdf_safe(_rget(r, "email") or _rget(r, "username"), 38), _pdf_safe(pw, 28), _pdf_safe(_rget(r, "source"), 22), _pdf_safe(_rget(r, "breach_date"), 14), f"{rs:.0f}", ] for val, (_, w) in zip(vals, cols): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() # ── Dork Results ───────────────────────────────────────────────── dork_results = data.get("dork_results", []) or [] if dork_results: pdf.add_page() pdf.set_font("Helvetica", "B", 13) pdf.set_text_color(0, 0, 0) pdf.cell(0, 9, _pdf_safe(f"Dork Results ({len(dork_results)} hits)"), ln=True) pdf.line(15, pdf.get_y(), 195, pdf.get_y()) pdf.ln(3) pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30); pdf.set_text_color(255, 255, 255) for col_name, col_w in [("URL / Title", 95), ("Snippet", 55), ("Engine", 30)]: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln(); pdf.set_text_color(0, 0, 0) for h in dork_results[:200]: pdf.set_fill_color(245, 245, 255); pdf.set_font("Helvetica", "", 7) url = _pdf_safe(h.get("url", h.get("title", "")), 65) snippet = _pdf_safe(h.get("snippet", ""), 38) engine = _pdf_safe(h.get("engine", ""), 20) for val, w in zip([url, snippet, engine], [95, 55, 30]): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() # ── Scrape Results ──────────────────────────────────────────────── scrape_results = data.get("scrape_results", {}) or {} pastes = scrape_results.get("pastes", []) creds_sc = scrape_results.get("credentials", []) tg_hits = scrape_results.get("telegram", []) mc_hits = scrape_results.get("dork_misconfigs", []) if pastes or creds_sc or tg_hits or mc_hits: pdf.add_page() pdf.set_font("Helvetica", "B", 13) pdf.set_text_color(0, 0, 0) pdf.cell(0, 9, "Scrape Results", ln=True) pdf.line(15, pdf.get_y(), 195, pdf.get_y()) pdf.ln(3) if pastes: pdf.set_font("Helvetica", "B", 10) pdf.cell(0, 7, _pdf_safe(f"Pastes ({len(pastes)})"), ln=True) pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30); pdf.set_text_color(255, 255, 255) for col_name, col_w in [("Site", 25), ("Paste ID", 80), ("Patterns", 75)]: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln(); pdf.set_text_color(0, 0, 0) for p in pastes[:100]: pdf.set_fill_color(245, 245, 245); pdf.set_font("Helvetica", "", 7) site = _pdf_safe(p.get("site", ""), 15) pid = _pdf_safe(p.get("id", ""), 55) pats = _pdf_safe(", ".join(f"{k}({len(v)})" for k, v in (p.get("patterns") or {}).items()), 50) for val, w in zip([site, pid, pats], [25, 80, 75]): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() pdf.ln(3) if creds_sc: pdf.set_font("Helvetica", "B", 10) pdf.cell(0, 7, _pdf_safe(f"Extracted Credentials ({len(creds_sc)})"), ln=True) pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30); pdf.set_text_color(255, 255, 255) for col_name, col_w in [("Raw Credential", 120), ("Source", 30), ("Paste ID", 30)]: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln(); pdf.set_text_color(0, 0, 0) for c in creds_sc[:150]: pdf.set_fill_color(255, 240, 240); pdf.set_font("Helvetica", "", 7) raw = _pdf_safe(c.get("raw", ""), 80) src = _pdf_safe(c.get("source", ""), 20) pid = _pdf_safe(c.get("paste_id", ""), 20) for val, w in zip([raw, src, pid], [120, 30, 30]): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() pdf.ln(3) if tg_hits: pdf.set_font("Helvetica", "B", 10) pdf.cell(0, 7, _pdf_safe(f"Telegram CTI ({len(tg_hits)})"), ln=True) pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30); pdf.set_text_color(255, 255, 255) for col_name, col_w in [("Channel", 50), ("Message Excerpt", 100), ("Patterns", 30)]: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln(); pdf.set_text_color(0, 0, 0) for t in tg_hits[:80]: pdf.set_fill_color(245, 245, 255); pdf.set_font("Helvetica", "", 7) link = _pdf_safe(f"t.me/s/{t.get('channel','')}", 35) text = _pdf_safe(t.get("text", ""), 70) pats = _pdf_safe(", ".join(f"{k}({len(v)})" for k, v in (t.get("patterns") or {}).items()), 25) for val, w in zip([link, text, pats], [50, 100, 30]): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() pdf.ln(3) if mc_hits: pdf.set_font("Helvetica", "B", 10) pdf.cell(0, 7, _pdf_safe(f"Misconfigurations ({len(mc_hits)})"), ln=True) pdf.set_font("Helvetica", "B", 8) pdf.set_fill_color(30, 30, 30); pdf.set_text_color(255, 255, 255) for col_name, col_w in [("URL", 90), ("Title", 60), ("Dork", 30)]: pdf.cell(col_w, 6, col_name, border=1, fill=True) pdf.ln(); pdf.set_text_color(0, 0, 0) for m in mc_hits[:80]: pdf.set_fill_color(255, 245, 230); pdf.set_font("Helvetica", "", 7) url_m = _pdf_safe(m.get("url", ""), 60) title_m = _pdf_safe(m.get("title", ""), 40) dork_m = _pdf_safe(m.get("dork", ""), 25) for val, w in zip([url_m, title_m, dork_m], [90, 60, 30]): pdf.cell(w, 5, val, border=1, fill=True) pdf.ln() pdf.output(path) print(f"[+] PDF report saved: {path}")