From ca26e117ea5a115a3a5c640414c5b73873e104df Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 7 Jul 2026 07:41:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(inbox):=20.xlsx=20extraction=20=E2=80=94?= =?UTF-8?q?=20formula=20gate,=20hidden=20sheets,=20cell=20comments=20(stag?= =?UTF-8?q?e=202h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- tests/inbox_frontend.py | 47 ++++++++++++++++++ tests/test_okf_inbox_uploads.py | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 419b422..267e457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/inbox_frontend.py b/tests/inbox_frontend.py index 5a85463..4580d61 100644 --- a/tests/inbox_frontend.py +++ b/tests/inbox_frontend.py @@ -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) diff --git a/tests/test_okf_inbox_uploads.py b/tests/test_okf_inbox_uploads.py index 7d38c66..4a02c16 100644 --- a/tests/test_okf_inbox_uploads.py +++ b/tests/test_okf_inbox_uploads.py @@ -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"