| |
| |
| """ |
| PDF Redaction Auditor & Sanitizer (CLI Tool) |
| Part of the PhishDestroy Intelligence Suite |
| |
| Forensic utility to audit, detect, and neutralize failed visual redactions in PDF documents. |
| Flawed redactions occur when solid black vector shapes are drawn on top of sensitive text |
| characters without deleting the underlying text stream. |
| |
| This tool offers two powerful sanitization methodologies: |
| 1. 'stream' (Default): Surgical stream replacement of rectangle painting operators with 'no-fill'. |
| 2. 'overlay' (Fallback): Appending white rects over coordinates of detected black vector paths. |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import re |
| from datetime import datetime |
|
|
| |
| try: |
| import fitz |
| except ImportError: |
| print("\033[91m[!] Error: PyMuPDF (fitz) is not installed in your active environment.\033[0m") |
| print("\033[93m[*] Please install it using: pip install pymupdf\033[0m") |
| sys.exit(1) |
|
|
| class Theme: |
| RST = "\033[0m" |
| BOLD = "\033[1m" |
| DIM = "\033[2m" |
| RED = "\033[91m" |
| GREEN = "\033[92m" |
| YELLOW = "\033[93m" |
| BLUE = "\033[94m" |
| CYAN = "\033[96m" |
| MAGENTA = "\033[95m" |
|
|
| def log_info(msg, prefix="*"): |
| ts = datetime.now().strftime("%H:%M:%S") |
| color = Theme.CYAN if prefix == "*" else Theme.GREEN |
| print(f"{Theme.DIM}[{ts}]{Theme.RST} {color}{prefix}{Theme.RST} {msg}") |
|
|
| def log_warn(msg): |
| ts = datetime.now().strftime("%H:%M:%S") |
| print(f"{Theme.DIM}[{ts}]{Theme.RST} {Theme.YELLOW}!{Theme.RST} {msg}") |
|
|
| def log_error(msg): |
| ts = datetime.now().strftime("%H:%M:%S") |
| print(f"{Theme.DIM}[{ts}]{Theme.RST} {Theme.RED}x{Theme.RST} {msg}") |
|
|
| def check_text_under_shapes(doc, threshold=0.1): |
| """ |
| Scans the pages of the PDF to see if there are actual text character layers |
| situated directly underneath the coordinates of any detected solid black visual drawings. |
| Returns a dictionary mapping page numbers to lists of leaked text snippets. |
| """ |
| log_info("Scanning document layout for active text-under-shape leaks...") |
| leaks = {} |
| total_black_bars = 0 |
| total_leaked_bars = 0 |
|
|
| |
| scan_limit = min(len(doc), 100) |
|
|
| for page_num in range(scan_limit): |
| page = doc[page_num] |
| drawings = page.get_drawings() |
| words = page.get_text("words") |
|
|
| page_leaks = [] |
|
|
| for path in drawings: |
| fill = path.get("fill") |
| if fill and fill[0] <= threshold and fill[1] <= threshold and fill[2] <= threshold: |
| total_black_bars += 1 |
| rect = fitz.Rect(path["rect"]) |
|
|
| |
| words_under = [] |
| for w in words: |
| w_rect = fitz.Rect(w[0], w[1], w[2], w[3]) |
| |
| if rect.contains(w_rect) or rect.intersects(w_rect): |
| words_under.append(w[4]) |
|
|
| if words_under: |
| total_leaked_bars += 1 |
| leak_text = " ".join(words_under) |
| |
| if len(leak_text) > 60: |
| leak_text = leak_text[:57] + "..." |
| page_leaks.append({ |
| "rect": path["rect"], |
| "text": leak_text |
| }) |
|
|
| if page_leaks: |
| leaks[page_num + 1] = page_leaks |
|
|
| return leaks, total_black_bars, total_leaked_bars |
|
|
| def audit_document_integrity(input_path, threshold=0.1): |
| """Parses PDF metadata, scans for visual black bars, and verifies if text is actually leaked underneath.""" |
| log_info("Performing forensic layout audit...") |
| try: |
| doc = fitz.open(input_path) |
| except Exception as e: |
| log_error(f"Failed to open PDF document: {e}") |
| sys.exit(1) |
|
|
| metadata = doc.metadata |
| producer = metadata.get("producer", "Unknown") or "Unknown" |
| creator = metadata.get("creator", "Unknown") or "Unknown" |
| page_count = len(doc) |
|
|
| log_info(f"Metadata Producer: {Theme.BOLD}{producer}{Theme.RST}") |
| log_info(f"Metadata Creator : {Theme.BOLD}{creator}{Theme.RST}") |
| log_info(f"Total Page Count : {Theme.BOLD}{page_count}{Theme.RST}") |
|
|
| |
| leaks, total_black_bars, total_leaked_bars = check_text_under_shapes(doc, threshold) |
| doc.close() |
|
|
| p_low = producer.lower() |
| is_aspose = "aspose" in p_low or "aspose.pdf" in p_low |
|
|
| if is_aspose: |
| log_warn(f"Document generator signature matches vulnerable exporter: {Theme.BOLD}{producer}{Theme.RST}") |
|
|
| if total_leaked_bars > 0: |
| log_error(f"{Theme.BOLD}{Theme.RED}CONFIRMED ACTIVE DATA LEAK DETECTED!{Theme.RST}") |
| log_error(f"Found {Theme.BOLD}{total_leaked_bars}{Theme.RST} black visual shapes actively covering extractable text layers (out of {total_black_bars} total visual bars).") |
|
|
| |
| log_info("Leaked Text Preview:") |
| preview_count = 0 |
| for page_num, page_leaks in leaks.items(): |
| for leak in page_leaks: |
| if preview_count >= 5: |
| break |
| print(f" {Theme.DIM}[Page {page_num}]{Theme.RST} {Theme.RED}Leak:{Theme.RST} \"{Theme.BOLD}{leak['text']}{Theme.RST}\"") |
| preview_count += 1 |
| if preview_count >= 5: |
| break |
| if total_leaked_bars > 5: |
| log_info(f"... and {total_leaked_bars - 5} more leaks.") |
| else: |
| if total_black_bars > 0: |
| log_info(f"Found {total_black_bars} black visual bars, but {Theme.BOLD}{Theme.GREEN}zero text leaks{Theme.RST} underneath them.", prefix="+") |
| log_info("Redactions are structurally secure (text has been successfully destroyed or was never present under the bars).", prefix="+") |
| else: |
| log_info(f"{Theme.BOLD}{Theme.GREEN}SECURE: No visual black mask bars detected.{Theme.RST} Document is clean.", prefix="+") |
|
|
| def sanitize_via_streams(input_path, output_path): |
| """ |
| Surgical, global stream-level sanitization (Method 'stream'). |
| Scans ALL streams in the PDF (including page contents, Form XObjects, and patterns) |
| and replaces filled rect painting operators with 'no-fill' (re n). This handles visual |
| blockers nested inside Form XObjects or external resource objects, neutralizing them perfectly. |
| """ |
| log_info("Starting surgical global stream-level sanitization (Content Streams & Form XObjects)...") |
| doc = fitz.open(input_path) |
| |
| total_streams_modified = 0 |
| total_operators_replaced = 0 |
|
|
| |
| for xref in range(1, doc.xref_length()): |
| if not doc.is_stream(xref): |
| continue |
| |
| try: |
| |
| obj_dict = doc.xref_object(xref) |
| if any(marker in obj_dict for marker in ["/Type /Font", "/Subtype /Image", "/Type /Halftone"]): |
| continue |
| |
| |
| stream_bytes = doc.xref_stream(xref) |
| text = stream_bytes.decode('latin-1') |
| |
| |
| |
| modified_text, count = re.subn( |
| r'\bre\s+([fFbB]\*?)(?=\s|$)', |
| lambda m: f"re{m.group(0)[2:-len(m.group(1))]}n", |
| text |
| ) |
| |
| if count > 0: |
| doc.update_stream(xref, modified_text.encode('latin-1')) |
| total_streams_modified += 1 |
| total_operators_replaced += count |
| except Exception as e: |
| continue |
|
|
| log_info(f"Neutralized {Theme.BOLD}{total_operators_replaced}{Theme.RST} black visual block rectangles across {Theme.BOLD}{total_streams_modified}{Theme.RST} streams (including Page Contents and Form XObjects).", prefix="+") |
| return doc |
|
|
| def sanitize_via_overlays(doc, r_thresh, g_thresh, b_thresh): |
| """ |
| Drawing-level overlay sanitization (Method 'overlay'). |
| Detects solid black vector drawing layers and appends a white block directly over them. |
| This visually masks out the mask itself, letting the text be visible in traditional viewers. |
| """ |
| log_info("Starting canvas overlay-level drawing sanitization...") |
| total_masks_stripped = 0 |
|
|
| for page_num in range(len(doc)): |
| page = doc[page_num] |
| drawings = page.get_drawings() |
|
|
| for path in drawings: |
| fill = path.get("fill") |
| |
| if fill and fill[0] <= r_thresh and fill[1] <= g_thresh and fill[2] <= b_thresh: |
| rect = fitz.Rect(path["rect"]) |
| |
| page.draw_rect(rect, color=(1, 1, 1), fill=(1, 1, 1), width=0) |
| total_masks_stripped += 1 |
|
|
| log_info(f"Overlaid {Theme.BOLD}{total_masks_stripped}{Theme.RST} black drawings with visual whiteouts.", prefix="+") |
| return doc |
|
|
| def list_structural_elements(input_path): |
| """Lists page-by-page structural elements inside the PDF (images, drawings, annotations, text blocks).""" |
| log_info(f"Analyzing and listing structural elements of: {Theme.BOLD}{input_path}{Theme.RST}") |
| doc = fitz.open(input_path) |
| |
| for page_num in range(len(doc)): |
| page = doc[page_num] |
| drawings = len(page.get_drawings()) |
| images = len(page.get_images()) |
| annots = len(list(page.annots())) |
| text_blocks = len(page.get_text("blocks")) |
| |
| print(f" {Theme.CYAN}[Page {page_num+1}]{Theme.RST} " |
| f"Drawings: {Theme.BOLD}{drawings}{Theme.RST} | " |
| f"Raster Images: {Theme.BOLD}{images}{Theme.RST} | " |
| f"Annotations: {Theme.BOLD}{annots}{Theme.RST} | " |
| f"Text Blocks: {Theme.BOLD}{text_blocks}{Theme.RST}") |
| doc.close() |
|
|
| def extract_and_save_leaks(input_path, output_txt_path, threshold=0.1): |
| """Saves all extracted text situated underneath black bars to a text file.""" |
| log_info("Running leak extraction engine...") |
| doc = fitz.open(input_path) |
| |
| leaks = [] |
| |
| for page_num in range(len(doc)): |
| page = doc[page_num] |
| drawings = page.get_drawings() |
| words = page.get_text("words") |
| |
| page_leaks = [] |
| for path in drawings: |
| fill = path.get("fill") |
| if fill and fill[0] <= threshold and fill[1] <= threshold and fill[2] <= threshold: |
| rect = fitz.Rect(path["rect"]) |
| words_under = [w[4] for w in words if rect.contains(fitz.Rect(w[0], w[1], w[2], w[3])) or rect.intersects(fitz.Rect(w[0], w[1], w[2], w[3]))] |
| if words_under: |
| page_leaks.append(" ".join(words_under)) |
| |
| if page_leaks: |
| leaks.append((page_num + 1, page_leaks)) |
| |
| doc.close() |
| |
| if leaks: |
| try: |
| with open(output_txt_path, "w", encoding="utf-8") as f: |
| f.write(f"=== FORENSIC LEAK ANALYSIS FOR {os.path.basename(input_path)} ===\n") |
| f.write(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") |
| f.write("="*60 + "\n\n") |
| |
| total_leaks = 0 |
| for page, page_leaks in leaks: |
| f.write(f"--- Page {page} ---\n") |
| for idx, text in enumerate(page_leaks): |
| total_leaks += 1 |
| f.write(f"Leak #{idx+1} [Coordinates in original PDF]:\n") |
| f.write(f" Content: \"{text}\"\n\n") |
| f.write(f"Total verified leaks: {total_leaks}\n") |
| log_info(f"Extracted {total_leaks} leaks and saved to text report: {Theme.BOLD}{output_txt_path}{Theme.RST}", prefix="+") |
| except Exception as e: |
| log_error(f"Failed to write leaks report: {e}") |
| else: |
| log_info("No active leaks found to extract.", prefix="+") |
|
|
| def decompose_to_text_only(input_path, output_path): |
| """ |
| Decomposes the PDF by completely stripping all visual drawing layers, vector shapes, |
| filled boxes, borders, lines, and annotations, leaving only the pure text and image elements. |
| """ |
| log_info("Decomposing document to reveal raw core text layers...") |
| doc = fitz.open(input_path) |
| |
| total_cleaned = 0 |
| |
| for xref in range(1, doc.xref_length()): |
| if not doc.is_stream(xref): |
| continue |
| |
| try: |
| obj_dict = doc.xref_object(xref) |
| if any(marker in obj_dict for marker in ["/Type /Font", "/Subtype /Image", "/Type /Halftone"]): |
| continue |
| |
| stream_bytes = doc.xref_stream(xref) |
| text = stream_bytes.decode('latin-1') |
| |
| |
| |
| modified_text, count = re.subn( |
| r'\b(re|l|c|v|y|m|h)\s+([fFsSbB]\*?)(?=\s|$)', |
| lambda m: f"{m.group(1)} n", |
| text |
| ) |
| |
| if count > 0: |
| doc.update_stream(xref, modified_text.encode('latin-1')) |
| total_cleaned += count |
| except Exception: |
| continue |
| |
| log_info(f"Decomposed vector layers. Neutralized {total_cleaned} graphic operations.", prefix="+") |
| doc.save(output_path, garbage=4, deflate=True, clean=True) |
| doc.close() |
|
|
| def main(): |
| print(f"\n{Theme.BOLD}{Theme.BLUE}=== PDF REDACTION AUDITOR & SANITIZER (CLI) ==={Theme.RST}") |
| print(f"{Theme.DIM}Powered by PhishDestroy Threat Intelligence Division{Theme.RST}\n") |
|
|
| parser = argparse.ArgumentParser( |
| description="Audit PDF structures, detect visual masking redaction errors, and strip vector masks." |
| ) |
| parser.add_argument( |
| "-i", "--input", required=True, help="Path to the compromised PDF document to audit/sanitize" |
| ) |
| parser.add_argument( |
| "-o", "--output", help="Destination path for sanitized PDF (default: <input>_unmasked.pdf)" |
| ) |
| parser.add_argument( |
| "-m", "--method", choices=["stream", "overlay", "both"], default="stream", |
| help="Sanitization strategy: 'stream' (surgical replace), 'overlay' (visual whiteout), 'both' (apply stream, then fallback overlay)" |
| ) |
| parser.add_argument( |
| "-t", "--threshold", type=float, default=0.1, |
| help="RGB color channel maximum threshold (0.0 - 1.0) for detecting black shapes in overlay mode (default: 0.1)" |
| ) |
| parser.add_argument( |
| "-l", "--list", action="store_true", |
| help="Decompose and list structural elements (images, drawings, blocks) page-by-page and exit" |
| ) |
| parser.add_argument( |
| "-e", "--extract-txt", help="Path to extract and save verified leak text to a report (e.g. leaks.txt) and exit" |
| ) |
| parser.add_argument( |
| "-d", "--decompose-pdf", action="store_true", |
| help="Decompose PDF into raw text layer by stripping ALL vector drawings, lines, and boxes, and exit" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| if not os.path.exists(args.input): |
| log_error(f"Input file does not exist: {args.input}") |
| sys.exit(1) |
|
|
| |
| if args.list: |
| list_structural_elements(args.input) |
| sys.exit(0) |
|
|
| |
| if args.decompose_pdf: |
| if not args.output: |
| base, ext = os.path.splitext(args.input) |
| args.output = f"{base}_decomposed{ext}" |
| decompose_to_text_only(args.input, args.output) |
| sys.exit(0) |
|
|
| |
| if args.extract_txt: |
| extract_and_save_leaks(args.input, args.extract_txt, args.threshold) |
| sys.exit(0) |
|
|
| |
| if not args.output: |
| base, ext = os.path.splitext(args.input) |
| args.output = f"{base}_unmasked{ext}" |
|
|
| |
| audit_document_integrity(args.input, args.threshold) |
|
|
| |
| doc = None |
|
|
| |
| if args.method in ["stream", "both"]: |
| doc = sanitize_via_streams(args.input, args.output) |
|
|
| |
| if args.method == "overlay": |
| doc = fitz.open(args.input) |
| doc = sanitize_via_overlays(doc, args.threshold, args.threshold, args.threshold) |
| elif args.method == "both": |
| doc = sanitize_via_overlays(doc, args.threshold, args.threshold, args.threshold) |
|
|
| |
| try: |
| log_info(f"Writing sanitized file to: {Theme.BOLD}{args.output}{Theme.RST}") |
| |
| |
| |
| doc.save(args.output, garbage=4, deflate=True, clean=True) |
| doc.close() |
| log_info(f"{Theme.BOLD}{Theme.GREEN}Sanitized PDF successfully saved!{Theme.RST}\n", prefix="+") |
| except Exception as e: |
| log_error(f"Failed to serialize output document: {e}") |
| sys.exit(1) |
|
|
| if __name__ == "__main__": |
| main() |
|
|