diff --git a/tests/inbox_frontend.py b/tests/inbox_frontend.py index a12f662..83908a8 100644 --- a/tests/inbox_frontend.py +++ b/tests/inbox_frontend.py @@ -150,6 +150,38 @@ def _extract_docx_text(fs_path) -> str: return "\n".join(parts) +def _shape_alt_text(shape) -> str: + """Read a shape's alt-text (cNvPr@descr). python-pptx 1.0.2 has no stable + public accessor across shape types, so read it off the XML directly.""" + for element in shape._element.iter(): + if element.tag.endswith("}cNvPr"): + return element.get("descr") or "" + return "" + + +def _extract_pptx_text(fs_path) -> str: + """Extract text from a ``.pptx``, including the regions an audience watching + the slides does not see: speaker notes, off-slide (off-canvas) text boxes, and + image/shape alt-text. ``python-pptx`` is imported lazily (dev/showcase-scoped). + """ + from pptx import Presentation + + prs = Presentation(str(fs_path)) + parts: list = [] + for slide in prs.slides: + for shape in slide.shapes: + if shape.has_text_frame and shape.text_frame.text: + parts.append(shape.text_frame.text) # incl. off-slide boxes + alt = _shape_alt_text(shape) + if alt: + parts.append(alt) + if slide.has_notes_slide: + notes = slide.notes_slide.notes_text_frame.text + if notes: + parts.append(notes) + return "\n".join(parts) + + def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict): """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.""" @@ -159,6 +191,8 @@ def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, str _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 == ".pptx": + _materialize_text(rel_name, _extract_pptx_text(fs_path), "pptx", bundle, provenance) elif suffix in _TEXT_SUFFIXES: text = Path(fs_path).read_text(encoding="utf-8", errors="replace") _materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance) diff --git a/tests/test_okf_inbox_uploads.py b/tests/test_okf_inbox_uploads.py index 09031f8..bf6d4f9 100644 --- a/tests/test_okf_inbox_uploads.py +++ b/tests/test_okf_inbox_uploads.py @@ -284,3 +284,82 @@ def test_docx_hidden_run_detach_proof(tmp_path): fp = _make_docx(tmp_path, body="A normal paragraph.") _extracted, _result, verdict = receive([fp]) assert verdict == "ADMIT" + + +# --- slice 2e: .pptx (python-pptx) ------------------------------------------ +# The payload hides where an audience watching the slides does not look: speaker +# notes, an off-slide text box, or an image's alt-text. The extractor surfaces +# all three so the stage-2 scan catches them. + + +def _set_alt_text(shape, text): + # alt-text lives on the shape's non-visual props (cNvPr@descr); python-pptx + # 1.0.2 has no stable public accessor across shape types, so set it on the XML. + for el in shape._element.iter(): + if el.tag.endswith("}cNvPr"): + el.set("descr", text) + return + + +def _make_pptx(tmp_path, name="deck.pptx", *, body=None, notes=None, offslide=None, alt=None): + import io + + from pptx import Presentation + from pptx.util import Emu + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank + if body is not None: + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(3000000), Emu(500000)) + tb.text_frame.text = body + if offslide is not None: + tb = slide.shapes.add_textbox(Emu(-3000000), Emu(0), Emu(1000000), Emu(400000)) + tb.text_frame.text = offslide # positioned off the canvas + if alt is not None: + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (2, 2), (255, 255, 255)).save(buf, "PNG") + buf.seek(0) + pic = slide.shapes.add_picture(buf, Emu(0), Emu(0), Emu(500000), Emu(500000)) + _set_alt_text(pic, alt) + if notes is not None: + slide.notes_slide.notes_text_frame.text = notes + fp = tmp_path / name + prs.save(str(fp)) + return fp + + +def test_pptx_speaker_notes_injection_is_caught(tmp_path): + fp = _make_pptx(tmp_path, body="Slide one.", notes=_INJECTION) + extracted, result, verdict = receive([fp]) + assert extracted.provenance[0].source_type == "pptx" + assert result.disposition is Disposition.FAIL_SECURE + assert verdict == "REJECT" + + +def test_pptx_offslide_textbox_injection_is_caught(tmp_path): + fp = _make_pptx(tmp_path, body="Slide one.", offslide=_INJECTION) + _extracted, _result, verdict = receive([fp]) + assert verdict == "REJECT" + + +def test_pptx_image_alt_text_injection_is_caught(tmp_path): + fp = _make_pptx(tmp_path, body="Slide one.", alt=_INJECTION) + _extracted, _result, verdict = receive([fp]) + assert verdict == "REJECT" + + +def test_clean_pptx_admits(tmp_path): + fp = _make_pptx(tmp_path, name="clean.pptx", body="A routine slide. No behavior change.") + extracted, _result, verdict = receive([fp]) + assert "uploads/clean.md" in extracted.bundle + assert verdict == "ADMIT" + + +def test_pptx_notes_detach_proof(tmp_path): + # Same visible slide, no speaker notes -> ADMIT: the notes region is what + # caught it, not merely "a pptx". + fp = _make_pptx(tmp_path, body="A routine slide.") + _extracted, _result, verdict = receive([fp]) + assert verdict == "ADMIT"