Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<<TYPE_N>>` 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
Expand Down
26 changes: 26 additions & 0 deletions src/noirdoc/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions tests/test_sdk_xlsx.py
Original file line number Diff line number Diff line change
@@ -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("<<PERSON")
assert out.cell(3, 1).value.startswith("<<PERSON")
assert out.cell(2, 2).value.startswith("<<EMAIL")
assert out.cell(3, 2).value.startswith("<<EMAIL")
# Non-classified column passes through untouched.
assert out.cell(2, 3).value == "leave alone"
assert out.cell(3, 3).value == "also untouched"


def test_reveal_file_xlsx_roundtrips(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")
r.redact_file(src, output=dst)
revealed = r.reveal_file(dst)

assert revealed is not None
out = load_workbook(io.BytesIO(revealed))["Sheet1"]
assert out.cell(2, 1).value == "Anna Müller"
assert out.cell(2, 2).value == "anna@example.com"
assert out.cell(3, 1).value == "Ben Schulz"
assert out.cell(3, 2).value == "ben@example.com"
Loading