llm-ingestion-pipeline-secu.../tests/inbox_frontend.py
Kjell Tore Guttormsen d8a95e465f feat(inbox): .pptx extraction — speaker notes, off-slide box, alt-text (stage 2e)
The pptx payload hides where an audience watching the slides does not look. The
extractor surfaces all three regions into the concept text so the stage-2 scan
catches the injection:

- speaker notes (slide.notes_slide.notes_text_frame.text);
- off-slide (off-canvas) text boxes (shape.text_frame.text, position-agnostic);
- image/shape alt-text (cNvPr@descr, read off the XML — python-pptx 1.0.2 has no
  stable public accessor across shape types).

Detach-proof: same visible slide without notes ADMITs. python-pptx imported
lazily (dev/showcase-scoped, not a core dep). Tests 305 -> 310.
2026-07-06 11:16:59 +02:00

292 lines
12 KiB
Python

"""OKF inbox front-end — stage 1 of the two-stage upload showcase (PLAN §247).
Reads the files a human actually drops into an inbox and *materializes* them into
an OKF bundle ``{concept_path: document_text}`` with provenance, so the stage-2
guard (:func:`llm_ingestion_guard.okf.import_bundle`) can validate every concept.
This stage owns the container/format threats; the guard owns the text/structural
threats.
**Placement.** This lives in the test tree, not ``src/``: the extraction parsers
are showcase/dev-scoped (``python-docx``/``python-pptx`` in the ``dev`` extra,
never core ``dependencies``), and the shippable core stays stdlib-only. It is an
in-repo demonstration a consumer reads and adapts, not v1 shipped code.
Slice 2a covers the text formats — ``.txt`` and ``.md`` (stdlib only). ``.zip``
(zip-slip / zip-bomb), ``.csv`` (formula injection), folders, ``.docx`` and
``.pptx`` land in later slices.
"""
from __future__ import annotations
import csv
import io
import stat
import zipfile
from dataclasses import dataclass
from pathlib import Path
from llm_ingestion_guard.okf import import_bundle, Origin, Channel, BundleResult
from llm_ingestion_guard.disposition import Disposition
# Aggregate disposition -> inbox verdict. Fail-secure by default: a front-end
# refusal (a container threat the guard never sees) is itself a REJECT.
_VERDICT = {
Disposition.WARN: "ADMIT",
Disposition.QUARANTINE_REVIEW: "HOLD",
Disposition.FAIL_SECURE: "REJECT",
}
# Where materialized uploads live inside the bundle. An upload named ``index.*``
# thus lands on the reserved ``uploads/index.md`` and is refused by the path gate.
_MATERIALIZE_PREFIX = "uploads"
# The text formats this slice reads directly (no parser dependency).
_TEXT_SUFFIXES = {".txt", ".md"}
# CSV cells leading with any of these parse as a formula in a spreadsheet — the
# CSV-injection / DDE vector (RCE when a human opens the file). Leading whitespace
# does not defuse it, so it is stripped before the check. Numeric cells that lead
# with '-'/'+' are the accepted false-positive (README honest-limits).
_FORMULA_LEADS = ("=", "+", "-", "@")
# Zip self-safety caps (OWASP LLM10). Bounded so a decompression bomb is refused
# before its uncompressed bytes are read into memory. Defaults are generous for a
# document inbox; tests pass small caps to exercise the gate.
MAX_ENTRY_BYTES = 5_000_000 # per uncompressed entry
MAX_TOTAL_BYTES = 25_000_000 # per archive, summed across entries
@dataclass(frozen=True)
class Provenance:
"""Where one materialized concept came from — the audit record (brief §6)."""
concept_path: str
source_name: str
source_type: str
@dataclass(frozen=True)
class InboxExtract:
"""The stage-1 output: the OKF bundle, its provenance, and front-end refusals.
``rejected`` holds ``(source_name, reason)`` for drops the front-end refuses
outright (a container threat the guard never gets to see, e.g. a zip bomb).
Empty for the text-format slice.
"""
bundle: dict
provenance: tuple
rejected: tuple
def _materialize_path(rel_name: str) -> str:
"""Assign an OKF concept path to a dropped file: ``<prefix>/<rel>.md``.
The relative name is preserved verbatim, including any ``..`` — a zip-slip
entry (``../../evil.md``) thus lands on a traversal concept path that the
stage-2 path gate (T4) rejects, rather than being silently normalized away.
"""
rel = Path(rel_name).with_suffix(".md").as_posix()
return f"{_MATERIALIZE_PREFIX}/{rel}"
def _is_symlink_entry(info: zipfile.ZipInfo) -> bool:
"""True if a zip entry encodes a Unix symlink (mode bits in external_attr)."""
return stat.S_ISLNK(info.external_attr >> 16)
def _materialize_text(rel_name, text, source_type, bundle, provenance):
"""Add one text concept to the bundle under the materialize prefix."""
concept_path = _materialize_path(rel_name)
bundle[concept_path] = text
provenance.append(Provenance(concept_path, rel_name, source_type))
def _is_formula_cell(cell: str) -> bool:
stripped = cell.lstrip("\t\r\n ")
return bool(stripped) and stripped[0] in _FORMULA_LEADS
def _ingest_csv(rel_name, text, bundle, provenance, rejected):
"""Materialize CSV cell text as a concept and flag formula-injection cells.
The raw cell text becomes the concept body so a prompt-injection *phrase* in
a cell is caught by the stage-2 scan; formula-lead cells are a spreadsheet
threat the guard would not recognize, so the front-end refuses them here.
"""
formula_cells = [
cell for row in csv.reader(io.StringIO(text)) for cell in row if _is_formula_cell(cell)
]
if formula_cells:
rejected.append(
(rel_name, f"CSV formula-injection lead in {len(formula_cells)} cell(s): {formula_cells[0][:24]!r}")
)
_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 _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."""
suffix = Path(rel_name).suffix.lower()
if suffix == ".csv":
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
_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)
elif strict:
raise ValueError(f"unsupported upload format in this slice: {Path(rel_name).name!r}")
def _extract_folder(root, bundle, provenance, rejected):
"""Walk a dropped folder, materializing its text/CSV members (relative paths
preserved, so a reserved-name member trips the guard's path gate)."""
root = Path(root)
for fs_path in sorted(root.rglob("*")):
if fs_path.is_symlink():
rejected.append((str(fs_path.relative_to(root)), "symlink refused (container threat)"))
continue
if not fs_path.is_file():
continue
rel_name = fs_path.relative_to(root).as_posix()
_ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, strict=False)
def _extract_zip(path, bundle, provenance, rejected, max_entry_bytes, max_total_bytes):
"""Read a ``.zip`` in memory, materializing its text entries; refuse bombs,
symlinks and oversize entries at the front-end (container threats)."""
total = 0
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
name = info.filename
if name.endswith("/"):
continue # directory entry — no content
if _is_symlink_entry(info):
rejected.append((name, "symlink entry refused (container threat)"))
continue
# Fast reject on the declared uncompressed size (a bomb, before reading).
if info.file_size > max_entry_bytes:
rejected.append((name, f"entry exceeds {max_entry_bytes}-byte cap (declared {info.file_size})"))
continue
if total + info.file_size > max_total_bytes:
rejected.append((name, f"archive exceeds {max_total_bytes}-byte total cap"))
continue
if Path(name).suffix.lower() not in _TEXT_SUFFIXES:
continue # only text concepts are materialized in this slice
# Bounded read defends against a header that lies about file_size.
with zf.open(info) as f:
data = f.read(max_entry_bytes + 1)
if len(data) > max_entry_bytes:
rejected.append((name, f"entry expands past {max_entry_bytes}-byte cap on read"))
continue
total += len(data)
concept_path = _materialize_path(name)
bundle[concept_path] = data.decode("utf-8", errors="replace")
provenance.append(Provenance(concept_path, name, "zip"))
def extract_inbox(
paths,
*,
max_entry_bytes: int = MAX_ENTRY_BYTES,
max_total_bytes: int = MAX_TOTAL_BYTES,
) -> InboxExtract:
"""Read dropped files and materialize them into an OKF bundle + provenance.
``paths`` is an iterable of file/folder paths. Each ``.txt`` / ``.md`` becomes
one concept (a ``.md`` keeps its OKF frontmatter verbatim); a ``.csv`` is
materialized and its formula-lead cells refused; a ``.zip`` is read in memory
and its text entries materialized, with bomb/symlink/oversize entries refused;
a folder is walked member-by-member. Refusals land in ``InboxExtract.rejected``.
"""
bundle: dict = {}
provenance: list = []
rejected: list = []
for path in paths:
path = Path(path)
if path.is_dir():
_extract_folder(path, bundle, provenance, rejected)
elif path.suffix.lower() == ".zip":
_extract_zip(path, bundle, provenance, rejected, max_entry_bytes, max_total_bytes)
else:
_ingest_regular_file(path, path.name, bundle, provenance, rejected, strict=True)
return InboxExtract(bundle, tuple(provenance), tuple(rejected))
def receive(
paths,
*,
max_entry_bytes: int = MAX_ENTRY_BYTES,
max_total_bytes: int = MAX_TOTAL_BYTES,
) -> tuple[InboxExtract, BundleResult, str]:
"""The full two-stage inbox: extract & materialize, then guard, then verdict.
Returns ``(extracted, guard_result, verdict)``. A front-end refusal forces a
REJECT regardless of the guard's aggregate — the guard never saw that drop.
"""
extracted = extract_inbox(paths, max_entry_bytes=max_entry_bytes, max_total_bytes=max_total_bytes)
result = import_bundle(extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)
verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition]
return extracted, result, verdict