truscan / route /document_forensics.py
Manar55's picture
Deploy a5180b1
82d7501 verified
Raw History Blame Contribute Delete
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}
# ============================================================
# 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"<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