feat(inbox): .xlsx extraction — formula gate, hidden sheets, cell comments (stage 2h)
This commit is contained in:
parent
a46a96db0a
commit
ca26e117ea
3 changed files with 132 additions and 1 deletions
|
|
@ -27,7 +27,7 @@ judge = [] # LLM-judge / source-grounding implementation (placeholder)
|
|||
# §247). Deliberately in `dev`, NOT the core `dependencies` (which stays []) and
|
||||
# NOT a public `[extract]` extra — the front-end is an in-repo demonstration, not
|
||||
# v1 shipped code. They pull lxml/Pillow transitively; that footprint is dev-only.
|
||||
dev = ["pytest>=8", "python-docx>=1.2", "python-pptx>=1.0"]
|
||||
dev = ["pytest>=8", "python-docx>=1.2", "python-pptx>=1.0", "openpyxl>=3.1"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/llm_ingestion_guard"]
|
||||
|
|
|
|||
|
|
@ -199,6 +199,51 @@ def _extract_pptx_text(fs_path) -> str:
|
|||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_xlsx(fs_path):
|
||||
"""Extract text from an ``.xlsx``, including the regions a human reading the
|
||||
workbook in Excel does not see: cells on a *hidden* sheet (still worksheets, so
|
||||
iterated) and cell comments. Returns ``(text, formula_cells)`` — the text is
|
||||
materialized as the concept body (so a hidden-sheet / comment injection rides
|
||||
into the stage-2 scan) and ``formula_cells`` holds the formula-lead cells the
|
||||
front-end refuses (a spreadsheet threat, RCE/DDE when a human opens the file).
|
||||
|
||||
``openpyxl`` is imported lazily — a dev/showcase-scoped parser, not a core
|
||||
dependency. It reads formulas as their string (default ``data_only=False``).
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(str(fs_path))
|
||||
parts: list = []
|
||||
formula_cells: list = []
|
||||
for ws in wb.worksheets: # includes hidden / very-hidden sheets
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
value = cell.value
|
||||
if value is not None and value != "":
|
||||
parts.append(str(value))
|
||||
# Only a genuine text/formula cell can carry the injection lead;
|
||||
# a numeric cell is typed (int/float) by openpyxl, so a negative
|
||||
# number never trips the gate — unlike CSV, where every cell is
|
||||
# text and a leading '-'/'+' is the accepted false-positive.
|
||||
if isinstance(value, str) and _is_formula_cell(value):
|
||||
formula_cells.append(value)
|
||||
comment = cell.comment
|
||||
if comment is not None and comment.text:
|
||||
parts.append(comment.text)
|
||||
return "\n".join(parts), formula_cells
|
||||
|
||||
|
||||
def _ingest_xlsx(rel_name, fs_path, bundle, provenance, rejected):
|
||||
"""Materialize an ``.xlsx`` (all sheets incl. hidden, + cell comments) as a
|
||||
concept and flag formula-injection cells — mirrors :func:`_ingest_csv`."""
|
||||
text, formula_cells = _extract_xlsx(fs_path)
|
||||
if formula_cells:
|
||||
rejected.append(
|
||||
(rel_name, f"XLSX formula-injection lead in {len(formula_cells)} cell(s): {formula_cells[0][:24]!r}")
|
||||
)
|
||||
_materialize_text(rel_name, text, "xlsx", bundle, provenance)
|
||||
|
||||
|
||||
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."""
|
||||
|
|
@ -210,6 +255,8 @@ def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, str
|
|||
_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 == ".xlsx":
|
||||
_ingest_xlsx(rel_name, fs_path, bundle, provenance, rejected)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -422,3 +422,87 @@ def test_clean_pptx_grouped_shape_admits(tmp_path):
|
|||
fp = _pptx_with_grouped_text(tmp_path, "clean grouped text")
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
# --- slice 2h: .xlsx (openpyxl) ---------------------------------------------
|
||||
# Three planted regions (PLAN §247). A formula-lead cell (=cmd|'…', =HYPERLINK)
|
||||
# is a spreadsheet threat the guard would not recognize, so the front-end refuses
|
||||
# it (mirrors .csv). A hidden sheet and a cell comment hide injection text where a
|
||||
# human reading the workbook does not look; the extractor surfaces both so the
|
||||
# stage-2 scan catches them. openpyxl reads formulas as their string, iterates
|
||||
# hidden sheets, and exposes cell comments (verified empirically).
|
||||
|
||||
|
||||
def _make_xlsx(tmp_path, name="book.xlsx", *, cell="A normal value",
|
||||
formula=None, hidden_sheet=None, comment=None):
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.comments import Comment
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = cell
|
||||
if formula is not None:
|
||||
ws["A2"] = formula # leading '=' -> stored as a formula
|
||||
if comment is not None:
|
||||
ws["A1"].comment = Comment(comment, "m")
|
||||
if hidden_sheet is not None:
|
||||
hs = wb.create_sheet("secret")
|
||||
hs.sheet_state = "hidden" # invisible tab in Excel
|
||||
hs["A1"] = hidden_sheet
|
||||
fp = tmp_path / name
|
||||
wb.save(str(fp))
|
||||
return fp
|
||||
|
||||
|
||||
def test_xlsx_formula_injection_cell_is_flagged(tmp_path):
|
||||
fp = _make_xlsx(tmp_path, name="data.xlsx", formula="=cmd|'/c calc'!A1")
|
||||
extracted, _result, verdict = receive([fp])
|
||||
assert extracted.provenance[0].source_type == "xlsx"
|
||||
assert any("data.xlsx" in n for n, _r in extracted.rejected)
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_xlsx_hyperlink_formula_is_flagged(tmp_path):
|
||||
fp = _make_xlsx(tmp_path, formula="=HYPERLINK('http://evil')")
|
||||
extracted, _result, verdict = receive([fp])
|
||||
assert extracted.rejected != ()
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_xlsx_hidden_sheet_injection_is_caught(tmp_path):
|
||||
fp = _make_xlsx(tmp_path, hidden_sheet=_INJECTION)
|
||||
_extracted, result, verdict = receive([fp])
|
||||
assert result.disposition is Disposition.FAIL_SECURE # caught by the guard
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_xlsx_cell_comment_injection_is_caught(tmp_path):
|
||||
fp = _make_xlsx(tmp_path, comment=_INJECTION)
|
||||
_extracted, result, verdict = receive([fp])
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_clean_xlsx_admits(tmp_path):
|
||||
fp = _make_xlsx(tmp_path, name="clean.xlsx", cell="A routine value. No behavior change.")
|
||||
extracted, _result, verdict = receive([fp])
|
||||
assert "uploads/clean.md" in extracted.bundle
|
||||
assert extracted.rejected == ()
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
def test_xlsx_formula_detach_proof(tmp_path):
|
||||
# The same workbook with a plain cell (no formula lead) has no flag -> ADMIT,
|
||||
# so the flag is the formula content, not the .xlsx suffix or the filename.
|
||||
fp = _make_xlsx(tmp_path, name="data.xlsx", formula="calc")
|
||||
extracted, _result, verdict = receive([fp])
|
||||
assert extracted.rejected == ()
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
def test_xlsx_hidden_sheet_detach_proof(tmp_path):
|
||||
# Same visible cell, no hidden sheet -> ADMIT: it is the extractor surfacing the
|
||||
# hidden-sheet region that caught it, not merely "an xlsx".
|
||||
fp = _make_xlsx(tmp_path, cell="A routine value.")
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "ADMIT"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue