""" audit_report.py - generate a bank-letterhead-styled PDF audit report. Used by app.py to produce the 'Download audit PDF' button. Returns bytes so it can be streamed directly to the user without disk writes. """ import io from datetime import datetime from pathlib import Path from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm from reportlab.lib import colors from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, PageBreak) from reportlab.lib.enums import TA_CENTER, TA_LEFT import cv2 import numpy as np from PIL import Image as PILImage import forensics BAND_COLOURS = { "LOW": colors.HexColor("#16a34a"), "MEDIUM": colors.HexColor("#ca8a04"), "HIGH": colors.HexColor("#ea580c"), "CRITICAL": colors.HexColor("#dc2626"), } def _styles(): s = getSampleStyleSheet() s.add(ParagraphStyle(name="LetterheadBank", parent=s["Title"], fontSize=22, textColor=colors.HexColor("#1e3a8a"), spaceAfter=4)) s.add(ParagraphStyle(name="LetterheadSub", parent=s["Normal"], fontSize=10, textColor=colors.grey, spaceAfter=12, alignment=TA_CENTER)) s.add(ParagraphStyle(name="SectionH", parent=s["Heading2"], fontSize=13, textColor=colors.HexColor("#1e3a8a"), spaceAfter=6, spaceBefore=12)) s.add(ParagraphStyle(name="Evidence", parent=s["Normal"], fontSize=10, leftIndent=12, bulletIndent=2, spaceAfter=2)) s.add(ParagraphStyle(name="Mono", parent=s["Normal"], fontName="Courier", fontSize=8, textColor=colors.dimgray)) return s def _pil_to_flowable(pil_img, max_width=15 * cm, max_height=8 * cm): """Convert a PIL image to a sized RL Image flowable.""" buf = io.BytesIO() pil_img.convert("RGB").save(buf, "PNG") buf.seek(0) img = RLImage(buf) # scale preserving aspect ratio iw, ih = pil_img.size ratio = min(max_width / iw, max_height / ih) img.drawWidth = iw * ratio img.drawHeight = ih * ratio return img def _make_heatmap_image(heat, cmap="hot"): import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(5, 3)) ax.imshow(heat, cmap=cmap) ax.axis("off") buf = io.BytesIO() fig.savefig(buf, format="png", dpi=110, bbox_inches="tight") plt.close(fig) buf.seek(0) return PILImage.open(buf) def build_pdf_report(report, source_path): """ report: the dict returned by forensics.analyse_document(...) source_path: Path to the original document being analysed Returns: bytes of the rendered PDF """ source_path = Path(source_path) s = _styles() buf = io.BytesIO() doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.5 * cm, bottomMargin=1.5 * cm) story = [] # ---- Letterhead ---- story.append(Paragraph("DOCSENTRY - DOCUMENT FORENSICS REPORT", s["LetterheadBank"])) story.append(Paragraph("Confidential - For Underwriting Use Only", s["LetterheadSub"])) # ---- Document metadata table ---- meta_data = [ ["Field", "Value"], ["Document", source_path.name], ["Type", report.get("type", "-")], ["Analysed at", report.get("analysed_at", "-")[:19].replace("T", " ")], ["SHA-256", report.get("sha256", "-")[:32] + "..."], ] t = Table(meta_data, colWidths=[4 * cm, 13 * cm]) t.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a8a")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f8fafc"), colors.white]), ])) story.append(t) story.append(Spacer(1, 0.6 * cm)) # ---- Risk verdict ---- band_str = report.get("risk_band", "UNKNOWN") band_colour = BAND_COLOURS.get(band_str, colors.grey) verdict_table = Table([ [Paragraph(f"" f"{band_str}", s["Normal"]), Paragraph(f"Risk score: {report.get('risk_score', '-')}
" f"Action: {report.get('recommended_action', '-')}", s["Normal"])], ], colWidths=[5 * cm, 12 * cm]) verdict_table.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (0, 0), band_colour), ("BACKGROUND", (1, 0), (1, 0), colors.HexColor("#f1f5f9")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("BOX", (0, 0), (-1, -1), 0.4, colors.grey), ("LEFTPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 12), ("TOPPADDING", (0, 0), (-1, -1), 12), ("BOTTOMPADDING",(0, 0), (-1, -1), 12), ])) story.append(verdict_table) story.append(Spacer(1, 0.4 * cm)) # ---- Sub-score breakdown ---- story.append(Paragraph("Sub-score breakdown", s["SectionH"])) sub_rows = [["Detector", "Score (0=clean, 1=suspicious)"]] for k, v in (report.get("sub_scores") or {}).items(): bar = "#" * int(v * 30) sub_rows.append([k, f"{v:.2f} {bar}"]) if len(sub_rows) > 1: sub_t = Table(sub_rows, colWidths=[5 * cm, 12 * cm]) sub_t.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a8a")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("FONTNAME", (1, 1), (1, -1), "Courier"), ("FONTSIZE", (0, 0), (-1, -1), 9), ])) story.append(sub_t) story.append(Spacer(1, 0.4 * cm)) # ---- Evidence ---- story.append(Paragraph("Forensic evidence", s["SectionH"])) for ev in report.get("evidence", []): story.append(Paragraph(f"• {ev}", s["Evidence"])) story.append(Spacer(1, 0.3 * cm)) # ---- Image-specific: heatmaps ---- if report.get("type") == "image": try: story.append(PageBreak()) story.append(Paragraph("Forensic visualizations", s["SectionH"])) # ELA ela_img, ela_s = forensics.error_level_analysis(source_path) story.append(Paragraph(f"Error Level Analysis (score: {ela_s:.2f})", s["Normal"])) story.append(_pil_to_flowable(ela_img)) story.append(Spacer(1, 0.3 * cm)) # Copy-move viz, n_cm, _ = forensics.copy_move_detect(source_path) if viz is not None: story.append(Paragraph(f"Copy-move matches: {n_cm}", s["Normal"])) viz_rgb = cv2.cvtColor(viz, cv2.COLOR_BGR2RGB) story.append(_pil_to_flowable(PILImage.fromarray(viz_rgb))) story.append(Spacer(1, 0.3 * cm)) # Noise heatmap heat, ratio = forensics.noise_inconsistency(source_path) story.append(Paragraph(f"Noise outlier ratio: {ratio:.2%}", s["Normal"])) story.append(_pil_to_flowable(_make_heatmap_image(heat))) except Exception as e: story.append(Paragraph(f"Could not render heatmaps: {e}", s["Normal"])) # ---- PDF-specific audit details ---- if report.get("type") == "pdf": audit = report.get("pdf_audit", {}) fonts = report.get("font_audit", {}) story.append(Paragraph("PDF structural audit", s["SectionH"])) meta = audit.get("metadata", {}) or {} pdf_rows = [ ["Pages", str(audit.get("pages", "-"))], ["EOF markers", str(audit.get("eof_markers", "-"))], ["Producer", str(meta.get("producer", "-"))], ["Creator", str(meta.get("creator", "-"))], ["Fonts used", ", ".join(fonts.get("fonts", []) or ["-"])], ] pdf_t = Table(pdf_rows, colWidths=[5 * cm, 12 * cm]) pdf_t.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("FONTSIZE", (0, 0), (-1, -1), 9), ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f1f5f9")), ])) story.append(pdf_t) story.append(Spacer(1, 0.3 * cm)) for f in audit.get("flags", []) + fonts.get("flags", []): if f not in ("clean", "ok"): story.append(Paragraph(f"• {f}", s["Evidence"])) # ---- ML model verdict (if present) ---- if "ml_prediction" in report: ml = report["ml_prediction"] story.append(Paragraph("Trained ML model verdict", s["SectionH"])) ml_t = Table([ ["Tamper probability", f"{ml['tamper_probability']:.1%}"], ["Verdict", ml["verdict"]], ], colWidths=[5 * cm, 12 * cm]) ml_t.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("FONTSIZE", (0, 0), (-1, -1), 9), ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f1f5f9")), ])) story.append(ml_t) # ---- Footer ---- story.append(Spacer(1, 0.6 * cm)) story.append(Paragraph( "This report was generated automatically by DocSentry. " "Findings are based on forensic-signal heuristics and an explainable " "Random Forest classifier. Manual verification is required for any " "file in HIGH or CRITICAL bands.", s["Mono"])) doc.build(story) buf.seek(0) return buf.read() if __name__ == "__main__": # CLI smoke test import sys if len(sys.argv) < 2: print("Usage: python audit_report.py ") sys.exit(1) src = Path(sys.argv[1]) report = forensics.analyse_document(src) pdf_bytes = build_pdf_report(report, src) out = src.with_suffix(".audit.pdf") out.write_bytes(pdf_bytes) print(f"Wrote {out} ({len(pdf_bytes)} bytes)")