""" document_forensics.py — forensic metadata inspection for PDF and Word (.docx) files. Completely different domain from the image pipeline in analysis_engine.py: no pixels, no ML models — this reads the FILE'S OWN internal structure (creation/modification timestamps, producing software, incremental-save history, track-changes markup) for signs of tampering after the fact. IMPORTANT — these are FORENSIC INDICATORS, not a definitive verdict. Unlike the image pipeline (validated against thousands of labeled samples via bulk_calibrate.py/recalibrate.py), there's no labeled dataset backing these heuristics yet. Treat the output as leads for a human to investigate, not an automated "real/fake" score — the returned categories are informational, same spirit as EXIF inspection for images. PDF checks: - Creation date vs. modification date mismatch - Producer/Creator software (e.g. was it made by a PDF editor known for post-hoc edits like a scanner app, vs. a document generator?) - Incremental update count (a PDF can be saved multiple times, each appending a new revision rather than rewriting the file — many increments after an original creation is a classic sign of after-the-fact editing, especially concerning if the doc claims to be a final/signed version) - Digital signature presence (does NOT cryptographically verify the signature — just reports whether one exists, so you know to check it through a proper tool if it does) DOCX checks: - Creation date vs. last-modified date mismatch - Application/software that last saved it - Track-changes markup present (insertions/deletions still in the file) — doesn't mean forgery, but means edit history exists that a casual read of the rendered document wouldn't show - lastModifiedBy vs. creator mismatch (different named authors) """ import io import zipfile from datetime import datetime, timezone from xml.etree import ElementTree as ET def _r(key, text, **params): return {"key": key, "en": text, "params": params} # ============================================================ # PDF # ============================================================ def analyze_pdf(data): from pypdf import PdfReader from pypdf.errors import PdfReadError findings = [] suspicion = 0 try: reader = PdfReader(io.BytesIO(data)) except Exception as e: return { "suspicion_score": 0, "categories": [{ "key": "cat_pdf_parse", "name": "PDF Structure", "score": 0, "reasons": [_r("pdf_parse_failed", f"Could not parse PDF: {e}")], }], "verdict": "Could not analyze this PDF's structure.", "verdict_level": "low", } meta = reader.metadata or {} def parse_pdf_date(s): if not s: return None s = str(s) # PDF dates look like: D:20240115103000+02'00' s = s.replace("D:", "").split("+")[0].split("-")[0].split("Z")[0] try: return datetime.strptime(s[:14], "%Y%m%d%H%M%S") except Exception: return None created = parse_pdf_date(meta.get("/CreationDate")) modified = parse_pdf_date(meta.get("/ModDate")) producer = str(meta.get("/Producer", "") or "") creator = str(meta.get("/Creator", "") or "") date_reasons = [] if created and modified: delta = abs((modified - created).total_seconds()) if delta > 3600: # more than 1 hour apart suspicion += 30 days = delta / 86400 date_reasons.append(_r("pdf_date_mismatch", f"Creation date ({created.date()}) and modification date ({modified.date()}) differ by " f"{days:.1f} days — the file was edited after its original creation", created=str(created.date()), modified=str(modified.date()), days=f"{days:.1f}")) elif not created: date_reasons.append(_r("pdf_no_creation_date", "No creation date found in metadata — unusual for a normally-produced PDF")) suspicion += 10 findings.append({ "key": "cat_pdf_dates", "name": "Creation vs. Modification Dates", "score": min(100, suspicion), "reasons": date_reasons or [_r("pdf_dates_consistent", "Creation and modification dates are consistent")], }) # Producer/Creator software software_reasons = [] software_score = 0 if producer or creator: software_reasons.append(_r("pdf_software", f"Produced by: {producer or 'unknown'}" + (f", created with: {creator}" if creator and creator != producer else ""), producer=producer, creator=creator)) else: software_reasons.append(_r("pdf_no_software", "No producer/creator software recorded in metadata — can indicate metadata was stripped")) software_score = 20 findings.append({ "key": "cat_pdf_software", "name": "Producing Software", "score": software_score, "reasons": software_reasons, }) # Incremental updates (multiple %%EOF markers = multiple saves) eof_count = data.count(b"%%EOF") incr_score = 0 incr_reasons = [] if eof_count > 1: incr_score = min(100, (eof_count - 1) * 20) incr_reasons.append(_r("pdf_incremental_saves", f"File contains {eof_count} end-of-file markers, meaning it was saved/appended to " f"{eof_count - 1} time(s) after its original version — each incremental save can hide " f"what the document looked like before", count=str(eof_count))) else: incr_reasons.append(_r("pdf_single_save", "File appears to have a single, non-incremental save history")) findings.append({ "key": "cat_pdf_incremental", "name": "Incremental Save History", "score": incr_score, "reasons": incr_reasons, }) # Digital signature presence (existence only, not cryptographic validation) sig_reasons = [] has_signature = b"/ByteRange" in data and b"/Sig" in data if has_signature: sig_reasons.append(_r("pdf_has_signature", "This PDF contains a digital signature field. This tool does NOT verify the " "signature's cryptographic validity — check it in Adobe Acrobat or a dedicated " "signature-verification tool for a trustworthy result")) else: sig_reasons.append(_r("pdf_no_signature", "No digital signature found in this PDF")) findings.append({ "key": "cat_pdf_signature", "name": "Digital Signature", "score": 0, # informational only, not a suspicion contributor "reasons": sig_reasons, }) overall = min(100, suspicion + incr_score * 0.5) if overall >= 50: verdict = "This PDF shows structural signs of post-creation editing — investigate the flagged items" verdict_level = "high" elif overall >= 25: verdict = "This PDF has some minor structural inconsistencies — worth a closer look" verdict_level = "medium" else: verdict = "This PDF's structure looks consistent with a normal, unedited file" verdict_level = "low" return { "suspicion_score": round(overall), "categories": findings, "verdict": verdict, "verdict_level": verdict_level, } # ============================================================ # DOCX # ============================================================ NS = { "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "dc": "http://purl.org/dc/elements/1.1/", "dcterms": "http://purl.org/dc/terms/", "ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", } def analyze_docx(data): findings = [] suspicion = 0 try: z = zipfile.ZipFile(io.BytesIO(data)) except Exception as e: return { "suspicion_score": 0, "categories": [{ "key": "cat_docx_parse", "name": "Document Structure", "score": 0, "reasons": [_r("docx_parse_failed", f"Could not parse this .docx file: {e}")], }], "verdict": "Could not analyze this document's structure.", "verdict_level": "low", } def read_xml(path): try: return ET.fromstring(z.read(path)) except Exception: return None core = read_xml("docProps/core.xml") app = read_xml("docProps/app.xml") created = modified = creator = last_modified_by = None if core is not None: created_el = core.find("dcterms:created", NS) modified_el = core.find("dcterms:modified", NS) creator_el = core.find("dc:creator", NS) lmb_el = core.find("cp:lastModifiedBy", NS) created = created_el.text if created_el is not None else None modified = modified_el.text if modified_el is not None else None creator = creator_el.text if creator_el is not None else None last_modified_by = lmb_el.text if lmb_el is not None else None def parse_iso(s): if not s: return None try: return datetime.fromisoformat(s.replace("Z", "+00:00")) except Exception: return None created_dt, modified_dt = parse_iso(created), parse_iso(modified) date_reasons = [] date_score = 0 if created_dt and modified_dt: delta = abs((modified_dt - created_dt).total_seconds()) if delta > 3600: days = delta / 86400 date_score = 30 date_reasons.append(_r("docx_date_mismatch", f"Created {created_dt.date()}, last modified {modified_dt.date()} — " f"a gap of {days:.1f} days between creation and last edit", created=str(created_dt.date()), modified=str(modified_dt.date()), days=f"{days:.1f}")) else: date_reasons.append(_r("docx_no_dates", "Creation/modification dates not found in document metadata")) findings.append({ "key": "cat_docx_dates", "name": "Creation vs. Modification Dates", "score": date_score, "reasons": date_reasons or [_r("docx_dates_consistent", "Creation and modification dates are close together")], }) author_reasons = [] author_score = 0 if creator and last_modified_by and creator.strip().lower() != last_modified_by.strip().lower(): author_score = 15 author_reasons.append(_r("docx_author_mismatch", f"Originally created by \"{creator}\", but last edited by a different person: \"{last_modified_by}\"", creator=creator, editor=last_modified_by)) else: author_reasons.append(_r("docx_author_consistent", f"Creator and last editor match: {creator or last_modified_by or 'unknown'}")) findings.append({ "key": "cat_docx_authors", "name": "Author Consistency", "score": author_score, "reasons": author_reasons, }) app_name = None if app is not None: app_el = app.find("ep:Application", NS) app_name = app_el.text if app_el is not None else None findings.append({ "key": "cat_docx_software", "name": "Producing Software", "score": 0, "reasons": [_r("docx_software", f"Last saved with: {app_name or 'unknown application'}", app=app_name or "unknown")], }) # Track-changes markup still present in the file has_tracked_changes = False try: doc_xml = z.read("word/document.xml") has_tracked_changes = b"" in doc_xml or b"w:del>" in doc_xml except Exception: pass tc_reasons = [] if has_tracked_changes: tc_reasons.append(_r("docx_tracked_changes", "This file still contains tracked-changes markup (insertions/deletions) — " "an edit history exists that isn't visible when just reading the rendered document")) else: tc_reasons.append(_r("docx_no_tracked_changes", "No tracked-changes markup found in the document")) findings.append({ "key": "cat_docx_tracked_changes", "name": "Track-Changes History", "score": 20 if has_tracked_changes else 0, "reasons": tc_reasons, }) overall = date_score + author_score + (20 if has_tracked_changes else 0) overall = min(100, overall) if overall >= 50: verdict = "This document shows multiple signs of post-creation editing — investigate the flagged items" verdict_level = "high" elif overall >= 20: verdict = "This document has some minor inconsistencies — worth a closer look" verdict_level = "medium" else: verdict = "This document's structure looks consistent with normal, undisputed authorship" verdict_level = "low" return { "suspicion_score": round(overall), "categories": findings, "verdict": verdict, "verdict_level": verdict_level, } def analyze_generic_file(data, filename, ext): """ Fallback for any file format without a dedicated deep-forensic analyzer (only .pdf and .docx have one). Returns basic file information honestly, with suspicion_score=0 and a clear note that this format wasn't deeply inspected — NOT a claim that the file is "clean", just that we have no structural markers to check for it. Special-cased: plain text (.txt) genuinely has almost no forensic metadata to inspect (no creation/modified dates, no producer software embedded in the format itself) — that limitation is real, not a gap in our code, so the note explains why. """ size_kb = len(data) / 1024 reasons = [_r("generic_file_info", f"This is a .{ext} file ({size_kb:.1f} KB). No dedicated deep forensic analysis " f"is available for this format yet (only PDF and Word have one) — this is basic " f"file info only, not a verdict on authenticity.")] if ext == "txt": reasons.append(_r("txt_no_metadata", "Plain text files carry no embedded creation/modification dates or producer " "software — there's inherently no file-structure metadata to inspect for this format")) return { "suspicion_score": 0, "categories": [{ "key": "cat_generic_file", "name": "Basic File Info", "score": 0, "reasons": reasons, }], "verdict": "No deep forensic analysis available for this format — basic file info only", "verdict_level": "low", } def analyze_document_bytes(data, filename): ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if ext == "pdf": result = analyze_pdf(data) elif ext == "docx": result = analyze_docx(data) else: result = analyze_generic_file(data, filename, ext) result["type"] = "document" result["format"] = ext return result