Skip to content

Security patch: address all High findings for v0.1.2 - #4

Merged
comppaz merged 13 commits into
mainfrom
fix/security-high-0.1.2
Apr 27, 2026
Merged

Security patch: address all High findings for v0.1.2#4
comppaz merged 13 commits into
mainfrom
fix/security-high-0.1.2

Conversation

@comppaz

@comppaz comppaz commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Resolves all 11 High-severity findings from the internal 0.1.1 security review so we can cut v0.1.2 as a security patch.

Findings addressed

ID Area Fix
NDS-004 namespace Fernet key creation closes TOCTOU via O_CREAT | O_EXCL and 0700 dir mode
NDS-009 namespace Reject path-traversal namespace names (^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$)
NDS-014 extractor OCR refuses decompression-bomb images (50 MP cap, scoped to call)
NDS-015 extractor DOCX/XLSX zip-bomb pre-flight + defusedxml baseline dep
NDS-016 extractor DOCX walk now covers headers, footers, and comments on extract + reconstruct
NDS-017 extractor Surface PDF /Info metadata so detectors pseudonymize embedded PII
NDS-023 ensemble Surface per-detector failures via structured warning log
NDS-027 daemon Bounded socket buffers (32 MB) + per-field Pydantic length caps
NDS-028 daemon handle_redact enforces same-UID ownership on input + output parent
NDS-033 cli ns show requires --unsafe; points users at ns summary
NDS-034 cli Canonicalize -o / --output-dir; refuse writes into namespace store

Plus a follow-up to NDS-014 scoping Image.MAX_IMAGE_PIXELS per call so it cannot leak into other PIL consumers.

CHANGELOG cut for 0.1.2 in the final commit.

Test plan

  • pytest -m "not slow" — 254 passed, 2 skipped, 28 deselected (matches CI selector in .github/workflows/ci.yml)
  • Per-finding regression tests added under tests/file_analysis/, tests/daemon/, tests/test_namespace.py, tests/test_cli.py, tests/test_ensemble.py
  • CI green on the PR
  • Tag v0.1.2 after merge to trigger hatch-vcs version + release workflow

comppaz added 13 commits April 27, 2026 15:25
Set Pillow MAX_IMAGE_PIXELS to ~50 MP and convert
DecompressionBombWarning to an error so a malicious
image-block can never trigger huge memory allocations.
The dispatcher converts the raised ValueError into an
extraction_error and skips the block.
asyncio.gather(..., return_exceptions=True) silently dropped detector
exceptions; a spaCy/Flair/GLiNER load error would degrade the ensemble
to whatever still worked, with no signal. Replace with an explicit
per-detector wrapper that logs each failure under
detection.detector_failed so operators can detect the degraded state
and downstream PII may not silently leak.
A daemon peer could previously stream an unbounded line to wedge the
asyncio StreamReader into eating memory until OOM. Cap the read
buffer at 32 MB and reply with a structured bad_request when a peer
exceeds it. Add Pydantic max_length on every protocol string field so
a too-long path or text value is refused at the validation layer
before any work is queued.
Namespace names came straight from user input and were joined into the
namespace root with no validation. A name like '../../etc' or '/abs'
would silently escape the configured root. Whitelist names to
[A-Za-z0-9][A-Za-z0-9._-]{0,63} so traversal, separators, and shell
metacharacters all raise ValueError before any filesystem I/O.
The previous _ensure_key:
  - created the namespace dir with the user's umask, leaving
    intermediate dirs potentially world-readable
  - wrote the key with default mode then chmod'd 0600 (window where
    another UID could open the file)
  - check-then-write race could clobber a concurrently-created key

Replace with mkdir(mode=0o700) + explicit chmod, then atomic
os.open(O_CREAT|O_EXCL, 0o600). The key is born with the right mode
and a concurrent writer is detected by FileExistsError and re-read.
… (NDS-034)

-o and --output-dir flowed straight into Path joins. Two failure modes:
  - Resolved output could land outside the requested --output-dir
    (Path semantics mostly cover this, but the contract was implicit)
  - Nothing prevented writing inside DEFAULT_NAMESPACE_ROOT, so a
    crafted -o could clobber a Fernet key or mapper.enc and silently
    destroy reversibility for that namespace.

_guard_output_path now resolves both inputs, asserts is_relative_to the
requested dir, and refuses anything inside DEFAULT_NAMESPACE_ROOT. The
basename for derived names is taken from input_path.name only so a
crafted relative input cannot route the output elsewhere.
ns show printed the entire pseudonym→original map. The README's
command table called it a "summary", and any wrapper, transcript, or
log capture immediately defeated redaction for that namespace. Refuse
to run without --unsafe and point users at ns summary for the safe
counts-only view. Update the README table accordingly.
The redact handler accepted any input.path and output_path string from
the socket peer. Cross-user is blocked by the 0o600 socket inside a
0o700 dir, but any same-UID process able to talk to the socket could
turn the daemon into a confused-deputy primitive: read any
user-readable file (and exfil through the redacted output), or
overwrite any user-writable file with the redacted bytes of an
arbitrary input.

Resolve both paths and stat them against os.getuid() before doing any
I/O. Refuse with ERR_BAD_REQUEST if the input file or the output
parent dir is not owned by the daemon's UID. Add ValueError to the
bad_request branch in _dispatch so a refused request gets a
structured response instead of the generic internal error code.
…017)

PDF documents commonly carry the same names, titles, and addresses in
their Info dictionary (Author, Title, Subject, Creator, Producer,
Keywords) that the body does. Without extraction those entries never
reach the detector ensemble, so redacted PDFs leaked the originals
while pretending the body was clean.

extract_pdf and extract_pdf_with_ocr_fallback now read the Info dict
via pypdfium2.PdfDocument.get_metadata_value and prepend non-empty
fields to the page text. Detection treats them as ordinary text and
the PDF output path (plain-text fallback) carries the pseudonymized
versions instead of the originals.
DOCX and XLSX are zip envelopes around XML. python-docx already pins
its lxml parser with resolve_entities=False, but openpyxl only swaps
in defusedxml when the package is importable. We now declare
defusedxml as a baseline dependency so openpyxl's iterparse path
becomes entity-safe by default.

The zip envelope itself was unguarded. A new check_ooxml_zip_safe
helper runs before python-docx / openpyxl touches the bytes and
refuses archives whose central directory advertises more than 200 MB
uncompressed or a compression ratio above 100x — the standard zip
bomb shapes. Wired into extract_docx, extract_xlsx, and the smart
xlsx inference path.
… NDS-014)

The decompression-bomb guard mutated PIL's module-global
MAX_IMAGE_PIXELS without restoring it, leaking the lowered cap into
later code paths (notably PDF rendering). Wrap the assignment in a
try/finally so each call restores the previous value.
extract_docx previously only saw paragraphs and table cells in the
document body. Section headers, footers (default + first-page +
even-page variants), and review comments routinely carry the same
PII the body does — the detector ensemble never saw it, so it
survived into the redacted output untouched.

Both extract_docx and _reconstruct_docx now walk those surfaces.
PII embedded in headers/footers/comments now reaches detection on
the way in and gets pseudonymized in the output bytes on the way
out.

Footnotes, endnotes, and tracked-change deletions remain known
gaps; the standard mitigation ("Accept all changes" before
redacting) is documented in the README caveats.
11 High-severity findings from the internal 0.1.1 security review
are addressed in this release: PDF metadata leak (NDS-017), DOCX
header/footer/comment coverage (NDS-016), OOXML zip-bomb defense
and defusedxml (NDS-015), OCR decompression-bomb guard (NDS-014),
detector failure surfacing (NDS-023), daemon protocol size limits
(NDS-027), daemon path-trust check (NDS-028), namespace name
validation (NDS-009), Fernet key TOCTOU + 0700 dir mode (NDS-004),
CLI output-path traversal guard (NDS-034), and ns-show --unsafe
gate (NDS-033).
@comppaz
comppaz merged commit 06e4e4e into main Apr 27, 2026
3 checks passed
@comppaz
comppaz deleted the fix/security-high-0.1.2 branch April 27, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant