Skip to main content

D88 — Documents vault malware scanning: quarantine-by-default lifecycle with a MalwareScanner seam (ClamAV adapter, env-gated) (RESOLVED, extends D43/D57)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

The vault admitted uploads with only the (inert-in-practice) synchronous ContentPreScanner tripwire between the client's bytes and the provider forward. Decision: a quarantine-first scan lifecycle, config-gated OFF (documents.scanning.enabled) so the shipped behaviour is unchanged until a scanner is deployed:

  • Lifecycle: a new scan_status column (DocumentScanStatus: pending | clean | rejected) ORTHOGONAL to status (capture/forward/shred). Scanning enabled ⇒ every upload (proxied store AND direct confirm) commits quarantined (pending): not downloadable, not forwardable — the single DocumentServeGate refuses every byte-serving path (download-URL issue, streaming/redirect serve, forward read) with distinct coded 409s (DOCUMENT_QUARANTINED / DOCUMENT_SCAN_REJECTED / DIRECT_UPLOAD_INCOMPLETE), so a new serve path cannot forget a state. The queued ScanDocumentJob (documents queue, retries + backoff) reads the verified bytes through the bus (GetDocumentForScanningQuery — the ONE read path allowed to touch quarantined bytes) and records the verdict via the idempotent RecordDocumentScanVerdictCommand: clean promotes + releases the deferred provider forward (scan-before-forward replaces store-then- forward when scanning is on); rejected is terminal — verdict signature stamped in scan_verdict, DocumentScanRejected domain event + documents.scan_rejected audit line, and the blob RETAINED in quarantine for forensics (encrypted at rest, unreachable through any serve). A scanner ERROR throws — no verdict, the job retries, the document STAYS quarantined (fail-closed; exhausted retries emit the documents.scan_failed audit alert and the job is re-drivable). Existing rows are grandfathered clean. Scanning disabled ⇒ documents are admitted clean at store/confirm and the forward dispatches immediately (the pre-D88 behaviour, proven by the untouched existing tests). The client resource now surfaces scan_status so the SPA can show "being scanned" / "refused" without probing for 409s.
  • Scanner choice — ClamAV adapter now, managed service as a swap-in. Evaluated: a managed scanner (e.g. GuardDuty Malware Protection for S3 / a bucket-triggered lambda) is operationally lighter at scale but couples the verdict path to one cloud + an async eventing pipeline the app doesn't have; ClamAV (clamd INSTREAM over TCP) is provider-agnostic, runs as a sidecar anywhere, and slots directly into the synchronous-job shape. Decision: ship the MalwareScanner seam with the ClamAvMalwareScanner INSTREAM adapter (env-gated deploy config — documents.scanning.clamav.{host,port,timeout_seconds}; nothing stood up locally, tests bind a fake; the response parsing is unit-tested, fail-closed on ERROR/unknown). A managed-service adapter implements the same interface and binds in its place; a bucket-side scanner that quarantines via object tags would integrate at the confirm/verdict seam later.
  • Scanner-boundary guard: a stream the scanner refuses (e.g. past clamd's StreamMaxLength) yields NO verdict on every retry — permanent quarantine, fail-closed but silent. Two controls: the DEPLOY INVARIANT that clamd's StreamMaxLength MUST exceed documents.max_bytes (documented on both config knobs — clamd's own default, 25M, sits exactly at the default cap), and documents.scanning.max_scannable_bytes (default = max_bytes): with scanning enabled, an upload/confirm whose actual size exceeds it is refused as a clear 422 DOCUMENT_REJECTED at admission time instead of stranding in quarantine.
  • Deliberately NOT built: a separate physical quarantine BUCKET, scheduled re-scans of the standing corpus, and operator surfaces for the rejected queue (the audit line + event are the hooks; both deferrals tracked in docs/tracking/document-vault-retention-and-rtbf.md). On the quarantine bucket, the trade-off is owned honestly: quarantine is a metadata state and a second bucket adds a cross-bucket move + IAM surface — but the isolation this decision ships is app-gate-only. A quarantined DIRECT-path blob has no envelope encryption and no IAM boundary of its own; a separate quarantine bucket WOULD add an IAM-level GET boundary (credentials scoped away from the serving bucket) that the DocumentServeGate alone does not. That residual — quarantined direct-path bytes are protected by the application gate, not by storage-level isolation — is accepted and owned here the same way D87 owns the encryption trade-off, and is revisited if a real scanner deployment or a compromise-of-app-credentials threat model demands the storage-level boundary.

← Decision log index