Story 1857: the ZIP-expansion worker — a bundle becomes N first-class documents without ever bypassing the malware scanner or escaping its own folder

Why this exists (the catastrophe it prevents). Counsel almost never uploads one clean PDF; they drop a discovery-bundle.zip of 300 files. "Unpack it and treat each file as a document" sounds trivial, and that is exactly why it is dangerous. A naive recursive unzip is two well-known exploits waiting to happen. Zip-slip: an entry named ../../../../etc/passwd (or, in our world, ../../firms/OTHER_FIRM/...) writes outside the document prefix — a cross-firm write that shatters tenant isolation. Zip-bomb: a 42 KB archive that inflates to 4.5 PB exhausts the worker's memory and DLQs the queue. And the subtler one: if the worker writes a child object anywhere the malware-results parser does not recognise, or hands the child straight to extraction, that child's bytes were never scanned — the upload pipeline's whole scan-before-extract guarantee is silently bypassed for every file inside a ZIP.

The fix is three guarantees, all enforced one entry at a time so the archive is never inflated whole into the worker's bounded memory. (1) Every entry name is collapsed to a traversal-free basename by the same _safe_filename the upload path already uses, then written only under the managed raw/ prefix via the same _build_object_key — so a child can only ever land inside its own firm/matter/document folder. (2) Depth / per-entry / total / ratio / count caps reject zip-bombs as ledger facts, never as exceptions, so a malicious archive is accounted, not a DLQ black hole. (3) The worker writes each child's bytes and stops — GuardDuty rescans the new object on PUT and the existing malware-results consumer drives it through the identical scan→stored→extract path, so children get the same scan-before-extract guarantee as top-level uploads, for free.

Satellite view: where this sits in the pipeline

A ZIP container is a documents.documents row like any upload; it is scanned to stored. The readability router (a sibling story) classifies it as ARCHIVE_ZIP and enqueues a container pointer onto the zip-expand SQS queue instead of sending it to extraction. This Story is the consumer of that queue. Per record it: fetches the container's bytes from S3, streams them through the pure safety core, materialises each safe entry as its own child documents.documents row under raw/, writes a document_import_items ledger row for every entry (accepted zip_child or rejected + reason), and acks. It never enqueues extraction for a child — GuardDuty + the malware-results consumer do that, so the scan gate is never skipped.

container (stored)                          THIS WORKER                       per child, for free
  router: ARCHIVE_ZIP  --SQS pointer-->  fetch bytes from S3
                                          expand() one entry at a time
                                            +-- AcceptedChild --> new documents row (uploaded)
                                            |                     + bytes to firms/{f}/matters/{m}/
                                            |                       documents/{child}/raw/{safe}
                                            |                     + ledger zip_child (landed)        --> GuardDuty PUT scan
                                            |                                                            --> malware-results
                                            |                                                            --> stored -> extract
                                            +-- RejectedEntry --> ledger rejected + reason_code
                                          CorruptArchive --> container item rejected (no DLQ)
Per the repo's "done = system boundary" rule, the security-critical logic is the part that must be exhaustively exercised, so it is pulled into a pure module (expansion.py) with no S3/DB/AWS — the zip-slip and zip-bomb defences are unit-tested directly on bytes. The S3 + DB + ledger orchestration sits behind a ZipExpandSink port so the materialise / reject / idempotency flow is tested over a fake sink. The real end-to-end DB+S3 path activates once the ledger table (#1855) and the queue/IAM (infra Chore C1) land — this worker is a stacked dependency on top of them and adds no migration of its own.

The decisions that matter

1. Sanitisation reuses the upload path verbatim — one definition of "safe name" (V-ZIPNAME)

Every entry name passes through DocumentService._safe_filename — the same helper presign uses — which strips directory components (split("/")[-1].split("\\")[-1]), allowlists [A-Za-z0-9._ -], and floors to "document". The key is then built by _build_object_key, the single source of truth for the raw/ grammar. So ../../etc/passwd, a/b/../../../c.txt, and /abs/evil.sh all collapse to a bare basename under the child's own UUID-keyed prefix — the final key always has exactly the prefix shape and a basename with no /, \, .., or NUL. The unsanitised original is preserved in the ledger's source_path for the Record display only, never in a key.

2. Caps are enforced while streaming, and every breach is a ledger fact — never a throw (the B-7 lesson)

The pure core reads each entry in 1 MiB chunks, aborting at the per-child ceiling so a lying header cannot smuggle an over-cap child past a cheap precheck. Each breach yields a RejectedEntry carrying a closed reason code, which becomes a rejected ledger item — the archive is still fully accounted, and the worker never raises on content. That is the same principle the extraction worker learned: a content failure that throws becomes a retry becomes a DLQ black hole. Here it is a clean, reason-coded row instead.

Cap (V1 default)Reject reason code
max nesting depth = 1 (no nested ZIPs)zip_nested_depth_exceeded
max entry count = 1000zip_file_count_exceeded
max single child = 100 MiB (upload cap)zip_child_too_large
max total uncompressed = 2 GiBzip_uncompressed_total_exceeded
max compression ratio = 100:1zip_compression_ratio_exceeded
password-protected / corrupt entryzip_entry_unreadable

A nested ZIP is detected by magic bytes (PK\x03\x04), not the attacker-controlled extension. A wholly unreadable archive (cannot even be listed) is the one case that is not a per-entry reject — it raises CorruptArchiveError from the core so the worker records a container-level rejected ledger fact and acks, still never a DLQ.

3. Children re-enter the scan gate — the worker NEVER direct-enqueues extraction (§5.4)

Each child is created as a documents.documents row in processing_status = 'uploaded' and its bytes are written to its raw/ key, then the worker stops. GuardDuty scans every object under the managed prefix on PUT; the existing malware-results consumer fires per child and runs the normal CLEAN→stored (+ matter-scoped dedup) → extraction path. The child inherits the container's stored W3C trace carrier, so its scan→stored hop continues the same trace_id — the whole expansion subtree is one trace. Direct child enqueue is forbidden by construction: there is no document.ingest.requested write in this worker at all.

4. Idempotent re-expansion (§5.6)

SQS is at-least-once, so a container can be redelivered. Before materialising an entry the worker checks the ledger for an existing item keyed on (container_document_id, source_path); a child already landed/accounted is counted but never re-created or re-written. Child object keys are UUID-keyed per child, so even colliding sanitised names never collide on disk.

5. Observability: one span per boundary, low-cardinality counts, continuous trace (ADR-124)

Two registry-approved manual span names were added to the closed ADR-124 enum: functions.document_zip_expand.batch (one per SQS invocation, links every record's stored trace context) and functions.document_zip_expand.process_record (CONSUMER kind, parented to the container's stored traceparent for continuity). The per-record span carries only safe, low-cardinality attributes — firm_id, matter_id, document.mime_type, and the numeric unpack counts document.zip.child_count / document.zip.rejected_count / document.zip.depth (added to the document-KPI allowlist, structurally excluded from X-Ray annotation promotion). No raw entry names or document bytes ever reach telemetry — reason codes are a closed enum. Queue backlog/in-flight visibility rides the consumer's emit_sqs_consume_duration + emit_sqs_message_oldest_age (same as extraction) plus the queue's native SQS CloudWatch metrics and the DLQ alarm owned by infra Chore C1; the worker does not poll the queue (Lambda must not).

Verification

The security core is proven directly on bytes; the orchestration over a fake sink; the telemetry contract against the registry.

CheckProves
test_document_zip_expand_expansion.py — normal ZIPa 3-entry archive expands to 3 accepted children with traversal-free basenames and the right bytes (the AC's "N accounted children")
… zip-slip../../etc/passwd, a/b/../../../c.txt, /abs/evil.sh all collapse to a safe basename (no /, \, .., NUL); original kept only in source_path
… corrupt + encrypted + each capa CRC-corrupted entry → zip_entry_unreadable; an encrypted-flag entry → zip_entry_unreadable; count / size / ratio / total / nested-zip caps each → their own closed reason code; a non-archive → CorruptArchiveError
test_document_zip_expand_processor.pyeach accepted entry is materialised once with container lineage stamped; a re-run with existing ledger items materialises nothing (idempotent); a rejected entry is a reason-coded ledger call; a corrupt archive is a container-level reject, not a throw
test_document_zip_expand_observability.pyboth new span names resolve to CONSUMER kind and enforce their required attributes; the zip count attributes validate through the shared allowlist; the handler opens both spans, parents the per-record span to the container trace, and stamps the unpack counts

All 18 new unit tests pass. ruff check is clean on every changed file; mypy reports no error in src/workers/document_zip_expand/ (the pre-existing SQLModel typing noise in untouched files is unchanged and out of scope; mypy is local-only); git diff --check is clean. No route, request/response DTO, or trust-boundary HTTP payload changed (the SQS envelope is an internal message, not an API DTO), so the OpenAPI snapshots are unchanged.

Stacked dependencies (flagged, not built here). This worker writes documents.document_import_items rows the import-ledger story (#1855) provisions — it adds no migration and defines no ORM entity for that table, so it neither owns nor duplicates the ledger schema; it must merge after the ledger PR. The producer side — the readability router's ARCHIVE_ZIP branch that enqueues the container pointer, and the outbox event-type widening + relay filter — rides the readability-router work (#1856) and the ledger migration. The zip-expand SQS queue + DLQ + IAM are infra Chore C1 (pramaan-infra). The full real-DB+S3 system-boundary test activates once those land.

One sentence to remember: a ZIP becomes N first-class child documents whose names can never escape their own firm/matter prefix and whose bytes always re-enter the GuardDuty scan gate before extraction, while every bomb, traversal, or corrupt entry is an accounted, reason-coded ledger row instead of a crash or a DLQ.