From 737eaa327ca90ce27eb6d2458d4ae0046744d7da Mon Sep 17 00:00:00 2001 From: "Guillaume Meyer (The Opinionated Man)" <1385518+guillaumemeyer@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:32:12 -0700 Subject: [PATCH] fix: always empty DOCX docProps provenance fields (#76) (#83) --- service/scripts/container_meta.py | 88 +++++++++++++--------------- tests/test_container_meta.py | 97 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 46 deletions(-) diff --git a/service/scripts/container_meta.py b/service/scripts/container_meta.py index 35f30e5..8902c45 100644 --- a/service/scripts/container_meta.py +++ b/service/scripts/container_meta.py @@ -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"(]*>)(.*?)()", - None, - "dc:creator", - ), - ( - r"(]*>)(.*?)()", - None, - "cp:lastModifiedBy", - ), - ( - r"(]*>)(.*?)()", - None, - "Application", - ), - ( - r"(]*>)(.*?)()", - 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[^>]*>)(.*?)()" + + 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']*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") diff --git a/tests/test_container_meta.py b/tests/test_container_meta.py index 76f3f45..77e674a 100644 --- a/tests/test_container_meta.py +++ b/tests/test_container_meta.py @@ -334,6 +334,57 @@ def test_docx_metadata_vendor_word_is_still_flagged(): assert any("Claude" in f for f in findings) +def _make_docx_with_docprops() -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr( + "[Content_Types].xml", + """ + + + + + + +""", + ) + zf.writestr( + "word/document.xml", + 'Hello', + ) + zf.writestr( + "docProps/core.xml", + """ + + My Document + ChatGPT + Claude + Generated by AI + ai, model + artificial intelligence + report +""", + ) + zf.writestr( + "docProps/app.xml", + """ + + ChatGPT + 16.0 + OpenAI + Someone +""", + ) + zf.writestr( + "docProps/custom.xml", + """ + + Acme +""", + ) + return buf.getvalue() + + def _make_docx_with_invisible_body() -> bytes: buf = io.BytesIO() document = ( @@ -362,6 +413,52 @@ def _make_docx_with_invisible_body() -> bytes: return buf.getvalue() +def test_docx_scrubs_docprops_provenance_fields_unconditionally(): + import xml.etree.ElementTree as ET + + data = _make_docx_with_docprops() + cleaned, actions = clean_docx(data) + + with zipfile.ZipFile(io.BytesIO(cleaned)) as zf: + names = zf.namelist() + assert "docProps/core.xml" in names + assert "docProps/app.xml" in names + assert "docProps/custom.xml" not in names + ct = zf.read("[Content_Types].xml").decode() + assert 'PartName="/docProps/custom.xml"' not in ct + + core = zf.read("docProps/core.xml").decode() + app = zf.read("docProps/app.xml").decode() + + # Every provenance field is emptied... + for field in ("dc:creator", "cp:lastModifiedBy", "dc:description", "cp:keywords", "dc:subject", "cp:category"): + assert f"<{field}>" in core or f"<{field}/>" in core + for field in ("Application", "AppVersion", "Company", "Manager"): + assert f"<{field}>" in app or f"<{field}/>" in app + # ...while dc:title survives + assert "My Document" in core + assert "ChatGPT" not in core + assert "Generated by AI" not in core + assert "OpenAI" not in app + + # The output docProps remain well-formed XML. + ET.fromstring(core) + ET.fromstring(app) + + assert any("scrub docProps/core.xml field dc:creator" in a for a in actions) + assert any("drop part docProps/custom.xml" in a for a in actions) + + +def test_docx_docprops_scrub_clears_residual_warning(tmp_path: Path): + src = tmp_path / "in.docx" + src.write_bytes(_make_docx_with_docprops()) + dest = tmp_path / "out.docx" + result = clean_container(src, dest) + assert result["format"] == "docx" + assert not result["still_has_ai_metadata"] + assert not any("docProps/core.xml" in f for f in result["post_findings"]) + + def test_docx_layer_a_strips_invisible_body_chars(): data = _make_docx_with_invisible_body() cleaned, actions = clean_docx(data)