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[^>]*>)(.*?)({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']*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}>{field}>" in core or f"<{field}/>" in core
+ for field in ("Application", "AppVersion", "Company", "Manager"):
+ assert f"<{field}>{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)