Inspect static PDF and OOXML content with bounded local coverage

This commit is contained in:
Karl committed 2026-10-14 10:12:34 +02:00
1 parent 69d820e315
commit 9e7cb57093
14 files changed
+758 -51

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@ All inspection is local. No scan downloads models or calls cloud inference.
- YARA: genuine pinned yara-python 4.5.4 in private Windows CPython 3.13 runtime. Centrally supplied sources have bounded size and count, includes disabled, imported modules disallowed, compile warnings treated as errors, and five-second match timeout. Rules compile from in-memory strings and scan bytes. Errors, unavailable engine, missing rules and oversize payloads block downloads. Compilation currently runs inside the inspector process; a native compiler hang is bounded by the browser's inspector request deadline but does not restart the inspector automatically.
- Text: UTF-8 extraction and deterministic secret/literal/fingerprint classification.
- PDF/OOXML: bounded extraction of text and embedded images; full rendered visual coverage is incomplete and outbound DLP fails closed.
- PDF: bounded static subset now inspects decoded text, metadata and all indexed objects, independently OCRs embedded page images, and renders/OCRs every page with the bundled offline PDFium/OCR runtime. Active content, forms, annotations, attachments, layers, incremental updates, orphan/unsupported streams and embedded/custom font encodings remain blocked. See [PDF coverage](PDF-COVERAGE.md).
- OOXML: bounded extraction of text and embedded images; full rendered visual coverage is incomplete and outbound DLP fails closed.
- Images: bundled RapidOCR 3.8.1 with CPU ONNXRuntime 1.24.4 and packaged PP-OCRv4 detection/recognition plus v2 classification weights. Explicit verified local model paths bypass all model downloads. Real Windows test renders a secret into an image and blocks it with network model downloading forbidden. Models primarily support Chinese/English; other languages and recognition accuracy require validation. OCR completion indicates all frames processed, not guaranteed recognition accuracy. Pillow parsing has dimension/frame limits. Optional `SAFETYNET_OCR_ENGINE=tesseract` uses administrator-installed trusted `TESSERACT_PATH`, optionally `TESSDATA_PREFIX` for local traineddata. Tesseract weights are not bundled. Missing OCR blocks DLP.
- Audio/video: recognition of container types only. Transcription and full video inspection are unconfigured; DLP blocks rather than claiming safe coverage.
- Semantic adapter: real Google EmbeddingGemma 2, Q8_0 GGUF export revision `bfcd298762cc34d0357ece5ebdd31791a3a374d8`, 768 dimensions. A verified native worker runs explicitly on CPU, reads bounded local NDJSON, and returns actual normalized vectors. Both GGUF weights and native runtime modules must pass size/SHA-256 checks before loading. Configure `EMBEDDING_MODEL_DIR` and `EMBEDDING_WORKER_PATH` to staged, protected local resources. No cloud inference, model download or Python ML framework is used. Text uses the same pinned `document-v1` profile for training and inspection; native token windows must cover exact UTF-8 bytes through the final byte, otherwise scanning fails closed. At most 64 vectors are allowed; incomplete or oversized training is refused. Native image embeddings process all bounded frames resized to at most672px with48px alignment. Native audio supports only mono16kHz PCM16 WAV up to30seconds; other media remains uninspectable. Actual model fixtures must run separately from protocol unit tests to establish operational readiness.
+17
View File
@@ -0,0 +1,17 @@
# Local static PDF coverage
The inspector uses hash-pinned `pypdfium2 5.14.0` with official Windows x64 PDFium `156.0.8076.0`. The wheel has no V8 or XFA feature flags. No JavaScript, forms, external URLs or network rendering are enabled. This does not modify Chromium or Electron.
For the supported subset, inspection combines decoded page text, every indexed object's strings and metadata, decoded content operands, independent OCR of original page image resources, and rendering/OCR of every page. Rendered pages and original images are also supplied to local semantic analysis when policy requires it. Rendering alone is never treated as coverage of hidden bytes.
Limits include 32 pages, 10,000 objects/operations, depth 40, 20 million pixels per rendered page, 50 million rendered pixels total, 20 MiB decoded streams/media, 64 media items, one million extracted characters and a 20-second cooperative PDF deadline. The existing isolated worker enforces an external deadline and remains in the inspector's 4 GiB Windows Job Object. Decoder output, declared lengths, filter chains and decoding work are bounded before decompression via the installed pypdf configuration API. The external process deadline remains necessary for native rendering/OCR calls.
The supported font subset is the 14 standard PDF Type 1 fonts with standard, WinAnsi or MacRoman encoding. Embedded fonts, custom encoding/CMaps, active actions, annotations, forms, optional layers, attachments, incremental revisions, hidden/orphan streams and unsupported resources fail closed. This is **not full compatibility with common PDF exports**, which often embed fonts or use XMP metadata. Broader support needs independent coverage tests rather than changing the completeness flag.
`coverage.complete` means every component in this explicit processing subset was inspected; OCR and classification remain probabilistic and can miss visually ambiguous information. It does not certify that a document is harmless.
Actual offline tests cover benign PDF release, hex-encoded text secrets, metadata and unreferenced strings, vector-only rendered lettering without extractable text, embedded raster secrets, active/encrypted/hidden content rejection and a compressed expansion bomb rejected before OCR. These tests use the real renderer and local OCR, apart from the explicit missing-OCR failure test.
PDF timing stages are fixed `pdf.structure`, `pdf.images`, `pdf.render` and `pdf.ocr`, under the existing private `parse`/`inspect` stages. They contain timings and correlation, never document text.
Sources: [pypdfium2 release and licensing](https://pypi.org/project/pypdfium2/5.14.0/), [official PDFium API and process-safety guidance](https://pypdfium2.readthedocs.io/en/stable/python_api.html). The runtime retains the wheel's BSD/Apache and PDFium dependency license notices in its distribution metadata.
+5 -44
View File
@@ -200,51 +200,12 @@ def extract(data, kind):
text,complete=ocr_image(data)
return text,complete,[("image",data)]
if kind=="pdf":
from pypdf import PdfReader
reader=PdfReader(io.BytesIO(data),strict=True)
if reader.is_encrypted: raise ValueError("Encrypted PDF cannot be inspected")
if len(reader.pages)>100: raise ValueError("PDF page budget exceeded")
text=[];complete=True;media=[]
for page in reader.pages:
value=page.extract_text() or ""
text.append(value)
images=list(page.images)
if not value.strip() and not images: complete=False
for image in images:
if len(image.data)>MAX_BYTES: raise ValueError("PDF image budget exceeded")
scanned,ok=ocr_image(image.data);text.append(scanned);complete=complete and ok
media.append(("image",image.data))
if len(media)>100: raise ValueError("PDF media budget exceeded")
# Attachments, JavaScript, forms, actions and vector drawings need deeper inspection.
if page.get("/Annots"): complete=False
root=reader.trailer["/Root"]
if root.get("/AcroForm") or root.get("/OpenAction") or root.get("/Names"): complete=False
# Text extraction is useful, but does not prove all visible/vector PDF content was covered.
return "\n".join(text),False,media
from pdf_documents import extract_pdf
return extract_pdf(data, ocr_image)
if kind=="office":
from defusedxml.ElementTree import fromstring
text=[];media=[];complete=True
with zipfile.ZipFile(io.BytesIO(data)) as archive:
members=archive.infolist()
if len(members)>2000 or sum(x.file_size for x in members)>MAX_BYTES:
raise ValueError("Archive expansion budget exceeded")
if not any(x.filename in ["word/document.xml","ppt/presentation.xml","xl/workbook.xml"] for x in members):
raise ValueError("Only OOXML document archives are supported")
for entry in members:
if entry.flag_bits&1 or entry.file_size>MAX_BYTES or entry.file_size>max(entry.compress_size,1)*200:
raise ValueError("Unsafe document archive")
name=entry.filename
if any(s in name.lower() for s in ["vbaproject","embeddings/","activex/","customxml/"]): complete=False
if name.endswith(".xml"):
root=fromstring(archive.read(entry))
text.extend(element.text for element in root.iter() if element.tag.rsplit("}",1)[-1] in ["t","v"] and element.text)
elif "/media/" in name:
payload=archive.read(entry)
if content_kind(payload)!="image": complete=False; continue
scanned,ok=ocr_image(payload);text.append(scanned);complete=complete and ok;media.append(("image",payload))
if len(media)>100: raise ValueError("Office media budget exceeded")
# Charts, relationships, equations and rendered layout are not yet fully inspected.
return "\n".join(text),False,media
from office_content import extract_office
result=extract_office(data,ocr_image,max_bytes=MAX_BYTES)
return result["text"],result["complete"],result["media"]
if kind in ["audio","video"]: return "",False,[(kind,data)]
raise ValueError("Unsupported content type")
+216
View File
@@ -0,0 +1,216 @@
"""Bounded static OOXML content coverage, not an Office renderer or macro engine.
OPC and part families: Microsoft Open XML SDK structure documentation:
https://learn.microsoft.com/en-us/office/open-xml/general/how-to-create-a-package
https://learn.microsoft.com/en-us/office/open-xml/spreadsheet/structure-of-a-spreadsheetml-document
https://learn.microsoft.com/en-us/office/open-xml/presentation/structure-of-a-presentationml-document
All XML text, tails, attributes, namespace declarations and ZIP metadata are inputs.
Only known static XML parts and successfully OCR-inspected raster assets are covered.
Unknown parts, equations, vector/custom layouts and active features remain incomplete.
"""
import io
import posixpath
import re
import struct
import unicodedata
import zipfile
from urllib.parse import urlsplit
from defusedxml.ElementTree import fromstring,iterparse
from stage_timings import stage
CT='http://schemas.openxmlformats.org/package/2006/content-types'
REL='http://schemas.openxmlformats.org/package/2006/relationships'
R='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
S='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
P='http://schemas.openxmlformats.org/presentationml/2006/main'
A='http://schemas.openxmlformats.org/drawingml/2006/main'
CP='http://schemas.openxmlformats.org/package/2006/metadata/core-properties'
EP='http://schemas.openxmlformats.org/officeDocument/2006/extended-properties'
CUSTOM='http://schemas.openxmlformats.org/officeDocument/2006/custom-properties'
TYPE_PREFIX='application/vnd.openxmlformats-officedocument.'
MAIN={TYPE_PREFIX+'wordprocessingml.document.main+xml':('docx',W,'document'),TYPE_PREFIX+'spreadsheetml.sheet.main+xml':('xlsx',S,'workbook'),TYPE_PREFIX+'presentationml.presentation.main+xml':('pptx',P,'presentation')}
XML_TYPES={TYPE_PREFIX+'wordprocessingml.'+name+'+xml':(W,root) for name,root in [('styles','styles'),('settings','settings'),('fontTable','fonts'),('numbering','numbering'),('header','hdr'),('footer','ftr'),('comments','comments'),('footnotes','footnotes'),('endnotes','endnotes'),('webSettings','webSettings')]}
XML_TYPES.update({TYPE_PREFIX+'spreadsheetml.'+name+'+xml':(S,root) for name,root in [('worksheet','worksheet'),('sharedStrings','sst'),('styles','styleSheet'),('table','table')]})
XML_TYPES.update({TYPE_PREFIX+'presentationml.'+name+'+xml':(P,root) for name,root in [('slide','sld'),('notesSlide','notes'),('notesMaster','notesMaster'),('slideMaster','sldMaster'),('slideLayout','sldLayout'),('presProps','presentationPr'),('viewProps','viewPr'),('tableStyles','tblStyleLst')]})
XML_TYPES['application/vnd.ms-word.stylesWithEffects+xml']=(W,'styles')
XML_TYPES[TYPE_PREFIX+'customXmlProperties+xml']=('http://schemas.openxmlformats.org/officeDocument/2006/customXml','datastoreItem')
XML_TYPES[TYPE_PREFIX+'presentationml.tableStyles+xml']=(A,'tblStyleLst')
XML_TYPES.update({'application/vnd.openxmlformats-package.core-properties+xml':(CP,'coreProperties'),TYPE_PREFIX+'extended-properties+xml':(EP,'Properties'),TYPE_PREFIX+'custom-properties+xml':(CUSTOM,'Properties'),TYPE_PREFIX+'theme+xml':(A,'theme')})
SAFE_REL={R+'/'+name for name in ['officeDocument','extended-properties','custom-properties','styles','settings','fontTable','numbering','header','footer','comments','footnotes','endnotes','worksheet','sharedStrings','table','theme','slide','notesSlide','notesMaster','slideMaster','slideLayout','presProps','viewProps','tableStyles','image']}
SAFE_REL.update({R+'/customXml',R+'/customXmlProps',R+'/webSettings','http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects','http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties','http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail'})
SAFE_NS={CT,REL,R,W,S,P,A,CP,EP,CUSTOM,'http://schemas.openxmlformats.org/drawingml/2006/picture','http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing','http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes','http://purl.org/dc/elements/1.1/','http://purl.org/dc/terms/','http://purl.org/dc/dcmitype/','http://www.w3.org/2001/XMLSchema-instance','http://www.w3.org/XML/1998/namespace','http://schemas.openxmlformats.org/markup-compatibility/2006','http://schemas.openxmlformats.org/officeDocument/2006/bibliography','http://schemas.openxmlformats.org/officeDocument/2006/customXml','http://schemas.openxmlformats.org/officeDocument/2006/math'}
SAFE_EXTENSION_TAGS={'{urn:schemas-microsoft-com:office:office}'+name for name in ['shapedefaults','shapelayout','idmap']}|{'{http://schemas.microsoft.com/office/word/2010/wordml}'+name for name in ['docId','defaultImageDpi']}
UNSUPPORTED_TAGS={'AlternateContent','oMath','oMathPara','custGeom','pict','object','control','oleObj','graphicFrame','chart','grpSp','cxnSp','fldSimple','fldChar','instrText','altChunk','externalLink','externalReference','externalReferences','blob','hlinkClick','hlinkHover','animation','transition','timing','embedRegular','embedBold','embedItalic','embedBoldItalic'}
class OfficeCoverageError(ValueError):pass
def _part(name):
if not isinstance(name,str) or not name or len(name)>240 or '\\' in name or '%' in name or name.startswith('/') or any(part in {'','.', '..'} for part in name.split('/')) or any(ord(c)<32 for c in name) or unicodedata.normalize('NFC',name)!=name:raise OfficeCoverageError('office-part-name-invalid')
return name
def _physical_zip(data,archive,infos):
if not data.startswith(b'PK\x03\x04'):raise OfficeCoverageError('office-archive-prefix-invalid')
cursor=0
for info in sorted(infos,key=lambda item:item.header_offset):
if info.header_offset!=cursor or cursor+30>len(data):raise OfficeCoverageError('office-archive-gap')
signature,version,flags,method,time,date,crc,compressed,size,name_length,extra_length=struct.unpack_from('<IHHHHHIIIHH',data,cursor)
if signature!=0x04034b50 or flags!=info.flag_bits or method!=info.compress_type:raise OfficeCoverageError('office-local-header-invalid')
if extra_length:raise OfficeCoverageError('office-local-extra-uncovered')
cursor+=30+name_length+extra_length+info.compress_size
if flags&8:
if data[cursor:cursor+4]==b'PK\x07\x08':cursor+=4
if cursor+12>len(data) or struct.unpack_from('<III',data,cursor)!=(info.CRC,info.compress_size,info.file_size):raise OfficeCoverageError('office-descriptor-invalid')
cursor+=12
elif (crc,compressed,size)!=(info.CRC,info.compress_size,info.file_size):raise OfficeCoverageError('office-local-size-invalid')
if cursor!=archive.start_dir:raise OfficeCoverageError('office-archive-gap')
for _info in infos:
if data[cursor:cursor+4]!=b'PK\x01\x02' or cursor+46>len(data):raise OfficeCoverageError('office-central-header-invalid')
name_length,extra_length,comment_length=struct.unpack_from('<HHH',data,cursor+28);cursor+=46+name_length+extra_length+comment_length
if data[cursor:cursor+4]!=b'PK\x05\x06' or cursor+22>len(data):raise OfficeCoverageError('office-end-record-invalid')
disk,start_disk,count_disk,count,total_size,start,comment=struct.unpack_from('<HHHHIIH',data,cursor+4)
if disk or start_disk or count_disk!=len(infos) or count!=len(infos) or start!=archive.start_dir or total_size!=cursor-archive.start_dir or cursor+22+comment!=len(data):raise OfficeCoverageError('office-end-record-invalid')
def extract_office(data,ocr_image,*,max_bytes=20*1024*1024,max_parts=2000,max_media=100,max_text=2*1024*1024):
if not isinstance(data,bytes) or len(data)>max_bytes or not callable(ocr_image):raise OfficeCoverageError('office-input-invalid')
texts=[];seen_text=set();media=[];reasons=set();xml={};images={};types={};members={};payloads={};total=0;nodes=0
def append(value):
nonlocal total
if value:
value=str(value)
if value in seen_text:return
seen_text.add(value);total+=len(value)
if total>max_text:raise OfficeCoverageError('office-text-limit')
texts.append(value)
with stage('office.archive'):
with zipfile.ZipFile(io.BytesIO(data)) as archive:
infos=archive.infolist()
_physical_zip(data,archive,infos)
if len(infos)>max_parts or sum(item.file_size for item in infos)>max_bytes:raise OfficeCoverageError('office-expansion-limit')
append(archive.comment.decode('utf-8','strict'))
for info in infos:
name=_part(info.filename)
if name.casefold() in members:raise OfficeCoverageError('office-duplicate-part')
members[name.casefold()]=(name,info)
if info.is_dir() or info.flag_bits&1 or info.compress_type not in {zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED} or info.file_size>max_bytes or info.file_size>max(1,info.compress_size)*200 or (info.external_attr>>16)&0o170000==0o120000:raise OfficeCoverageError('office-archive-unsafe')
# Unknown local/central extra fields may hide material outside parts.
if info.extra:reasons.add('zip-extra-field-uncovered')
append(name);append(info.comment.decode('utf-8','strict'))
payload=archive.read(info)
if len(payload)!=info.file_size:raise OfficeCoverageError('office-part-size-invalid')
payloads[name]=payload
with stage('office.xml'):
for name,payload in payloads.items():
if name.endswith(('.xml','.rels')) or name=='[Content_Types].xml':
root=fromstring(payload,forbid_dtd=True,forbid_entities=True,forbid_external=True);xml[name]=root
for _event,namespace in iterparse(io.BytesIO(payload),events=['start-ns'],forbid_dtd=True,forbid_entities=True,forbid_external=True):append(namespace[0]);append(namespace[1])
for event,item in iterparse(io.BytesIO(payload),events=['comment','pi'],forbid_dtd=True,forbid_entities=True,forbid_external=True):
append(item.text)
if event=='pi':reasons.add('xml-processing-instruction-unsupported')
parents={child:parent for parent in root.iter() for child in parent}
append(''.join(value for value in root.itertext() if value.strip()))
for element in root.iter():
nodes+=1
if nodes>100000:raise OfficeCoverageError('office-xml-node-limit')
append(element.tag);append(element.text);append(element.tail)
for key,value in element.attrib.items():
append(key);append(value)
if key.startswith('{') and key[1:].split('}',1)[0] not in SAFE_NS and key not in {'{http://schemas.microsoft.com/office/word/2010/wordml}paraId','{http://schemas.microsoft.com/office/word/2010/wordml}textId'} and not (element.tag in SAFE_EXTENSION_TAGS and ((key=='{urn:schemas-microsoft-com:vml}ext' and value=='edit') or (key=='{http://schemas.microsoft.com/office/word/2010/wordml}val' and value.isdecimal()))):reasons.add('xml-namespace-unsupported')
namespace=element.tag[1:].split('}',1)[0] if element.tag.startswith('{') else ''
if namespace not in SAFE_NS and element.tag not in SAFE_EXTENSION_TAGS:reasons.add('xml-namespace-unsupported')
if element.tag.rsplit('}',1)[-1] in UNSUPPORTED_TAGS:reasons.add('xml-feature-unsupported')
if element.tag=='{'+A+'}prstGeom' and element.get('prst') not in {'rect','roundRect'}:reasons.add('vector-preset-unsupported')
if element.tag=='{'+S+'}f':
formula=element.text or '';cached=parents.get(element)
cached=cached.find('{'+S+'}v') if cached is not None else None
without_strings=re.sub(r'"(?:[^"]|"")*"','',formula)
functions={name.upper() for name in re.findall(r'([A-Za-z_][A-Za-z0-9_.]*)\s*\(',without_strings)}
safe_functions={'SUM','SUMIF','SUMIFS','COUNT','COUNTIF','COUNTIFS','COUNTA','AVERAGE','AVERAGEIF','AVERAGEIFS','MIN','MAX','ROUND','ROUNDDOWN','ROUNDUP','ABS','IF','AND','OR','NOT'}
if not formula or set(element.attrib)-{'ca'} or any(c in without_strings for c in '[]|') or not functions<=safe_functions or cached is None or not (cached.text or '').strip():reasons.add('formula-coverage-incomplete')
elif payload.startswith((b'\x89PNG\r\n\x1a\n',b'\xff\xd8\xff',b'GIF87a',b'GIF89a')):images[name]=payload
else:reasons.add('binary-part-unsupported')
with stage('office.relationships'):
content=xml.get('[Content_Types].xml')
if content is None or content.tag!='{'+CT+'}Types':raise OfficeCoverageError('office-content-types-invalid')
defaults={};overrides={}
for item in content:
if item.tag=='{'+CT+'}Default' and set(item.attrib)=={'Extension','ContentType'}:
ext=item.attrib['Extension'].lower()
if not re.fullmatch('[a-z0-9]+',ext) or ext in defaults:raise OfficeCoverageError('office-content-types-invalid')
defaults[ext]=item.attrib['ContentType']
elif item.tag=='{'+CT+'}Override' and set(item.attrib)=={'PartName','ContentType'}:
part=item.attrib['PartName']
if not part.startswith('/'):raise OfficeCoverageError('office-content-types-invalid')
part=_part(part[1:])
if part.casefold() in {p.casefold() for p in overrides}:raise OfficeCoverageError('office-content-types-invalid')
overrides[part]=item.attrib['ContentType']
else:raise OfficeCoverageError('office-content-types-invalid')
if any(name.casefold() not in members for name in overrides):raise OfficeCoverageError('office-content-types-dangling')
main=[]
for name,_info in members.values():
if name=='[Content_Types].xml':continue
type_=overrides.get(name,defaults.get(name.rsplit('.',1)[-1].lower()))
if not type_:raise OfficeCoverageError('office-content-type-missing')
types[name]=type_
if type_ in MAIN:
family,namespace,rootname=MAIN[type_];main.append((name,family))
if name not in xml or xml[name].tag!='{'+namespace+'}'+rootname:raise OfficeCoverageError('office-main-part-invalid')
elif name.endswith('.rels'):
if type_!='application/vnd.openxmlformats-package.relationships+xml':raise OfficeCoverageError('office-relationship-type-invalid')
elif type_=='application/xml' and name in xml and xml[name].tag=='{http://schemas.openxmlformats.org/officeDocument/2006/bibliography}Sources':pass
elif type_ in XML_TYPES:
namespace,rootname=XML_TYPES[type_]
if name not in xml or xml[name].tag!='{'+namespace+'}'+rootname:raise OfficeCoverageError('office-xml-part-invalid')
elif name in images:
expected='image/png' if images[name].startswith(b'\x89PNG') else 'image/jpeg' if images[name].startswith(b'\xff\xd8') else 'image/gif'
if type_!=expected:raise OfficeCoverageError('office-image-type-invalid')
else:reasons.add('content-type-unsupported')
if len(main)!=1:raise OfficeCoverageError('office-main-count-invalid')
relationships={};top_main=[]
for name,root in xml.items():
if not name.endswith('.rels'):continue
if root.tag!='{'+REL+'}Relationships':raise OfficeCoverageError('office-relationships-invalid')
if name=='_rels/.rels':owner=''
elif '/_rels/' in name:
prefix,file=name.rsplit('/_rels/',1);owner=prefix+'/'+file[:-5]
if owner.casefold() not in members:raise OfficeCoverageError('office-relationships-owner-invalid')
else:raise OfficeCoverageError('office-relationships-location-invalid')
ids=set()
for item in root:
if item.tag!='{'+REL+'}Relationship' or not {'Id','Type','Target'}<=set(item.attrib) or set(item.attrib)-{'Id','Type','Target','TargetMode'}:raise OfficeCoverageError('office-relationship-invalid')
id_=item.attrib['Id'];target=item.attrib['Target'];type_=item.attrib['Type']
if not id_ or id_ in ids:raise OfficeCoverageError('office-relationship-id-invalid')
ids.add(id_)
if item.attrib.get('TargetMode','Internal')!='Internal':reasons.add('external-relationship-unsupported');continue
if urlsplit(target).scheme or '%' in target or '\\' in target or '#' in target or '?' in target:raise OfficeCoverageError('office-relationship-target-invalid')
resolved=posixpath.normpath(posixpath.join(posixpath.dirname(owner),target)) if not target.startswith('/') else target[1:]
_part(resolved)
if resolved.casefold() not in members:raise OfficeCoverageError('office-relationship-target-missing')
if type_ not in SAFE_REL:reasons.add('relationship-type-unsupported')
else:
target_type=types.get(resolved)
role=type_.rsplit('/',1)[-1]
if role=='officeDocument':valid=target_type in MAIN
elif role in {'image','thumbnail'}:valid=resolved in images
elif role=='customXml':valid=target_type=='application/xml' and resolved in xml and xml[resolved].tag=='{http://schemas.openxmlformats.org/officeDocument/2006/bibliography}Sources'
elif role=='customXmlProps':valid=target_type==TYPE_PREFIX+'customXmlProperties+xml'
elif role=='stylesWithEffects':valid=target_type=='application/vnd.ms-word.stylesWithEffects+xml'
elif role=='core-properties':valid=target_type=='application/vnd.openxmlformats-package.core-properties+xml'
elif role in {'extended-properties','custom-properties'}:valid=target_type==TYPE_PREFIX+role+'+xml'
else:valid=target_type in XML_TYPES and (target_type==TYPE_PREFIX+'theme+xml' if role=='theme' else target_type.endswith('.'+role+'+xml'))
if not valid:raise OfficeCoverageError('office-relationship-type-target-mismatch')
if type_==R+'/officeDocument' and not owner:top_main.append(resolved)
relationships[owner]=ids
if top_main!=[main[0][0]]:raise OfficeCoverageError('office-root-relationship-invalid')
for name,root in xml.items():
if name.endswith('.rels') or name=='[Content_Types].xml':continue
for element in root.iter():
for key,value in element.attrib.items():
if key.startswith('{'+R+'}') and value not in relationships.get(name,set()):raise OfficeCoverageError('office-dangling-relationship-reference')
with stage('office.ocr'):
for name,payload in images.items():
if len(media)>=max_media:raise OfficeCoverageError('office-media-limit')
scanned,complete=ocr_image(payload)
if not isinstance(scanned,str) or complete is not True:reasons.add('image-ocr-incomplete')
else:append(scanned)
media.append(('image',payload))
return {'text':'\n'.join(texts),'complete':not reasons,'media':media,'family':main[0][1],'reasons':sorted(reasons),'parts':len(members)}
+186
View File
@@ -0,0 +1,186 @@
"""Bounded static PDF inspection: decoded content plus every-page rendering/OCR.
Active, layered, annotated, embedded-file and incremental documents are outside
the supported subset and fail closed. This runs inside the existing isolated
parser process; PDFium has no JavaScript/XFA support and forms are never enabled.
"""
import io
import math
import time
from stage_timings import stage
MAX_PAGES = 32
MAX_OBJECTS = 10000
MAX_PIXELS = 50_000_000
MAX_PAGE_PIXELS = 20_000_000
MAX_BYTES = 20 * 1024 * 1024
MAX_TEXT = 1_000_000
FORBIDDEN = frozenset(("/Annots", "/AcroForm", "/XFA", "/OpenAction", "/AA", "/A", "/JS", "/JavaScript", "/Names", "/EmbeddedFiles", "/EF", "/AF", "/OCProperties", "/Collection", "/RichMedia", "/Movie", "/Sound", "/3D", "/Rendition", "/Launch", "/URI", "/GoToR", "/GoToE", "/SubmitForm", "/ImportData", "/Encrypt", "/PieceInfo"))
STANDARD_FONTS = frozenset(("/Helvetica", "/Helvetica-Bold", "/Helvetica-Oblique", "/Helvetica-BoldOblique", "/Times-Roman", "/Times-Bold", "/Times-Italic", "/Times-BoldItalic", "/Courier", "/Courier-Bold", "/Courier-Oblique", "/Courier-BoldOblique", "/Symbol", "/ZapfDingbats"))
def extract_pdf(data, ocr_image):
from pypdf import apply_configuration
# Enforce decompressor limits BEFORE any stream get_data()/image allocation.
with apply_configuration(maximum_declared_stream_length=MAX_BYTES,
array_based_stream_maximum_output_length=MAX_BYTES,
zlib_maximum_output_length=MAX_BYTES,
lzw_maximum_output_length=MAX_BYTES,
run_length_maximum_output_length=MAX_BYTES,
brotli_maximum_output_length=MAX_BYTES,
jbig2_maximum_output_length=MAX_BYTES,
image_maximum_buffer_size=MAX_BYTES,
stream_filters_maximum_length=4,
stream_decoding_work_maximum_length=MAX_BYTES,
zlib_maximum_recovery_input_length=1048576):
return _extract_pdf(data, ocr_image)
def _extract_pdf(data, ocr_image):
from pypdf import PdfReader
from pypdf.generic import IndirectObject, DictionaryObject, ArrayObject, StreamObject, TextStringObject, ByteStringObject, NameObject, ContentStream
import pypdfium2 as pdfium
if len(data) > MAX_BYTES or data.count(b"%%EOF") != 1 or data.rsplit(b"%%EOF", 1)[-1].strip():
raise ValueError("Unsupported PDF container")
if pdfium.PDFIUM_INFO.flags or pdfium.PYPDFIUM_INFO.api_tag != (5, 14, 0):
raise ValueError("Unverified PDF renderer")
deadline = time.monotonic() + 20
def budget():
if time.monotonic() >= deadline:
raise ValueError("PDF processing deadline")
text, media, seen, indirect = [], [], {}, set()
extracted_bytes, characters = 0, 0
allowed_streams = set()
def append(value):
nonlocal characters
characters += len(value)
text.append(value)
if characters > MAX_TEXT:
raise ValueError("PDF text budget exceeded")
def walk(value, depth=0):
nonlocal extracted_bytes
budget()
if depth > 40 or len(seen) + len(indirect) > MAX_OBJECTS:
raise ValueError("PDF object budget exceeded")
if isinstance(value, IndirectObject):
identity = (value.idnum, value.generation)
if identity in indirect: return
indirect.add(identity)
walk(value.get_object(), depth + 1)
return
if isinstance(value, (DictionaryObject, ArrayObject)):
if id(value) in seen: return
# Retain objects as well as IDs: parsed operand containers could
# otherwise be collected and their IDs reused across streams.
seen[id(value)] = value
if isinstance(value, DictionaryObject):
if any(str(key) in FORBIDDEN for key in value) or str(value.get("/Type", "")) in ("/Filespec", "/Action", "/ObjStm", "/EmbeddedFile", "/XRef"):
raise ValueError("Unsupported active PDF content")
if value.get("/Type") == "/Font" and (value.get("/Subtype") != "/Type1" or value.get("/BaseFont") not in STANDARD_FONTS or "/FontDescriptor" in value or "/ToUnicode" in value or value.get("/Encoding") not in (None, "/WinAnsiEncoding", "/MacRomanEncoding", "/StandardEncoding")):
raise ValueError("Unsupported PDF font encoding")
if isinstance(value, StreamObject):
if id(value) not in allowed_streams:
raise ValueError("Unsupported hidden PDF stream")
decoded = value.get_data()
extracted_bytes += len(decoded)
if extracted_bytes > MAX_BYTES:
raise ValueError("PDF expansion budget exceeded")
try: append(decoded.decode("utf-8", errors="strict"))
except UnicodeError: pass
if value.get("/Subtype") != "/Image":
operations = ContentStream(value, reader).operations
if len(operations) > MAX_OBJECTS: raise ValueError("PDF operation budget exceeded")
for operands, _operator in operations:
for operand in operands: walk(operand, depth + 1)
for key, item in value.items():
append(str(key))
walk(item, depth + 1)
elif isinstance(value, ArrayObject):
for item in value: walk(item, depth + 1)
elif isinstance(value, (TextStringObject, NameObject)): append(str(value))
elif isinstance(value, ByteStringObject):
try: append(bytes(value).decode("utf-8", errors="strict"))
except UnicodeError: raise ValueError("Unsupported PDF string encoding")
with stage("pdf.structure"):
reader = PdfReader(io.BytesIO(data), strict=True)
if reader.is_encrypted or reader.trailer.get("/Prev") or not 0 < len(reader.pages) <= MAX_PAGES:
raise ValueError("Unsupported PDF document")
# Only page content and page-reachable image resources may carry streams.
# Fonts/alternate encodings, hidden orphan streams and XMP streams remain
# outside this deliberately explicit static PDF subset.
def allow_resource(value, depth=0, visited=None):
if visited is None: visited = set()
budget()
if depth > 40 or len(visited) > MAX_OBJECTS: raise ValueError("PDF resource budget exceeded")
if isinstance(value, IndirectObject): value = value.get_object()
if isinstance(value, (DictionaryObject, ArrayObject)):
if id(value) in visited: return
visited.add(id(value))
if isinstance(value, StreamObject):
if value.get("/Subtype") not in ("/Image", "/Form"):
raise ValueError("Unsupported PDF resource stream")
allowed_streams.add(id(value))
if isinstance(value, DictionaryObject):
for item in value.values(): allow_resource(item, depth + 1, visited)
elif isinstance(value, ArrayObject):
for item in value: allow_resource(item, depth + 1, visited)
for page in reader.pages:
contents = page.get("/Contents")
if isinstance(contents, IndirectObject): contents = contents.get_object()
if isinstance(contents, ArrayObject):
for item in contents:
item = item.get_object()
if not isinstance(item, StreamObject): raise ValueError("Unsupported PDF page content")
allowed_streams.add(id(item))
elif isinstance(contents, StreamObject): allowed_streams.add(id(contents))
elif contents is not None: raise ValueError("Unsupported PDF page content")
if page.get("/Resources") is not None: allow_resource(page["/Resources"])
walk(reader.trailer)
# Inspect every indexed object, including unreferenced metadata/actions.
for generation, entries in reader.xref.items():
for number in entries:
if number: walk(IndirectObject(number, generation, reader))
for number in reader.xref_objStm:
walk(IndirectObject(number, 0, reader))
media_bytes, pixels = 0, 0
with pdfium.PdfDocument(data) as document:
if len(document) != len(reader.pages): raise ValueError("PDF parser disagreement")
for index, source in enumerate(reader.pages):
budget()
append(source.extract_text() or "")
# Hidden/unpainted image resources also require independent OCR.
with stage("pdf.images"):
for image in source.images:
if len(image.data) > MAX_BYTES: raise ValueError("PDF image budget exceeded")
media_bytes += len(image.data)
if media_bytes > MAX_BYTES or len(media) >= 64: raise ValueError("PDF media budget exceeded")
value, complete = ocr_image(image.data)
if not complete: raise ValueError("Incomplete PDF image OCR")
append(value)
media.append(("image", image.data))
page = document[index]
try:
width, height = page.get_size()
if not all(math.isfinite(value) and value > 0 for value in (width, height)):
raise ValueError("Invalid PDF page dimensions")
count = math.ceil(width * 2) * math.ceil(height * 2)
pixels += count
if count > MAX_PAGE_PIXELS or pixels > MAX_PIXELS:
raise ValueError("PDF rendering budget exceeded")
with stage("pdf.render"):
bitmap = page.render(scale=2, draw_annots=False)
try:
rendered = io.BytesIO()
bitmap.to_pil().save(rendered, format="PNG")
payload = rendered.getvalue()
finally: bitmap.close()
media_bytes += len(payload)
if media_bytes > MAX_BYTES or len(media) >= 64: raise ValueError("PDF rendered media budget exceeded")
with stage("pdf.ocr"):
value, complete = ocr_image(payload)
if not complete: raise ValueError("Incomplete PDF rendered OCR")
append(value)
media.append(("image", payload))
finally: page.close()
budget()
return "\n".join(text), True, media
+2
View File
@@ -0,0 +1,2 @@
# Official PyPI Windows x64 wheel, includes PDFium and its license notices.
pypdfium2==5.14.0 --hash=sha256:149fd5c6397b8df8bf7911a93506eff0be874f877afe7ac936cf5d37d21a6a06
+1
View File
@@ -1,4 +1,5 @@
pypdf>=6,<8
pypdfium2==5.14.0
Pillow>=12,<14
defusedxml>=0.7,<1
yara-python==4.5.4
+1 -1
View File
@@ -5,7 +5,7 @@ import math
import time
from contextlib import contextmanager
STAGES = frozenset(('inspect', 'parse', 'ocr', 'ocr.load', 'yara', 'semantic', 'semantic.load', 'macros'))
STAGES = frozenset(('inspect', 'parse', 'ocr', 'ocr.load', 'yara', 'semantic', 'semantic.load', 'macros', 'pdf.structure', 'pdf.images', 'pdf.render', 'pdf.ocr', 'office.archive', 'office.xml', 'office.relationships', 'office.ocr'))
_current = contextvars.ContextVar('inspection_timings', default=None)
def enabled(policy):
+4 -1
View File
@@ -14,9 +14,12 @@ class InspectionTests(unittest.TestCase):
def test_office_extraction(self):
output=io.BytesIO()
with zipfile.ZipFile(output,"w") as archive:
archive.writestr("word/document.xml",'<document xmlns="urn:test"><t>Secret Roadmap</t></document>')
archive.writestr("[Content_Types].xml",'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
archive.writestr("_rels/.rels",'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
archive.writestr("word/document.xml",'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Secret Roadmap</w:t></w:r></w:p></w:body></w:document>')
result=inspect_content(output.getvalue(),policy())
self.assertEqual(result["contentType"],"office");self.assertEqual(result["ruleId"],"secret")
self.assertTrue(result["coverage"]["complete"])
def test_pdf_extraction(self):
from pypdf import PdfWriter
from pypdf.generic import DecodedStreamObject,NameObject,DictionaryObject
+117
View File
@@ -0,0 +1,117 @@
import io
import unittest
import zlib
from unittest.mock import patch
from pypdf import PdfWriter, PdfReader
from pypdf.generic import NameObject, DictionaryObject, DecodedStreamObject, EncodedStreamObject, TextStringObject
from engine import inspect_content
from pdf_documents import extract_pdf
def policy():
return {"dlp": {"maxBytes": 1048576, "rules": [{"id": "secret", "kind": "literal", "value": "Secret Roadmap"}], "semantic": {"enabled": False}}}
def pdf(value=b'BT /F1 24 Tf 20 150 Td (Public information) Tj ET', mutate=None):
writer = PdfWriter()
page = writer.add_blank_page(400, 220)
font = DictionaryObject({NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/Type1'), NameObject('/BaseFont'): NameObject('/Helvetica')})
page[NameObject('/Resources')] = DictionaryObject({NameObject('/Font'): DictionaryObject({NameObject('/F1'): writer._add_object(font)})})
stream = DecodedStreamObject(); stream.set_data(value)
page[NameObject('/Contents')] = writer._add_object(stream)
if mutate: mutate(writer, page)
output = io.BytesIO(); writer.write(output)
return output.getvalue()
class PdfTests(unittest.TestCase):
def test_public_render_and_ocr_all_pages(self):
result = inspect_content(pdf(), policy())
self.assertEqual(result['decision'], 'allow')
self.assertTrue(result['coverage']['complete'])
self.assertEqual(result['coverage']['mediaItems'], 1)
def test_encoded_secret_decoded_text(self):
data = pdf(b'BT /F1 24 Tf 20 150 Td <53656372657420526F61646D6170> Tj ET')
self.assertNotIn(b'Secret Roadmap', data)
self.assertEqual(inspect_content(data, policy())['ruleId'], 'secret')
def test_hidden_metadata_and_unreferenced_strings(self):
for mutation in [lambda w, _: w.add_metadata({'/Title': 'Secret Roadmap'}), lambda w, _: w._add_object(TextStringObject('Secret Roadmap'))]:
self.assertEqual(inspect_content(pdf(mutate=mutation), policy())['ruleId'], 'secret')
def test_render_only_vector_secret_has_no_extractable_text(self):
from PIL import Image, ImageDraw, ImageFont
mask = Image.new('L', (500, 80), 255)
ImageDraw.Draw(mask).text((10, 8), 'Secret Roadmap', font=ImageFont.load_default(size=42), fill=0)
operations = []
for y in range(mask.height):
x = 0
while x < mask.width:
if mask.getpixel((x, y)) < 128:
start = x
while x < mask.width and mask.getpixel((x, y)) < 128: x += 1
operations.append(f'{start + 15} {180-y} {x-start} 1 re f')
else: x += 1
data = pdf(('\n'.join(operations)).encode())
self.assertFalse(PdfReader(io.BytesIO(data)).pages[0].extract_text())
self.assertNotIn(b'Secret Roadmap', data)
self.assertEqual(inspect_content(data, policy())['ruleId'], 'secret')
def test_embedded_raster_secret(self):
from PIL import Image, ImageDraw, ImageFont
image = Image.new('RGB', (700, 200), 'white')
ImageDraw.Draw(image).text((20, 40), 'Secret Roadmap', font=ImageFont.load_default(size=48), fill='black')
output = io.BytesIO(); image.save(output, format='PDF')
self.assertEqual(inspect_content(output.getvalue(), policy())['ruleId'], 'secret')
def test_unsupported_active_encrypted_or_hidden_stream(self):
def action(writer, _): writer._root_object[NameObject('/OpenAction')] = DictionaryObject({NameObject('/S'): NameObject('/JavaScript')})
def attachment(writer, _): writer.add_attachment('private.txt', b'opaque')
def encryption(writer, _): writer.encrypt('private')
def hidden_stream(writer, _):
stream = DecodedStreamObject(); stream.set_data(b'opaque'); writer._add_object(stream)
for mutation in [action, attachment, encryption, hidden_stream]:
result = inspect_content(pdf(mutate=mutation), policy())
self.assertEqual(result['decision'], 'block')
self.assertFalse(result['coverage']['complete'])
def test_decoder_and_render_budgets_fail_before_ocr(self):
def bomb(writer, page):
stream = EncodedStreamObject(); stream._data = zlib.compress(b'x' * (21 * 1024 * 1024))
stream[NameObject('/Filter')] = NameObject('/FlateDecode')
reference = page.raw_get('/Contents')
stream.indirect_reference = reference
writer._objects[reference.idnum - 1] = stream
from pypdf.filters import FlateDecode
from pypdf import get_configuration
original_decode = FlateDecode.decode
decodes = []
def bounded_decode(*args, **kwargs):
decodes.append(get_configuration().zlib_maximum_output_length)
return original_decode(*args, **kwargs)
with patch('engine.ocr_image', side_effect=AssertionError('Must not OCR oversized input')):
with patch.object(FlateDecode, 'decode', side_effect=bounded_decode):
self.assertEqual(inspect_content(pdf(mutate=bomb), policy())['decision'], 'block')
self.assertEqual(decodes, [20 * 1024 * 1024])
self.assertEqual(inspect_content(pdf(mutate=lambda _w, page: setattr(page.mediabox, 'right', 1000000)), policy())['decision'], 'block')
def test_missing_ocr_never_claims_complete(self):
with self.assertRaises(ValueError): extract_pdf(pdf(), lambda _data: ('', False))
def test_debug_stages_record_actual_structure_render_and_ocr(self):
from stage_timings import collect, metadata, validated
p = policy(); p['diagnostics'] = {'enabled': True, 'mode': 'debug'}
with collect(p) as state:
result = inspect_content(pdf(), p)
self.assertEqual(result['decision'], 'allow')
envelope = validated(metadata(state))
self.assertIsNotNone(envelope)
stages = {record['stage'] for record in envelope['records']}
self.assertTrue({'pdf.structure', 'pdf.images', 'pdf.render', 'pdf.ocr'} <= stages)
for record in envelope['records']:
self.assertGreaterEqual(record['durationMs'], 0)
if record['stage'].startswith('pdf.'): self.assertIsNotNone(record['parentId'])
if __name__ == '__main__': unittest.main()
+1 -1
View File
@@ -2,7 +2,7 @@
function validateStages(envelope,now=Date.now()){try{
const own=(object,key)=>{const d=Object.getOwnPropertyDescriptor(object,key);return d&&'value'in d?d.value:undefined;};
if(!envelope||typeof envelope!=='object')return null;const base=own(envelope,'baseUnixMs'),records=own(envelope,'records');if(!Number.isSafeInteger(base)||base<now-60000||base>now+2000||!Array.isArray(records)||records.length>64)return null;
const allowed=new Set(['inspect','parse','ocr','yara','semantic','macros','ocr.load','semantic.load']),seen=new Map(),validated=[];
const allowed=new Set(['inspect','parse','ocr','yara','semantic','macros','ocr.load','semantic.load','pdf.structure','pdf.images','pdf.render','pdf.ocr','office.archive','office.xml','office.relationships','office.ocr']),seen=new Map(),validated=[];
const identifier=value=>Number.isSafeInteger(value)&&value>=0||typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value);
for(let i=0;i<records.length;i++){const record=own(records,String(i));if(!record||typeof record!=='object')return null;const id=own(record,'id'),parentId=own(record,'parentId'),stage=own(record,'stage'),start=own(record,'startOffsetMs'),duration=own(record,'durationMs'),status=own(record,'status');if(!identifier(id)||seen.has(id)||parentId!==null&&!seen.has(parentId)||!allowed.has(stage)||!Number.isFinite(start)||!Number.isFinite(duration)||start<0||duration<0||start+duration>30000||base+start+duration>now+2000||!['ok','error'].includes(status))return null;
const item={id,parentId,stage,start,duration,status};seen.set(id,item);validated.push(item);
+27 -3
View File
@@ -1,5 +1,6 @@
"""Build a pinned private Windows x64 inspector runtime; no machine-wide install."""
import hashlib
import argparse
import json
import shutil
import subprocess
@@ -9,6 +10,9 @@ import zipfile
from pathlib import Path
if sys.platform!='win32': raise SystemExit('Windows runtime provisioning runs on Windows only')
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--verify-existing',action='store_true',help='Verify exact installed locks/wheel before updating manifest; no dependency reinstall')
arguments=parser.parse_args()
VERSION='3.13.16'
CHECKSUM='97dae5274cc54867065e8d5a3226e48c35017ed332a0fdb0e27d5b5821961297'
root=Path.cwd().resolve()
@@ -16,11 +20,26 @@ directory=root/'.runtime'/'windows'/'python'
directory.mkdir(parents=True,exist_ok=True)
requirements=root/'apps'/'inspector'/'requirements.lock.txt'
macro_requirements=root/'apps'/'inspector'/'requirements-macros.lock.txt'
requirements_hash=hashlib.sha256(requirements.read_bytes()+macro_requirements.read_bytes()).hexdigest()
pdf_requirements=root/'apps'/'inspector'/'requirements-pdf.lock.txt'
requirements_hash=hashlib.sha256(requirements.read_bytes()+macro_requirements.read_bytes()+pdf_requirements.read_bytes()).hexdigest()
manifest=directory/'safetynet-runtime.json'
expected={'python':VERSION,'architecture':'amd64','sha256':CHECKSUM,'requirementsHash':requirements_hash}
previous=json.loads(manifest.read_text()) if manifest.exists() else {}
if previous!=expected:
pdf_wheel=directory.parent/'pypdfium2-5.14.0-py3-none-win_amd64.whl'
pdf_checksum='149fd5c6397b8df8bf7911a93506eff0be874f877afe7ac936cf5d37d21a6a06'
if not arguments.verify_existing and not pdf_wheel.exists():
# Retain the reviewed wheel for independent verification on fresh builds.
# Verification mode remains offline and never repairs a missing archive.
subprocess.run([sys.executable,'-m','pip','download','--dest',str(directory.parent),
'--platform','win_amd64','--python-version','3.13','--implementation','cp','--abi','cp313',
'--only-binary=:all:','--no-deps','--require-hashes','-r',str(pdf_requirements)],check=True)
if not pdf_wheel.is_file() or hashlib.sha256(pdf_wheel.read_bytes()).hexdigest()!=pdf_checksum:
raise SystemExit('Retained PDF renderer wheel missing or checksum mismatch')
if arguments.verify_existing:
if any(previous.get(k)!=expected[k] for k in ('python','architecture','sha256')):
raise SystemExit('Existing private runtime identity mismatch')
subprocess.run([str(directory/'python.exe'),str(root/'scripts/verify-python-dependencies.py')],check=True)
if previous!=expected and not arguments.verify_existing:
archive=directory.parent/f'python-{VERSION}-embed-amd64.zip'
if not archive.exists() or hashlib.sha256(archive.read_bytes()).hexdigest()!=CHECKSUM:
with urllib.request.urlopen(f'https://www.python.org/ftp/python/{VERSION}/python-{VERSION}-embed-amd64.zip',timeout=60) as response:
@@ -42,10 +61,15 @@ if previous!=expected:
subprocess.run([sys.executable,'-m','pip','install','--target',str(directory/'Lib'/'site-packages'),
'--platform','win_amd64','--python-version','3.13','--implementation','cp','--abi','cp313',
'--only-binary=:all:','--no-deps','--require-hashes','--upgrade','-r',str(macro_requirements)],check=True)
subprocess.run([sys.executable,'-m','pip','install','--target',str(directory/'Lib'/'site-packages'),
'--platform','win_amd64','--python-version','3.13','--implementation','cp','--abi','cp313',
'--only-binary=:all:','--no-deps','--require-hashes','--upgrade','-r',str(pdf_requirements)],check=True)
manifest.write_text(json.dumps(expected,indent=2))
inspector=directory.parent/'inspector'
inspector.mkdir(exist_ok=True)
for name in ['stage_timings.py','office_macros.py','engine.py','server.py','semantic.py','windows_containment.py','windows_appcontainer.py','sandbox_worker.py','provision_runtime_acl.py']:
for name in ['stage_timings.py','pdf_documents.py','office_content.py','office_macros.py','engine.py','server.py','semantic.py','windows_containment.py','windows_appcontainer.py','sandbox_worker.py','provision_runtime_acl.py']:
shutil.copyfile(root/'apps'/'inspector'/name,inspector/name)
subprocess.run([str(directory/'python.exe'),'-c','import pypdf, PIL, defusedxml, yara, engine; assert engine.inspect_content(b"public", {"dlp":{"maxBytes":1024,"rules":[],"semantic":{"enabled":False}}})["decision"] == "allow"; assert yara.compile(source="rule test { condition: true }").match(data=b"test"); engine.local_ocr(); print("Bundled Windows inspector, YARA and offline ONNX OCR runtime verified.")'],check=True)
subprocess.run([str(directory/'python.exe'),str(root/'apps/inspector/provision_runtime_acl.py'),'--provision'],check=True)
if arguments.verify_existing:
manifest.write_text(json.dumps(expected,indent=2))
+65
View File
@@ -0,0 +1,65 @@
"""Read-only verification of this exact private runtime's pinned distributions.
Installed RECORD checks detect inconsistent installation; only the PDFium wheel
comparison below supplies independent pinned artifact provenance. Neither is an
independent attestation of all base package wheels beyond prior provisioning.
"""
import base64
import hashlib
import importlib.metadata
import re
import sys
import zipfile
from pathlib import Path
root = Path(__file__).resolve().parent.parent
runtime = root / '.runtime/windows/python'
site = runtime / 'Lib/site-packages'
if Path(sys.executable).resolve().parent != runtime.resolve():
raise SystemExit('Use the exact private Windows Python runtime')
names = []
checked = 0
def file_hash(path):
digest = hashlib.sha256()
with path.open('rb') as file:
for chunk in iter(lambda: file.read(1048576), b''): digest.update(chunk)
return digest.digest()
for filename in ('requirements.lock.txt', 'requirements-macros.lock.txt', 'requirements-pdf.lock.txt'):
for line in (root / 'apps/inspector' / filename).read_text(encoding='utf-8').splitlines():
if not line or line.startswith('#'): continue
match = re.match(r'^([A-Za-z0-9_.-]+)==([^\s]+)\s+--hash=sha256:', line)
if not match: raise SystemExit('Runtime dependency lock rejected')
name, version = match.groups()
distribution = importlib.metadata.distribution(name)
if distribution.version != version: raise SystemExit('Runtime dependency version mismatch')
names.append(name)
for entry in distribution.files or []:
if entry.hash is None: continue
path = distribution.locate_file(entry).resolve()
if not path.is_relative_to(runtime.resolve()) or not path.is_file(): raise SystemExit('Runtime dependency file missing')
if entry.hash.mode != 'sha256': raise SystemExit('Runtime dependency hash algorithm rejected')
actual = base64.urlsafe_b64encode(file_hash(path)).decode().rstrip('=')
if actual != entry.hash.value: raise SystemExit('Runtime dependency file integrity mismatch')
checked += 1
wheel = root / '.runtime/windows/pypdfium2-5.14.0-py3-none-win_amd64.whl'
expected = '149fd5c6397b8df8bf7911a93506eff0be874f877afe7ac936cf5d37d21a6a06'
if not wheel.is_file() or hashlib.sha256(wheel.read_bytes()).hexdigest() != expected:
raise SystemExit('Pinned PDFium wheel verification failed')
pdf_files = 0
with zipfile.ZipFile(wheel) as archive:
for entry in archive.infolist():
if entry.is_dir() or entry.filename.endswith('.dist-info/RECORD'): continue
target = (site / entry.filename).resolve()
if not target.is_relative_to(site.resolve()) or not target.is_file() or target.read_bytes() != archive.read(entry):
raise SystemExit('Installed PDFium differs from pinned wheel')
pdf_files += 1
import pypdfium2
if pypdfium2.PYPDFIUM_INFO.api_tag != (5, 14, 0) or pypdfium2.PDFIUM_INFO.flags:
raise SystemExit('PDFium feature manifest rejected')
licenses = site / 'pypdfium2-5.14.0.dist-info/licenses'
if not (licenses / 'LICENSES/Apache-2.0.txt').is_file() or not (licenses / 'LICENSES/BSD-3-Clause.txt').is_file() or not any((licenses / 'data/windows_x64/BUILD_LICENSES').glob('*')):
raise SystemExit('PDFium license notices missing')
print(f'Private runtime verified: {len(names)} pinned distributions, {checked} RECORD hashes, {pdf_files} pinned PDFium wheel files; license notices retained.')
+114
View File
@@ -0,0 +1,114 @@
"""Real OPC ZIP fixtures for strict static OOXML coverage and hostile parts."""
import base64
import io
import json
import sys
import unittest
import zipfile
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent.parent/'apps/inspector'))
from stage_timings import collect,stage,metadata,validated
from office_content import extract_office,OfficeCoverageError,CT,REL,R,W,S,P,A,CP,MAIN,TYPE_PREFIX
PNG=base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6b5sAAAAASUVORK5CYII=')
def xml(tag,namespace,inside=''):return f'<{tag} xmlns="{namespace}" xmlns:r="{R}">{inside}</{tag}>'.encode()
def relations(items):return xml('Relationships',REL,''.join(f'<Relationship Id="{id_}" Type="{type_}" Target="{target}"'+(f' TargetMode="{mode}"' if mode else '')+'/>' for id_,type_,target,mode in items))
def package(family='docx',parts=None,main=None,relationships=None,extra_info=None):
main_name={'docx':'word/document.xml','xlsx':'xl/workbook.xml','pptx':'ppt/presentation.xml'}[family]
main_type=next(type_ for type_,values in MAIN.items() if values[0]==family)
default={'docx':xml('document',W,'<body><p><r><t>Public company newsletter</t></r></p></body>'),'xlsx':xml('workbook',S,'<sheets><sheet name="Public budget" sheetId="1" r:id="rId1"/></sheets>'),'pptx':xml('presentation',P,'<sldIdLst><sldId id="256" r:id="rId1"/></sldIdLst>')}
values={main_name:(main_type,main or default[family])};values.update(parts or {})
if family=='xlsx' and 'xl/worksheets/sheet1.xml' not in values:values['xl/worksheets/sheet1.xml']=(TYPE_PREFIX+'spreadsheetml.worksheet+xml',xml('worksheet',S,'<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Public projected revenue</t></is></c></row></sheetData>'))
if family=='pptx' and 'ppt/slides/slide1.xml' not in values:values['ppt/slides/slide1.xml']=(TYPE_PREFIX+'presentationml.slide+xml',xml('sld',P,f'<cSld><spTree><nvGrpSpPr/><grpSpPr/><sp><nvSpPr/><spPr/><txBody xmlns:a="{A}"><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Public quarterly update</a:t></a:r></a:p></txBody></sp></spTree></cSld>'))
rels={'_rels/.rels':[('root',R+'/officeDocument',main_name,None)]}
if family=='xlsx':rels['xl/_rels/workbook.xml.rels']=[('rId1',R+'/worksheet','worksheets/sheet1.xml',None)]
if family=='pptx':rels['ppt/_rels/presentation.xml.rels']=[('rId1',R+'/slide','slides/slide1.xml',None)]
rels.update(relationships or {})
types=f'<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'+''.join(f'<Override PartName="/{name}" ContentType="{type_}"/>' for name,(type_,_payload) in values.items())+'</Types>'
output=io.BytesIO()
with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as archive:
archive.writestr('[Content_Types].xml',types)
for name,(_type,payload) in values.items():archive.writestr(name,payload)
for name,items in rels.items():archive.writestr(name,relations(items))
if extra_info:archive.writestr(extra_info,b'PUBLIC')
return output.getvalue()
def extract(data,callback=lambda _data:('',True),**limits):return extract_office(data,callback,**limits)
class OfficeTests(unittest.TestCase):
def test_public_valid_docx_xlsx_and_pptx_static_parts_complete(self):
for family in ['docx','xlsx','pptx']:
result=extract(package(family));self.assertTrue(result['complete'],result['reasons']);self.assertEqual(result['family'],family);self.assertIn('Public',result['text'])
def test_core_metadata_attributes_namespace_and_split_runs_included(self):
main=xml('document',W,'<body><p secret="ATTR-SECRET"><r><t>TOP</t></r><r><t>SECRET</t></r></p></body>')
metadata=xml('coreProperties',CP,'<title xmlns="http://purl.org/dc/elements/1.1/">METADATA-SECRET</title>')
result=extract(package(parts={'docProps/core.xml':('application/vnd.openxmlformats-package.core-properties+xml',metadata)},main=main))
self.assertTrue(result['complete']);self.assertIn('ATTR-SECRET',result['text']);self.assertIn('METADATA-SECRET',result['text']);self.assertIn('TOPSECRET',result['text']);self.assertIn(W,result['text'])
def test_shared_strings_and_hidden_unreferenced_parts_are_inspected(self):
parts={'xl/sharedStrings.xml':(TYPE_PREFIX+'spreadsheetml.sharedStrings+xml',xml('sst',S,'<si><t>SHARED-SECRET</t></si>')),'xl/worksheets/hidden.xml':(TYPE_PREFIX+'spreadsheetml.worksheet+xml',xml('worksheet',S,'<sheetData><row><c t="inlineStr"><is><t>HIDDEN-SECRET</t></is></c></row></sheetData>'))}
result=extract(package('xlsx',parts));self.assertTrue(result['complete']);self.assertIn('SHARED-SECRET',result['text']);self.assertIn('HIDDEN-SECRET',result['text'])
def test_image_text_receives_ocr_and_cannot_escape_dlp_input(self):
parts={'word/media/image1.png':('image/png',PNG)};calls=[]
def ocr(payload):calls.append(payload);return ('RASTER-SECRET',True)
result=extract(package(parts=parts),ocr);self.assertTrue(result['complete']);self.assertEqual(calls,[PNG]);self.assertIn('RASTER-SECRET',result['text']);self.assertEqual(len(result['media']),1)
self.assertFalse(extract(package(parts=parts),lambda _data:('',False))['complete'])
def test_external_relationship_metadata_scanned_but_coverage_incomplete(self):
result=extract(package(relationships={'word/_rels/document.xml.rels':[('external',R+'/hyperlink','https://host/RELATIONSHIP-SECRET','External')]}))
self.assertFalse(result['complete']);self.assertIn('RELATIONSHIP-SECRET',result['text'])
def test_unsupported_font_ole_macro_equation_chart_and_vector_fail_closed(self):
for name,type_,payload in [('word/fonts/private.odttf','application/vnd.openxmlformats-officedocument.obfuscatedFont',b'font'),('word/embeddings/a.bin','application/vnd.openxmlformats-officedocument.oleObject',b'OLE'),('word/vbaProject.bin','application/vnd.ms-office.vbaProject',b'VBA'),('word/chart.xml',TYPE_PREFIX+'drawingml.chart+xml',xml('chartSpace',A,'<chart/>'))]:
self.assertFalse(extract(package(parts={name:(type_,payload)}))['complete'])
for content in ['<oMath xmlns="http://schemas.openxmlformats.org/officeDocument/2006/math"/>',f'<custGeom xmlns="{A}"/>','<fldSimple instr="DDE secret"/>']:
self.assertFalse(extract(package(main=xml('document',W,'<body>'+content+'</body>')))['complete'])
def test_unknown_xml_content_type_is_incomplete_even_if_text_readable(self):
result=extract(package(parts={'word/private.xml':('application/x-private+xml',xml('document',W,'<t>SECRET</t>'))}));self.assertFalse(result['complete']);self.assertIn('SECRET',result['text'])
def test_duplicate_case_paths_traversal_and_compression_bombs_rejected(self):
for extra in ['word/../outside','WORD/document.xml']:
with self.assertRaises(OfficeCoverageError):extract(package(extra_info=extra))
with self.assertRaises(OfficeCoverageError):extract(package(main=xml('document',W,'A'*100000)))
def test_relationship_target_missing_escape_id_duplicate_and_role_mismatch_rejected(self):
for items in [[('x',R+'/styles','missing.xml',None)],[('x',R+'/styles','../../../escape.xml',None)],[('x',R+'/styles','document.xml',None)],[('x',R+'/officeDocument','document.xml',None),('x',R+'/officeDocument','document.xml',None)]]:
with self.assertRaises(OfficeCoverageError):extract(package(relationships={'word/_rels/document.xml.rels':items}))
def test_dtd_and_bad_xml_are_rejected(self):
for payload in [b'<!DOCTYPE x [<!ENTITY e "SECRET">]><x>&e;</x>',b'<x>']:
with self.assertRaises(Exception):extract(package(main=payload))
def test_comments_and_processing_instructions_are_not_silently_discarded(self):
result=extract(package(main=xml('document',W,'<!--COMMENT-SECRET--><body><p><r><t>Public</t></r></p></body>')))
self.assertTrue(result['complete']);self.assertIn('COMMENT-SECRET',result['text'])
result=extract(package(main=xml('document',W,'<?xml-stylesheet href="PI-SECRET"?><body/>')))
self.assertFalse(result['complete']);self.assertIn('PI-SECRET',result['text'])
def test_simple_formula_cache_covered_dynamic_dde_and_missing_cache_incomplete(self):
def sheet(formula,cache):return xml('worksheet',S,'<sheetData><row><c r="A1"><f>'+formula+'</f><v>'+cache+'</v></c></row></sheetData>')
for formula,cache,complete in [('SUM(1,2)','3',True),('IF(1,&quot;CACHE-SECRET&quot;,&quot;Public&quot;)','CACHE-SECRET',True),('CHAR(83)&amp;CHAR(69)','SE',False),('WEBSERVICE(&quot;https://host&quot;)','Public',False),("cmd|'/c calc'!A0",'0',False),('SUM(1,2)','',False)]:
result=extract(package('xlsx',{'xl/worksheets/sheet1.xml':(TYPE_PREFIX+'spreadsheetml.worksheet+xml',sheet(formula,cache))}))
self.assertEqual(result['complete'],complete,formula)
if cache:self.assertIn(cache,result['text'])
def test_trailing_data_and_local_extra_fields_cannot_claim_coverage(self):
with self.assertRaises(OfficeCoverageError):extract(package()+b'TRAILING-SECRET')
info=zipfile.ZipInfo('word/extra.bin');info.extra=b'\x01\x00\x03\x00ABC'
with self.assertRaises(OfficeCoverageError):extract(package(extra_info=info))
def test_aggregate_stages_have_real_parent_timing_and_no_document_content(self):
parts={'word/header'+str(index)+'.xml':(TYPE_PREFIX+'wordprocessingml.header+xml',xml('hdr',W,'<p><r><t>SYNTHETIC-PRIVATE-CONTENT</t></r></p>')) for index in range(90)}
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
with stage('parse'):result=extract(package(parts=parts))
self.assertTrue(result['complete']);proof=metadata(state);self.assertIs(validated(proof),proof)
self.assertEqual([record['stage'] for record in proof['records']],['parse','office.archive','office.xml','office.relationships','office.ocr'])
parent=proof['records'][0]
for record in proof['records'][1:]:
self.assertEqual(record['parentId'],parent['id']);self.assertGreaterEqual(record['startOffsetMs'],parent['startOffsetMs']);self.assertLessEqual(record['startOffsetMs']+record['durationMs'],parent['startOffsetMs']+parent['durationMs']);self.assertEqual(record['status'],'ok')
self.assertNotIn('SYNTHETIC-PRIVATE-CONTENT',json.dumps(proof));self.assertNotIn('header90.xml',json.dumps(proof))
def test_error_stages_preserve_correlation_without_exception_text(self):
def bad_ocr(_payload):raise ValueError('SYNTHETIC-PRIVATE-ERROR')
cases=[(package()+b'TAIL',None,'office.archive'),(package(main=b'<BROKEN>'),None,'office.xml'),(package(relationships={'word/_rels/document.xml.rels':[('x',R+'/styles','missing.xml',None)]}),None,'office.relationships'),(package(parts={'word/media/image.png':('image/png',PNG)}),bad_ocr,'office.ocr')]
for data,callback,expected in cases:
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
with self.assertRaises(Exception):
with stage('parse'):extract(data,callback or (lambda _image:('',True)))
proof=metadata(state);self.assertIs(validated(proof),proof);self.assertEqual(proof['records'][0]['status'],'error')
record=next(record for record in proof['records'] if record['stage']==expected);self.assertEqual(record['status'],'error');self.assertEqual(record['parentId'],1);self.assertNotIn('SYNTHETIC-PRIVATE-ERROR',json.dumps(proof))
def test_disabled_or_operational_diagnostics_do_not_change_extraction(self):
for policy in [{},{'diagnostics':{'enabled':False,'mode':'debug'}},{'diagnostics':{'enabled':True,'mode':'operational'}}]:
with collect(policy) as state:result=extract(package())
self.assertTrue(result['complete']);self.assertIsNone(metadata(state))
def test_bounds_cannot_return_complete(self):
data=package()
for limits in [{'max_bytes':len(data)-1},{'max_parts':1},{'max_text':10}]:
with self.assertRaises(OfficeCoverageError):extract(data,**limits)
if __name__=='__main__':unittest.main()