fix: always empty DOCX docProps provenance fields (#76) (#83)

This commit is contained in:
Guillaume Meyer (The Opinionated Man)
2026-08-15 15:32:12 -07:00
committed by GitHub
parent 6750f88d01
commit 737eaa327c
2 changed files with 139 additions and 46 deletions
+42 -46
View File
@@ -436,6 +436,22 @@ DOCX_CUSTOM_PREFIXES = (
"docProps/",
)
# Provenance fields in docProps/core.xml and docProps/app.xml that always come
# out empty. dc:title is deliberately not listed: it is the document's own
# heading, not provenance.
DOCX_SCRUB_FIELDS = (
("dc:creator", "dc:creator"),
("cp:lastModifiedBy", "cp:lastModifiedBy"),
("dc:description", "dc:description"),
("cp:keywords", "cp:keywords"),
("dc:subject", "dc:subject"),
("cp:category", "cp:category"),
("Application", "Application"),
("AppVersion", "AppVersion"),
("Company", "Company"),
("Manager", "Manager"),
)
def _zip_namelist(data: bytes) -> list[str]:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
@@ -606,55 +622,27 @@ def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, l
actions.append(f"drop part {name}")
continue
if name in DOCX_META_PARTS or name.startswith("docProps/"):
text = raw.decode("utf-8", errors="replace")
# Scrub known AI generator fields via simple regex on XML text nodes
new = text
for pat, repl, label in (
(
r"(<dc:creator[^>]*>)(.*?)(</dc:creator>)",
None,
"dc:creator",
),
(
r"(<cp:lastModifiedBy[^>]*>)(.*?)(</cp:lastModifiedBy>)",
None,
"cp:lastModifiedBy",
),
(
r"(<Application[^>]*>)(.*?)(</Application>)",
None,
"Application",
),
(
r"(<AppVersion[^>]*>)(.*?)(</AppVersion>)",
None,
"AppVersion",
),
):
def _sub(m: re.Match[str], _label=label) -> str:
inner = m.group(2)
if AI_META_NAME_RE.search(inner) or AI_META_NAME_RE.search(_label):
actions.append(f"scrub {name} field {_label}")
return m.group(1) + m.group(3)
# Always clear Application if it looks like AI
if _label in ("Application", "AppVersion") and re.search(
r"claude|openai|anthropic|gemini|chatgpt|synthid|copilot",
inner,
re.I,
):
actions.append(f"scrub {name} field {_label}")
return m.group(1) + m.group(3)
return m.group(0)
new = re.sub(pat, _sub, new, flags=re.I | re.DOTALL)
# Drop custom.xml entirely if AI-ish
if name.endswith("custom.xml") and (
_blob_hits(raw)[1] or AI_META_NAME_RE.search(text)
):
# docProps/custom.xml holds arbitrary user properties — a
# provenance channel. The part is optional, so drop it whole.
if name.endswith("custom.xml"):
actions.append(f"drop part {name}")
continue
text = raw.decode("utf-8", errors="replace")
# Empty the provenance fields unconditionally (dc:title is not
# in DOCX_SCRUB_FIELDS). Keeping the tags keeps the XML schema
# valid; Word tolerates empty core/app properties.
new = text
for tag, label in DOCX_SCRUB_FIELDS:
pat = rf"(<{tag}\b[^>]*>)(.*?)(</{tag}>)"
def _empty(m: re.Match[str], _label=label) -> str:
if m.group(2):
actions.append(f"scrub {name} field {_label}")
return m.group(1) + m.group(3)
new = re.sub(pat, _empty, new, flags=re.I | re.DOTALL)
raw = new.encode("utf-8")
# content types: leave as-is (removing overrides for dropped customXml is nice-to-have)
# content types: remove overrides for parts that no longer exist
if name == "[Content_Types].xml":
text = raw.decode("utf-8", errors="replace")
new, n = re.subn(
@@ -665,6 +653,14 @@ def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, l
if n:
actions.append(f"drop Content_Types customXml overrides x{n}")
raw = new.encode("utf-8")
new, n = re.subn(
r'<Override\b[^>]*PartName="/docProps/custom\.xml"[^>]*/>',
"",
raw.decode("utf-8", errors="replace"),
)
if n:
actions.append(f"drop Content_Types custom.xml override x{n}")
raw = new.encode("utf-8")
# Layer A over the visible body: headers/footers/footnotes included.
if also_layer_a_text and name.startswith("word/") and name.endswith(".xml"):
text = raw.decode("utf-8", errors="replace")