diff --git a/CHANGELOG.md b/CHANGELOG.md index b28905a..1ce0b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.2] — 2026-04-27 Security patch covering all High-severity findings from the 0.1.1 -internal security review. Recommended upgrade for anyone running -0.1.x. +internal security review, plus an Excel-redaction quality fix that +restores parity between the CLI/SDK/daemon path and the noirdoc-cloud +proxy. Recommended upgrade for anyone running 0.1.x. ### Security - **PDF metadata leak.** PII embedded in a PDF's `/Info` dictionary @@ -59,6 +60,21 @@ internal security review. Recommended upgrade for anyone running command now exits with an error and points at `noirdoc ns summary` unless `--unsafe` is passed. +### Fixed +- **XLSX redaction quality regression.** `Redactor.redact_file` (used + by `noirdoc redact`, the Python SDK, and the daemon) flattened every + cell across every sheet into a single ` | `-joined string before + detection, then did substring `cell.value.replace()` on + reconstruction. Cell context was destroyed and many entities — short + surnames, locations, numerically-typed cells — were silently missed. + XLSX inputs now route through + `noirdoc.file_analysis.xlsx_inference.pseudonymize_xlsx_smart`, which + classifies columns by header keyword, samples the first rows for + unclassified columns, and writes per-cell `<>` pseudonyms via + `mapper.get_or_create()`. The reveal path was already cell-aware and + round-trips correctly. This was the same path the noirdoc-cloud proxy + has always used; the SDK simply wasn't wired up to it. + ## [0.1.1] — 2026-04-27 ### Added diff --git a/src/noirdoc/sdk.py b/src/noirdoc/sdk.py index 9adf6cd..a976cab 100644 --- a/src/noirdoc/sdk.py +++ b/src/noirdoc/sdk.py @@ -28,6 +28,8 @@ Policy = Literal["pseudonymize", "extract_only"] DetectorChoice = Literal["presidio", "gliner", "ensemble"] +_XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + class RedactionResult: """The result of redacting a single file.""" @@ -234,6 +236,30 @@ async def _redact_file_async(self, path: Path, language: str) -> RedactionResult if format_for_mime(mime) is None: raise ValueError(f"Unsupported MIME type for {path.name}: {mime}") + # XLSX uses a column-aware pipeline: header keyword classification + per-column NLP + # sampling + cell-level pseudonymization. The generic flat-text path destroys cell + # context and misses many entities — see xlsx_inference.pseudonymize_xlsx_smart. + if mime == _XLSX_MIME: + from noirdoc.file_analysis.xlsx_inference import pseudonymize_xlsx_smart + + detector = await self._ensure_detector() + xr = await pseudonymize_xlsx_smart( + content, + detector, + self._mapper, + language=language, + pseudonymize=True, + ) + self._persist() + return RedactionResult( + input_path=path, + output_bytes=xr.new_bytes if xr.new_bytes is not None else content, + entity_count=xr.entity_count, + entity_types=dict(xr.entity_types), + mime_type=mime, + reconstructed=True, + ) + block = FileBlock( source_path="sdk", source_type="file", diff --git a/tests/test_sdk_xlsx.py b/tests/test_sdk_xlsx.py new file mode 100644 index 0000000..630ae01 --- /dev/null +++ b/tests/test_sdk_xlsx.py @@ -0,0 +1,74 @@ +"""Regression test for XLSX redaction via the SDK. + +Confirms that ``Redactor.redact_file`` routes ``.xlsx`` through the +column-aware ``pseudonymize_xlsx_smart`` pipeline (header keyword +classification + per-cell pseudonyms) instead of the flat-text fallback, +which silently dropped most entities on real spreadsheets. +""" + +from __future__ import annotations + +import io + +import pytest + +pytestmark = pytest.mark.slow + + +def _build_workbook(path): + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = "Sheet1" + ws.append(["Name", "Email", "Notes"]) + ws.append(["Anna Müller", "anna@example.com", "leave alone"]) + ws.append(["Ben Schulz", "ben@example.com", "also untouched"]) + wb.save(path) + + +def test_redact_file_xlsx_uses_smart_pipeline(tmp_path): + from openpyxl import load_workbook + + from noirdoc.sdk import Redactor + + src = tmp_path / "in.xlsx" + dst = tmp_path / "out.xlsx" + _build_workbook(src) + + r = Redactor(detector="presidio", language="de") + result = r.redact_file(src, output=dst) + + assert result.reconstructed is True + assert result.mime_type.endswith("spreadsheetml.sheet") + assert result.entity_count >= 4 + + out = load_workbook(dst)["Sheet1"] + assert out.cell(2, 1).value.startswith("<