solidprivacy-nl commited on
Commit
43b2999
·
1 Parent(s): 872ba55

Add WP37 DOCX hidden content extraction helper

Browse files
Files changed (1) hide show
  1. docx_hidden_content_extractor.py +225 -0
docx_hidden_content_extractor.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DOCX hidden-content extraction helpers for SolidPrivacy Scrub.
2
+
3
+ WP37 adds local, read-only inspection of DOCX package parts that are outside the
4
+ current foundation DOCX reinsert path. The helper detects and extracts text from
5
+ headers, footers and comments, and reports tracked-change signals. It does not
6
+ clean, remove, rewrite, block export, change reinsert behavior, call AI/cloud
7
+ services, persist files or process real-data fixtures.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from io import BytesIO
13
+ from typing import Any
14
+ from zipfile import BadZipFile, ZipFile
15
+ import re
16
+ import xml.etree.ElementTree as ET
17
+
18
+ WORDPROCESSINGML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
19
+ DOCX_MAIN_DOCUMENT = "word/document.xml"
20
+
21
+ HEADER_PATTERN = re.compile(r"^word/header\d*\.xml$")
22
+ FOOTER_PATTERN = re.compile(r"^word/footer\d*\.xml$")
23
+ COMMENT_PARTS = {
24
+ "word/comments.xml",
25
+ "word/commentsExtended.xml",
26
+ "word/person.xml",
27
+ }
28
+ TRACKED_CHANGE_TAGS = {
29
+ "ins",
30
+ "del",
31
+ "delText",
32
+ "moveFrom",
33
+ "moveTo",
34
+ "moveFromRangeStart",
35
+ "moveFromRangeEnd",
36
+ "moveToRangeStart",
37
+ "moveToRangeEnd",
38
+ }
39
+
40
+
41
+ def _local_name(tag: str) -> str:
42
+ """Return the XML local name for a namespaced or plain tag."""
43
+ if "}" in tag:
44
+ return tag.rsplit("}", 1)[1]
45
+ return tag
46
+
47
+
48
+ def _text_nodes(root: ET.Element) -> list[str]:
49
+ """Extract WordprocessingML text node values from an XML root."""
50
+ values: list[str] = []
51
+ for node in root.iter():
52
+ if _local_name(node.tag) in {"t", "delText", "instrText"} and node.text:
53
+ values.append(node.text)
54
+ return values
55
+
56
+
57
+ def _joined(values: list[str]) -> str:
58
+ return "\n".join(value for value in values if value)
59
+
60
+
61
+ def _parse_xml_part(part_name: str, data: bytes) -> dict[str, Any]:
62
+ """Parse one XML part and return text/tracked-change diagnostics."""
63
+ try:
64
+ root = ET.fromstring(data)
65
+ except ET.ParseError as exc:
66
+ return {
67
+ "part_name": part_name,
68
+ "text_fragments": [],
69
+ "text": "",
70
+ "parse_error": str(exc),
71
+ "tracked_changes": [],
72
+ }
73
+
74
+ tracked_changes: list[dict[str, str]] = []
75
+ for element in root.iter():
76
+ tag_name = _local_name(element.tag)
77
+ if tag_name in TRACKED_CHANGE_TAGS:
78
+ fragments = _text_nodes(element)
79
+ tracked_changes.append({
80
+ "part_name": part_name,
81
+ "element": tag_name,
82
+ "text": _joined(fragments),
83
+ })
84
+
85
+ fragments = _text_nodes(root)
86
+ return {
87
+ "part_name": part_name,
88
+ "text_fragments": fragments,
89
+ "text": _joined(fragments),
90
+ "parse_error": "",
91
+ "tracked_changes": tracked_changes,
92
+ }
93
+
94
+
95
+ def _comment_authors(part_name: str, data: bytes) -> list[str]:
96
+ """Extract synthetic-safe comment/person author-like metadata where present."""
97
+ if part_name not in COMMENT_PARTS:
98
+ return []
99
+ try:
100
+ root = ET.fromstring(data)
101
+ except ET.ParseError:
102
+ return []
103
+
104
+ authors: list[str] = []
105
+ for element in root.iter():
106
+ for attr_name, attr_value in element.attrib.items():
107
+ if _local_name(attr_name) in {"author", "initials", "name"} and attr_value:
108
+ authors.append(attr_value)
109
+ return sorted(set(authors))
110
+
111
+
112
+ def inspect_docx_hidden_content(content: bytes) -> dict[str, Any]:
113
+ """Inspect high-risk DOCX hidden-content parts without changing the file.
114
+
115
+ The returned structure is intentionally audit-oriented. It reports whether
116
+ headers, footers, comments and tracked-change markers exist, and extracts
117
+ their text where possible. The helper is side-effect free and does not make
118
+ clean-export claims.
119
+ """
120
+ if not isinstance(content, (bytes, bytearray)):
121
+ return {
122
+ "document_type": "docx",
123
+ "valid_docx": False,
124
+ "validation_issues": ["DOCX content must be bytes."],
125
+ "local_only": True,
126
+ "ai_processing": False,
127
+ "cloud_processing": False,
128
+ "extraction_only": True,
129
+ "cleaning_applied": False,
130
+ "export_blocking": False,
131
+ "docx_parts_seen": [],
132
+ "headers": [],
133
+ "footers": [],
134
+ "comments": [],
135
+ "tracked_changes": [],
136
+ "detected": {},
137
+ "warnings": ["DOCX content could not be inspected because it was not bytes."],
138
+ }
139
+
140
+ original_content = bytes(content)
141
+ try:
142
+ with ZipFile(BytesIO(original_content), "r") as package:
143
+ part_names = sorted(package.namelist())
144
+ parsed_parts: list[dict[str, Any]] = []
145
+ headers: list[dict[str, Any]] = []
146
+ footers: list[dict[str, Any]] = []
147
+ comments: list[dict[str, Any]] = []
148
+ tracked_changes: list[dict[str, str]] = []
149
+
150
+ for part_name in part_names:
151
+ is_header = bool(HEADER_PATTERN.match(part_name))
152
+ is_footer = bool(FOOTER_PATTERN.match(part_name))
153
+ is_comment = part_name in COMMENT_PARTS
154
+ should_scan_for_tracked_changes = part_name.startswith("word/") and part_name.endswith(".xml")
155
+
156
+ if not (is_header or is_footer or is_comment or should_scan_for_tracked_changes):
157
+ continue
158
+
159
+ data = package.read(part_name)
160
+ parsed = _parse_xml_part(part_name, data)
161
+ parsed_parts.append(parsed)
162
+ tracked_changes.extend(parsed["tracked_changes"])
163
+
164
+ if is_header:
165
+ headers.append(parsed)
166
+ if is_footer:
167
+ footers.append(parsed)
168
+ if is_comment:
169
+ comment_info = dict(parsed)
170
+ comment_info["authors"] = _comment_authors(part_name, data)
171
+ comments.append(comment_info)
172
+ except BadZipFile:
173
+ return {
174
+ "document_type": "docx",
175
+ "valid_docx": False,
176
+ "validation_issues": ["DOCX content is not a valid OOXML package."],
177
+ "local_only": True,
178
+ "ai_processing": False,
179
+ "cloud_processing": False,
180
+ "extraction_only": True,
181
+ "cleaning_applied": False,
182
+ "export_blocking": False,
183
+ "docx_parts_seen": [],
184
+ "headers": [],
185
+ "footers": [],
186
+ "comments": [],
187
+ "tracked_changes": [],
188
+ "detected": {},
189
+ "warnings": ["DOCX content is not a valid OOXML package."],
190
+ }
191
+
192
+ detected = {
193
+ "headers_detected": bool(headers),
194
+ "footers_detected": bool(footers),
195
+ "comments_detected": bool(comments),
196
+ "tracked_changes_detected": bool(tracked_changes),
197
+ }
198
+ warnings: list[str] = []
199
+ if headers:
200
+ warnings.append("DOCX headers were detected and extracted for audit; they are not cleaned by this helper.")
201
+ if footers:
202
+ warnings.append("DOCX footers were detected and extracted for audit; they are not cleaned by this helper.")
203
+ if comments:
204
+ warnings.append("DOCX comments/person metadata were detected and extracted for audit; they are not cleaned by this helper.")
205
+ if tracked_changes:
206
+ warnings.append("DOCX tracked-change markers were detected; no accept/remove/blocking policy is applied by this helper.")
207
+
208
+ return {
209
+ "document_type": "docx",
210
+ "valid_docx": True,
211
+ "validation_issues": [],
212
+ "local_only": True,
213
+ "ai_processing": False,
214
+ "cloud_processing": False,
215
+ "extraction_only": True,
216
+ "cleaning_applied": False,
217
+ "export_blocking": False,
218
+ "docx_parts_seen": part_names,
219
+ "headers": headers,
220
+ "footers": footers,
221
+ "comments": comments,
222
+ "tracked_changes": tracked_changes,
223
+ "detected": detected,
224
+ "warnings": warnings,
225
+ }