Download route/document_forensics.py from Manar55/truscan: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/document_forensics.py
- Command line
-
hf download hf://spaces/Manar55/truscan/route/document_forensics.py
-
curl -L -o document_forensics.py https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/document_forensics.py
15.1 kB
| """ | |
| 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} | |
| # ============================================================ | |
| # ============================================================ | |
| 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"<w:ins " in doc_xml or b"<w:del " in doc_xml or b"w:ins>" 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 | |