feat(inbox): .docx extraction — hidden runs, comments, core metadata (stage 2d)
The docx payload hides where a human reviewing the file in Word does not look. The extractor surfaces all three regions into the concept text so the stage-2 scan catches the injection: - hidden/vanish runs (w:vanish) — still runs, so paragraph.text includes them; - review comments (doc.comments[].text); - core metadata properties (subject/keywords/comments/title/category/author). Detach-proof: the same visible body WITHOUT the hidden run ADMITs, so it is the extractor surfacing the hidden region that caught it, not merely 'a docx'. python-docx is imported lazily (dev/showcase-scoped, not a core dep). Tests 300 -> 305.
This commit is contained in:
parent
02d59efeb2
commit
26a231d6c4
2 changed files with 88 additions and 0 deletions
|
|
@ -124,6 +124,32 @@ def _ingest_csv(rel_name, text, bundle, provenance, rejected):
|
||||||
_materialize_text(rel_name, text, "csv", bundle, provenance)
|
_materialize_text(rel_name, text, "csv", bundle, provenance)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx_text(fs_path) -> str:
|
||||||
|
"""Extract text from a ``.docx``, including the regions a human reviewing the
|
||||||
|
document in Word does not see: hidden/vanish runs (still runs, so ``paragraph
|
||||||
|
.text`` includes them), core metadata properties, and review comments.
|
||||||
|
|
||||||
|
``python-docx`` is imported lazily — it is a dev/showcase-scoped parser, not a
|
||||||
|
core dependency; a consumer would guard the import behind their own extra.
|
||||||
|
"""
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
doc = Document(str(fs_path))
|
||||||
|
parts = [para.text for para in doc.paragraphs if para.text]
|
||||||
|
|
||||||
|
cp = doc.core_properties
|
||||||
|
for attr in ("title", "subject", "keywords", "comments", "category", "author"):
|
||||||
|
value = getattr(cp, attr, None)
|
||||||
|
if value:
|
||||||
|
parts.append(str(value))
|
||||||
|
|
||||||
|
for comment in doc.comments:
|
||||||
|
if comment.text:
|
||||||
|
parts.append(comment.text)
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict):
|
def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict):
|
||||||
"""Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported
|
"""Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported
|
||||||
suffix (a top-level drop); a folder walk passes ``strict=False`` to skip it."""
|
suffix (a top-level drop); a folder walk passes ``strict=False`` to skip it."""
|
||||||
|
|
@ -131,6 +157,8 @@ def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, str
|
||||||
if suffix == ".csv":
|
if suffix == ".csv":
|
||||||
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
||||||
_ingest_csv(rel_name, text, bundle, provenance, rejected)
|
_ingest_csv(rel_name, text, bundle, provenance, rejected)
|
||||||
|
elif suffix == ".docx":
|
||||||
|
_materialize_text(rel_name, _extract_docx_text(fs_path), "docx", bundle, provenance)
|
||||||
elif suffix in _TEXT_SUFFIXES:
|
elif suffix in _TEXT_SUFFIXES:
|
||||||
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
||||||
_materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance)
|
_materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance)
|
||||||
|
|
|
||||||
|
|
@ -224,3 +224,63 @@ def test_clean_folder_admits(tmp_path):
|
||||||
extracted, _result, verdict = receive([folder])
|
extracted, _result, verdict = receive([folder])
|
||||||
assert set(extracted.bundle) == {"uploads/tables/users.md", "uploads/notes.md"}
|
assert set(extracted.bundle) == {"uploads/tables/users.md", "uploads/notes.md"}
|
||||||
assert verdict == "ADMIT"
|
assert verdict == "ADMIT"
|
||||||
|
|
||||||
|
|
||||||
|
# --- slice 2d: .docx (python-docx) ------------------------------------------
|
||||||
|
# The payload hides where a human reviewing the doc in Word does not look: a
|
||||||
|
# hidden (vanish) run, a review comment, or a core metadata property. The
|
||||||
|
# extractor must surface all three regions so the stage-2 scan catches them.
|
||||||
|
|
||||||
|
|
||||||
|
def _make_docx(tmp_path, name="doc.docx", *, body="A normal paragraph.",
|
||||||
|
hidden=None, comment=None, subject=None):
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
d = Document()
|
||||||
|
p = d.add_paragraph()
|
||||||
|
p.add_run(body)
|
||||||
|
if hidden is not None:
|
||||||
|
run = p.add_run(hidden)
|
||||||
|
run.font.hidden = True # w:vanish — invisible in Word
|
||||||
|
if subject is not None:
|
||||||
|
d.core_properties.subject = subject # core metadata property
|
||||||
|
if comment is not None:
|
||||||
|
d.add_comment(runs=p.runs, text=comment, author="m", initials="m")
|
||||||
|
fp = tmp_path / name
|
||||||
|
d.save(str(fp))
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_hidden_run_injection_is_caught(tmp_path):
|
||||||
|
fp = _make_docx(tmp_path, hidden=_INJECTION)
|
||||||
|
extracted, result, verdict = receive([fp])
|
||||||
|
assert extracted.provenance[0].source_type == "docx"
|
||||||
|
assert result.disposition is Disposition.FAIL_SECURE
|
||||||
|
assert verdict == "REJECT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_comment_injection_is_caught(tmp_path):
|
||||||
|
fp = _make_docx(tmp_path, comment=_INJECTION)
|
||||||
|
_extracted, _result, verdict = receive([fp])
|
||||||
|
assert verdict == "REJECT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_core_metadata_injection_is_caught(tmp_path):
|
||||||
|
fp = _make_docx(tmp_path, subject=_INJECTION)
|
||||||
|
_extracted, _result, verdict = receive([fp])
|
||||||
|
assert verdict == "REJECT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_docx_admits(tmp_path):
|
||||||
|
fp = _make_docx(tmp_path, name="clean.docx", body="A routine paragraph. No behavior change.")
|
||||||
|
extracted, _result, verdict = receive([fp])
|
||||||
|
assert "uploads/clean.md" in extracted.bundle
|
||||||
|
assert verdict == "ADMIT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_hidden_run_detach_proof(tmp_path):
|
||||||
|
# The same visible body WITHOUT the hidden run ADMITs, proving it is the
|
||||||
|
# extractor surfacing the hidden region that caught it (not just "a docx").
|
||||||
|
fp = _make_docx(tmp_path, body="A normal paragraph.")
|
||||||
|
_extracted, _result, verdict = receive([fp])
|
||||||
|
assert verdict == "ADMIT"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue