docx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docx_cli/__init__.py +8 -0
- docx_cli/__main__.py +7 -0
- docx_cli/_templates/hodatajstvo.json +60 -0
- docx_cli/_templates/iskovoe_zayavlenie.json +75 -0
- docx_cli/_templates/otzyv_na_isk.json +69 -0
- docx_cli/_templates/soyu_rayon.json +61 -0
- docx_cli/_templates/vs_sud.json +65 -0
- docx_cli/agent_reader.py +87 -0
- docx_cli/blocks.py +333 -0
- docx_cli/builder.py +116 -0
- docx_cli/cli.py +641 -0
- docx_cli/errors.py +21 -0
- docx_cli/fonts_normalize.py +119 -0
- docx_cli/inject_scan.py +369 -0
- docx_cli/mcp_server.py +380 -0
- docx_cli/md_court.py +665 -0
- docx_cli/profiles.py +272 -0
- docx_cli/spec.py +233 -0
- docx_cli/validate.py +786 -0
- docx_cli-0.1.0.dist-info/METADATA +95 -0
- docx_cli-0.1.0.dist-info/RECORD +25 -0
- docx_cli-0.1.0.dist-info/WHEEL +4 -0
- docx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- docx_cli-0.1.0.dist-info/licenses/LICENSE +199 -0
- docx_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""docx-cli normalize — приведение шрифтов/интервалов к Times New Roman."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docx import Document
|
|
8
|
+
from docx.oxml.ns import qn
|
|
9
|
+
from docx.shared import Cm, Pt
|
|
10
|
+
from docx.text.run import Run
|
|
11
|
+
|
|
12
|
+
TARGET_FONT = "Times New Roman"
|
|
13
|
+
GOST_FIRST_LINE_INDENT_CM = 1.25
|
|
14
|
+
_TOL_CM = 0.05
|
|
15
|
+
_KNOWN_FONTS = {
|
|
16
|
+
"calibri", "cambria", "arial", "verdana", "tahoma", "segoe ui",
|
|
17
|
+
"helvetica", "liberation serif", "liberation sans", "dejavu sans",
|
|
18
|
+
"pt serif", "pt sans",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _all_runs(p):
|
|
23
|
+
for r in p._p.iter(qn("w:r")):
|
|
24
|
+
yield Run(r, p)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _force_font(font_obj, size_pt: int | None = None) -> bool:
|
|
28
|
+
changed = False
|
|
29
|
+
name = (font_obj.name or "").lower()
|
|
30
|
+
if name in _KNOWN_FONTS or (name and name != TARGET_FONT.lower()):
|
|
31
|
+
font_obj.name = TARGET_FONT
|
|
32
|
+
changed = True
|
|
33
|
+
if size_pt is not None and font_obj.size is not None and font_obj.size != Pt(size_pt):
|
|
34
|
+
font_obj.size = Pt(size_pt)
|
|
35
|
+
changed = True
|
|
36
|
+
return changed
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _repair_first_line_indents(doc, target_cm: float = GOST_FIRST_LINE_INDENT_CM) -> int:
|
|
40
|
+
from docx_cli.validate import _classify_paragraphs
|
|
41
|
+
roles = _classify_paragraphs(doc, None)
|
|
42
|
+
fixed = 0
|
|
43
|
+
for idx, p in enumerate(doc.paragraphs):
|
|
44
|
+
if roles.get(idx) != "body":
|
|
45
|
+
continue
|
|
46
|
+
if not p.text.strip():
|
|
47
|
+
continue
|
|
48
|
+
pf = p.paragraph_format
|
|
49
|
+
cur = pf.first_line_indent
|
|
50
|
+
if cur is not None and abs(cur.cm - target_cm) <= _TOL_CM:
|
|
51
|
+
continue
|
|
52
|
+
pf.first_line_indent = Cm(target_cm)
|
|
53
|
+
fixed += 1
|
|
54
|
+
return fixed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_document(path, body_size: int = 12, header_size: int = 11,
|
|
58
|
+
dry_run: bool = False, backup: bool = True) -> dict:
|
|
59
|
+
doc = Document(str(path))
|
|
60
|
+
changed = 0
|
|
61
|
+
|
|
62
|
+
if _force_font(doc.styles["Normal"].font, body_size):
|
|
63
|
+
changed += 1
|
|
64
|
+
for style in doc.styles:
|
|
65
|
+
try:
|
|
66
|
+
if style.font is not None and _force_font(style.font):
|
|
67
|
+
changed += 1
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
continue
|
|
70
|
+
for p in doc.paragraphs:
|
|
71
|
+
if p.style and p.style.name and "Heading" in p.style.name:
|
|
72
|
+
for run in _all_runs(p):
|
|
73
|
+
changed += int(_force_font(run.font, header_size))
|
|
74
|
+
else:
|
|
75
|
+
for run in _all_runs(p):
|
|
76
|
+
changed += int(_force_font(run.font, body_size))
|
|
77
|
+
for table in doc.tables:
|
|
78
|
+
for row in table.rows:
|
|
79
|
+
for cell in row.cells:
|
|
80
|
+
for p in cell.paragraphs:
|
|
81
|
+
for run in _all_runs(p):
|
|
82
|
+
changed += int(_force_font(run.font, body_size))
|
|
83
|
+
for section in doc.sections:
|
|
84
|
+
for container in (section.header, section.footer):
|
|
85
|
+
for p in container.paragraphs:
|
|
86
|
+
for run in _all_runs(p):
|
|
87
|
+
changed += int(_force_font(run.font, header_size))
|
|
88
|
+
|
|
89
|
+
indent_fixed = _repair_first_line_indents(doc)
|
|
90
|
+
changed += indent_fixed
|
|
91
|
+
|
|
92
|
+
backup_path = None
|
|
93
|
+
if not dry_run:
|
|
94
|
+
if backup:
|
|
95
|
+
backup_path = str(path) + ".bak"
|
|
96
|
+
shutil.copy2(str(path), backup_path)
|
|
97
|
+
doc.save(str(path))
|
|
98
|
+
return {"file": str(path), "changes": changed,
|
|
99
|
+
"first_line_indent_fixed": indent_fixed,
|
|
100
|
+
"dry_run": dry_run, "backup": backup_path}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_path(path, body_size: int = 12, header_size: int = 11,
|
|
104
|
+
dry_run: bool = False, backup: bool = True,
|
|
105
|
+
yes: bool = False) -> dict:
|
|
106
|
+
p = Path(path)
|
|
107
|
+
if p.is_file():
|
|
108
|
+
res = normalize_document(p, body_size, header_size, dry_run=dry_run, backup=backup)
|
|
109
|
+
return {"normalized": [res], "count": 1, "dry_run": dry_run}
|
|
110
|
+
if p.is_dir():
|
|
111
|
+
if not yes:
|
|
112
|
+
raise ValueError("invalid_usage: directory mode requires --yes")
|
|
113
|
+
files = [f for f in sorted(p.glob("*.docx")) if not f.name.startswith("~$")]
|
|
114
|
+
if not files:
|
|
115
|
+
raise FileNotFoundError(f"нет .docx в каталоге: {p}")
|
|
116
|
+
results = [normalize_document(f, body_size, header_size, dry_run=dry_run, backup=backup)
|
|
117
|
+
for f in files]
|
|
118
|
+
return {"normalized": results, "count": len(results), "dry_run": dry_run}
|
|
119
|
+
raise FileNotFoundError(f"not found: {p}")
|
docx_cli/inject_scan.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""docx-cli scan — аудит входящих .docx на LLM-атаки (скрытые векторы).
|
|
2
|
+
|
|
3
|
+
v0.3.0 (2026-10-20): собственная clean-room реализация эвристик аудита
|
|
4
|
+
входящих документов. Дополняет guard-слои обработки текста: те изолируют
|
|
5
|
+
ПРОЧИТАННОЕ (spotlight), этот находит
|
|
6
|
+
СКРЫТОЕ ДО обработки:
|
|
7
|
+
|
|
8
|
+
- скрытый текст w:vanish;
|
|
9
|
+
- микрошрифт (<= 2pt в w:sz);
|
|
10
|
+
- белый текст на светлом/прозрачном фоне (с фильтром тёмных ячеек таблиц);
|
|
11
|
+
- текст внутри отслеживаемых правок w:ins / w:del;
|
|
12
|
+
- скрытые инструкции в word/comments.xml.
|
|
13
|
+
|
|
14
|
+
Fail-closed: битый файл/не-docx = findings не пуст и clean=False.
|
|
15
|
+
v0.6.0: стиль-наследование (rStyle/basedOn), near-white/themeColor,
|
|
16
|
+
все word/*.xml части (headers/footers/footnotes/endnotes), fail-closed
|
|
17
|
+
конверты для не-docx/битого XML (внешний аудит 21.10, векторы F1/F2/F3)."""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import copy
|
|
21
|
+
import re
|
|
22
|
+
import zipfile
|
|
23
|
+
from xml.etree import ElementTree as ET
|
|
24
|
+
|
|
25
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
26
|
+
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
27
|
+
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
28
|
+
|
|
29
|
+
def _extract_text(el) -> str:
|
|
30
|
+
return "".join(t.text or "" for t in el.iter(f"{{{W}}}t"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _has_dark_cell_shading(tc) -> bool:
|
|
34
|
+
shd = tc.find(f".//{{{W}}}shd")
|
|
35
|
+
if shd is None:
|
|
36
|
+
return False
|
|
37
|
+
fill = (shd.get(f"{{{W}}}fill") or "").lower()
|
|
38
|
+
return fill not in ("", "auto", "ffffff")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _styles_map(styles) -> dict:
|
|
42
|
+
if styles is None:
|
|
43
|
+
return {}
|
|
44
|
+
if isinstance(styles, dict):
|
|
45
|
+
return styles
|
|
46
|
+
out = {}
|
|
47
|
+
try:
|
|
48
|
+
for s in styles.iter(f"{{{W}}}style"):
|
|
49
|
+
sid = s.get(f"{{{W}}}styleId")
|
|
50
|
+
if sid is not None:
|
|
51
|
+
out[sid] = s
|
|
52
|
+
except Exception: # noqa: BLE001
|
|
53
|
+
pass
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _style_chain(styles_map: dict, style_id) -> list:
|
|
58
|
+
chain = []
|
|
59
|
+
cur = style_id
|
|
60
|
+
seen = set()
|
|
61
|
+
while cur is not None and cur not in seen and len(chain) < 4:
|
|
62
|
+
seen.add(cur)
|
|
63
|
+
chain.append(cur)
|
|
64
|
+
st = styles_map.get(cur)
|
|
65
|
+
if st is None:
|
|
66
|
+
break
|
|
67
|
+
bo = st.find(f"{{{W}}}basedOn")
|
|
68
|
+
cur = bo.get(f"{{{W}}}val") if bo is not None else None
|
|
69
|
+
chain.reverse()
|
|
70
|
+
return chain
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _effective_rpr(styles_map: dict, run_rpr, style_id) -> "ET.Element":
|
|
74
|
+
erpr = ET.Element(f"{{{W}}}rPr")
|
|
75
|
+
for sid in _style_chain(styles_map, style_id):
|
|
76
|
+
st = styles_map.get(sid)
|
|
77
|
+
if st is None:
|
|
78
|
+
continue
|
|
79
|
+
srpr = st.find(f"{{{W}}}rPr")
|
|
80
|
+
if srpr is None:
|
|
81
|
+
continue
|
|
82
|
+
for child in srpr:
|
|
83
|
+
existing = erpr.find(child.tag)
|
|
84
|
+
if existing is not None:
|
|
85
|
+
erpr.remove(existing)
|
|
86
|
+
erpr.append(copy.deepcopy(child))
|
|
87
|
+
if run_rpr is not None:
|
|
88
|
+
for child in run_rpr:
|
|
89
|
+
existing = erpr.find(child.tag)
|
|
90
|
+
if existing is not None:
|
|
91
|
+
erpr.remove(existing)
|
|
92
|
+
erpr.append(copy.deepcopy(child))
|
|
93
|
+
return erpr
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _scan_runs(root, *args, path="document.xml", styles=None, styles_map=None,
|
|
97
|
+
findings=None, **kwargs) -> None:
|
|
98
|
+
_findings = findings
|
|
99
|
+
_styles = styles if styles is not None else styles_map
|
|
100
|
+
_path = path
|
|
101
|
+
for a in args:
|
|
102
|
+
if isinstance(a, list):
|
|
103
|
+
_findings = a
|
|
104
|
+
elif isinstance(a, str):
|
|
105
|
+
_path = a
|
|
106
|
+
else:
|
|
107
|
+
if _styles is None:
|
|
108
|
+
_styles = a
|
|
109
|
+
elif _findings is None:
|
|
110
|
+
_findings = a
|
|
111
|
+
if _findings is None:
|
|
112
|
+
_findings = kwargs.get("findings", [])
|
|
113
|
+
if _path is None:
|
|
114
|
+
_path = kwargs.get("path", "document.xml")
|
|
115
|
+
smap = _styles_map(_styles)
|
|
116
|
+
if _findings is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
for r in root.iter(f"{{{W}}}r"):
|
|
120
|
+
rpr = r.find(f"{{{W}}}rPr")
|
|
121
|
+
style_id = None
|
|
122
|
+
rs = None
|
|
123
|
+
if rpr is not None:
|
|
124
|
+
rs = rpr.find(f"{{{W}}}rStyle")
|
|
125
|
+
if rs is None:
|
|
126
|
+
rs = r.find(f"{{{W}}}rStyle")
|
|
127
|
+
if rs is not None:
|
|
128
|
+
style_id = rs.get(f"{{{W}}}val")
|
|
129
|
+
eff = _effective_rpr(smap, rpr, style_id)
|
|
130
|
+
text = _extract_text(r)
|
|
131
|
+
if not text:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
vanish = eff.find(f"{{{W}}}vanish")
|
|
135
|
+
if vanish is not None and vanish.get(f"{{{W}}}val", "1") not in ("0", "false", "off"):
|
|
136
|
+
_findings.append({"type": "hidden_text_vanish", "text": text, "path": _path})
|
|
137
|
+
|
|
138
|
+
sz = eff.find(f"{{{W}}}sz")
|
|
139
|
+
szcs = eff.find(f"{{{W}}}szCs")
|
|
140
|
+
micro_val = None
|
|
141
|
+
if sz is not None and (sz.get(f"{{{W}}}val") or "").isdigit() and int(sz.get(f"{{{W}}}val")) <= 4:
|
|
142
|
+
micro_val = sz.get(f"{{{W}}}val")
|
|
143
|
+
elif szcs is not None and (szcs.get(f"{{{W}}}val") or "").isdigit() and int(szcs.get(f"{{{W}}}val")) <= 4:
|
|
144
|
+
micro_val = szcs.get(f"{{{W}}}val")
|
|
145
|
+
if micro_val is None and szcs is not None and (szcs.get(f"{{{W}}}val") or "").isdigit() and int(szcs.get(f"{{{W}}}val")) <= 4:
|
|
146
|
+
micro_val = szcs.get(f"{{{W}}}val")
|
|
147
|
+
if micro_val is not None:
|
|
148
|
+
_findings.append({"type": "microfont", "text": text,
|
|
149
|
+
"size_pt": int(micro_val) / 2, "path": _path})
|
|
150
|
+
|
|
151
|
+
color = eff.find(f"{{{W}}}color")
|
|
152
|
+
if color is not None:
|
|
153
|
+
val = (color.get(f"{{{W}}}val") or "").lower()
|
|
154
|
+
tc = (color.get(f"{{{W}}}themeColor") or "").lower()
|
|
155
|
+
if val in ("ffffff", "white"):
|
|
156
|
+
_findings.append({"type": "white_text_candidate", "text": text,
|
|
157
|
+
"color": "ffffff", "path": _path})
|
|
158
|
+
else:
|
|
159
|
+
if _near_white(val):
|
|
160
|
+
_findings.append({"type": "near_white", "text": text,
|
|
161
|
+
"color": val, "path": _path})
|
|
162
|
+
if tc in ("light1", "light2", "background1", "background2") or \
|
|
163
|
+
(tc == "auto" and val in ("ffffff", "white", "auto")):
|
|
164
|
+
_findings.append({"type": "theme_white", "text": text,
|
|
165
|
+
"themeColor": tc or val, "path": _path})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _near_white(val: str) -> bool:
|
|
169
|
+
if not re.fullmatch(r"[0-9a-f]{6}", val):
|
|
170
|
+
return False
|
|
171
|
+
try:
|
|
172
|
+
r = int(val[0:2], 16)
|
|
173
|
+
g = int(val[2:4], 16)
|
|
174
|
+
b = int(val[4:6], 16)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return False
|
|
177
|
+
dr, dg, db = 255 - r, 255 - g, 255 - b
|
|
178
|
+
if dr <= 8 and dg <= 8 and db <= 8:
|
|
179
|
+
return True
|
|
180
|
+
return (dr * dr + dg * dg + db * db) ** 0.5 < 16
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _filter_white_by_dark_cells(root, findings: list) -> list:
|
|
184
|
+
dark_cells = [tc for tbl in root.iter(f"{{{W}}}tbl")
|
|
185
|
+
for tc in tbl.iter(f"{{{W}}}tc") if _has_dark_cell_shading(tc)]
|
|
186
|
+
out = []
|
|
187
|
+
for f in findings:
|
|
188
|
+
if f.get("type") != "white_text_candidate":
|
|
189
|
+
out.append(f)
|
|
190
|
+
continue
|
|
191
|
+
in_dark = any(f["text"] in _extract_text(tc) for tc in dark_cells)
|
|
192
|
+
if not in_dark:
|
|
193
|
+
f["type"] = "white_text_on_light_bg"
|
|
194
|
+
f["note"] = "confirmed: white text on light/transparent background"
|
|
195
|
+
out.append(f)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _scan_part(zf: zipfile.ZipFile, part: str, findings: list, styles=None) -> None:
|
|
200
|
+
root = ET.fromstring(zf.read(part))
|
|
201
|
+
smap = _styles_map(styles)
|
|
202
|
+
if not smap:
|
|
203
|
+
try:
|
|
204
|
+
if "word/styles.xml" in set(zf.namelist()):
|
|
205
|
+
smap = _styles_map(ET.fromstring(zf.read("word/styles.xml")))
|
|
206
|
+
except Exception: # noqa: BLE001
|
|
207
|
+
smap = {}
|
|
208
|
+
_scan_runs(root, findings, path=part, styles=smap)
|
|
209
|
+
findings[:] = _filter_white_by_dark_cells(root, findings) if False else findings
|
|
210
|
+
# filter only newly added white candidates for this part without disturbing prior findings:
|
|
211
|
+
# re-run filter on tail is complex; apply filter to whole findings is idempotent for non-candidates.
|
|
212
|
+
filtered = _filter_white_by_dark_cells(root, findings)
|
|
213
|
+
findings.clear()
|
|
214
|
+
findings.extend(filtered)
|
|
215
|
+
for tag in ("ins", "del"):
|
|
216
|
+
for el in root.iter(f"{{{W}}}{tag}"):
|
|
217
|
+
txt = _extract_text(el)
|
|
218
|
+
if txt:
|
|
219
|
+
findings.append({"type": "tracked_change", "text": txt, "path": part})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _domain(url: str) -> str | None:
|
|
223
|
+
if not url:
|
|
224
|
+
return None
|
|
225
|
+
u = url.strip().lower()
|
|
226
|
+
if "://" in u:
|
|
227
|
+
u = u.split("://", 1)[1]
|
|
228
|
+
u = u.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
|
|
229
|
+
if ":" in u:
|
|
230
|
+
u = u.split(":", 1)[0]
|
|
231
|
+
return u or None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _domain_from_text(text: str) -> str | None:
|
|
235
|
+
import re
|
|
236
|
+
if not text:
|
|
237
|
+
return None
|
|
238
|
+
m = re.search(r"(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9.-]*\.[a-z]{2,})", text.lower())
|
|
239
|
+
return m.group(1) if m else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _suspicious_metadata(text: str) -> bool:
|
|
243
|
+
if not text or not text.strip():
|
|
244
|
+
return False
|
|
245
|
+
invisible = ("\u200b", "\u200c", "\u200d", "\u2060", "\ufeff", "\u202a", "\u202b", "\u202c", "\u202d", "\u202e")
|
|
246
|
+
if any(ch in text for ch in invisible):
|
|
247
|
+
return True
|
|
248
|
+
low = text.lower()
|
|
249
|
+
keys = ("инструкц", "ignore", "system", "prompt", "assistant", "disregard",
|
|
250
|
+
"jailbreak", "<", "w:t", "http://", "https://")
|
|
251
|
+
return any(k in low for k in keys)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _check_field(instr: str, display: str, findings: list, path: str) -> None:
|
|
255
|
+
if not instr:
|
|
256
|
+
return
|
|
257
|
+
iu = instr.upper()
|
|
258
|
+
benign = ("PAGE" in iu and "HYPERLINK" not in iu) or ("NUMPAGES" in iu and "HYPERLINK" not in iu)
|
|
259
|
+
if (not benign) and ("W:T" in iu or "<" in instr or "instrText" in instr):
|
|
260
|
+
findings.append({"type": "field_code", "text": instr.strip(), "path": path})
|
|
261
|
+
if "HYPERLINK" in iu:
|
|
262
|
+
import re
|
|
263
|
+
m = re.search(r'HYPERLINK\s+"([^"]+)"', instr, re.I)
|
|
264
|
+
url = m.group(1) if m else None
|
|
265
|
+
td = _domain(url) if url else None
|
|
266
|
+
dd = _domain_from_text(display)
|
|
267
|
+
if td and dd and td != dd:
|
|
268
|
+
findings.append({"type": "link_mismatch", "text": display, "target": url, "path": path})
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _scan_hyperlinks_and_fields(root, findings: list, path: str, rels: dict) -> None:
|
|
272
|
+
for hp in root.iter(f"{{{W}}}hyperlink"):
|
|
273
|
+
rid = hp.get(f"{{{R}}}id")
|
|
274
|
+
target = rels.get(rid) if rid else None
|
|
275
|
+
if target is None:
|
|
276
|
+
target = hp.get(f"{{{W}}}anchor")
|
|
277
|
+
display = "".join(t.text or "" for t in hp.iter(f"{{{W}}}t"))
|
|
278
|
+
td = _domain(target) if target and ("://" in (target or "") or "." in (target or "")) else None
|
|
279
|
+
# only treat as URL domain if target looks like url
|
|
280
|
+
if target and "://" not in (target or "") and not (target or "").startswith("http"):
|
|
281
|
+
td = None
|
|
282
|
+
dd = _domain_from_text(display)
|
|
283
|
+
if td and dd and td != dd:
|
|
284
|
+
findings.append({"type": "link_mismatch", "text": display, "target": target, "path": path})
|
|
285
|
+
|
|
286
|
+
for fs in root.iter(f"{{{W}}}fldSimple"):
|
|
287
|
+
instr = fs.get(f"{{{W}}}instr", "")
|
|
288
|
+
display = "".join(t.text or "" for t in fs.iter(f"{{{W}}}t"))
|
|
289
|
+
_check_field(instr, display, findings, path)
|
|
290
|
+
|
|
291
|
+
for p in root.iter(f"{{{W}}}p"):
|
|
292
|
+
instrs = [it.text or "" for it in p.iter(f"{{{W}}}instrText")]
|
|
293
|
+
if not instrs:
|
|
294
|
+
continue
|
|
295
|
+
display = "".join(t.text or "" for t in p.iter(f"{{{W}}}t"))
|
|
296
|
+
_check_field(" ".join(instrs), display, findings, path)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def scan_all(filepath: str) -> dict:
|
|
300
|
+
findings: list = []
|
|
301
|
+
path = str(filepath)
|
|
302
|
+
try:
|
|
303
|
+
with zipfile.ZipFile(path) as zf:
|
|
304
|
+
names = set(zf.namelist())
|
|
305
|
+
if "word/document.xml" not in names:
|
|
306
|
+
return {"file": path, "clean": False,
|
|
307
|
+
"findings": [{"type": "not_a_docx", "path": "word/document.xml"}]}
|
|
308
|
+
|
|
309
|
+
smap = {}
|
|
310
|
+
if "word/styles.xml" in names:
|
|
311
|
+
try:
|
|
312
|
+
smap = _styles_map(ET.fromstring(zf.read("word/styles.xml")))
|
|
313
|
+
except Exception: # noqa: BLE001
|
|
314
|
+
findings.append({"type": "xml_parse_error", "path": "word/styles.xml"})
|
|
315
|
+
|
|
316
|
+
parts = sorted(n for n in names if (n.startswith("word/") or n.startswith("docProps/")) and n.endswith(".xml"))
|
|
317
|
+
for part in parts:
|
|
318
|
+
try:
|
|
319
|
+
root = ET.fromstring(zf.read(part))
|
|
320
|
+
except ET.ParseError:
|
|
321
|
+
findings.append({"type": "xml_parse_error", "path": part})
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
if part.startswith("docProps/"):
|
|
325
|
+
text = " ".join(root.itertext())
|
|
326
|
+
if _suspicious_metadata(text):
|
|
327
|
+
findings.append({"type": "metadata_injection", "text": text.strip(), "path": part})
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
if not list(root.iter(f"{{{W}}}t")) and part not in ("word/document.xml",):
|
|
331
|
+
# metadata-ish word parts may have no t; still scan fields
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
part_findings = []
|
|
335
|
+
_scan_runs(root, part_findings, path=part, styles=smap)
|
|
336
|
+
part_findings = _filter_white_by_dark_cells(root, part_findings)
|
|
337
|
+
for tag in ("ins", "del"):
|
|
338
|
+
for el in root.iter(f"{{{W}}}{tag}"):
|
|
339
|
+
txt = _extract_text(el)
|
|
340
|
+
if txt:
|
|
341
|
+
part_findings.append({"type": "tracked_change", "text": txt, "path": part})
|
|
342
|
+
|
|
343
|
+
rels = {}
|
|
344
|
+
rels_name = part.replace("word/", "word/_rels/", 1) + ".rels" if part.startswith("word/") else None
|
|
345
|
+
if rels_name and rels_name in names:
|
|
346
|
+
try:
|
|
347
|
+
rroot = ET.fromstring(zf.read(rels_name))
|
|
348
|
+
for rel in rroot.iter(f"{{{REL_NS}}}Relationship"):
|
|
349
|
+
rid = rel.get("Id")
|
|
350
|
+
target = rel.get("Target")
|
|
351
|
+
if rid and target:
|
|
352
|
+
rels[rid] = target
|
|
353
|
+
except ET.ParseError:
|
|
354
|
+
pass
|
|
355
|
+
_scan_hyperlinks_and_fields(root, part_findings, part, rels)
|
|
356
|
+
findings.extend(part_findings)
|
|
357
|
+
except FileNotFoundError:
|
|
358
|
+
return {"file": path, "clean": False,
|
|
359
|
+
"findings": [{"type": "io_error", "path": path, "reason": "file not found"}]}
|
|
360
|
+
except zipfile.BadZipFile:
|
|
361
|
+
return {"file": path, "clean": False,
|
|
362
|
+
"findings": [{"type": "invalid_zip", "path": path}]}
|
|
363
|
+
except OSError as exc:
|
|
364
|
+
return {"file": path, "clean": False,
|
|
365
|
+
"findings": [{"type": "io_error", "path": path, "reason": str(exc)}]}
|
|
366
|
+
except Exception as exc: # noqa: BLE001
|
|
367
|
+
return {"file": path, "clean": False,
|
|
368
|
+
"findings": [{"type": "io_error", "path": path, "reason": str(exc)}]}
|
|
369
|
+
return {"file": path, "clean": not findings, "findings": findings}
|