# ADR-0036: Uploaded documents are scanned by ClamAV in the Domain Service, fail closed - **Status:** Accepted - **Date:** 2026-10-02 - **Deciders:** Respellion engineering - **Slice:** proposed in [#190](https://git.labs.respellion.tech/eho/register-referentie/issues/190); clamd deployed in [#191](https://git.labs.respellion.tech/eho/register-referentie/issues/191) (S-28), scanning wired in [#192](https://git.labs.respellion.tech/eho/register-referentie/issues/192) (S-29). ## Context A zorgprofessional's diploma upload goes portal → BFF → Domain (`ProvideDocuments`) → ACL → OpenZaak. Nothing on that path looks at the file. It is not checked for malware, and nobody checks that it is a PDF. Behandelaars open these files later, so the register stores, and then serves, whatever a citizen sends. Scanning needs a signature engine that stays up to date. That means a new peer service, and that makes it an ADR (CLAUDE.md §14). ## Decision 1. **Engine:** the ClamAV daemon (`clamd`), official image `clamav/clamav`, pinned tag, as its own service in compose and in the Helm chart. `freshclam` in the same container keeps the signatures current, and they live on a volume. 2. **Where the check lives:** in the **Domain Service**, behind an `IDocumentScanner` port in `Big.Application`. "Only a clean PDF is stored and unblocks beoordeling" is a rule of the provide-documents use case. The BFF is a thin proxy (§8.3), and the ACL translates ZGW and nothing else (§8.1). A check in the domain also covers every entry point, not just the portal. 3. **Protocol:** the adapter in `Big.Infrastructure` speaks clamd's INSTREAM protocol over `TcpClient`: `zINSTREAM\0`, length-prefixed chunks, a zero-length terminator, then a `stream: OK` or `stream: FOUND` reply. That is a few lines of code, so we add **no NuGet package** for it (nClam and similar). 4. **Fail closed:** if clamd can't be reached, the upload is refused (503). Nothing is stored and the document wait stays open. We never store an unscanned file. 5. **Type check:** content must also start with `%PDF-`, checked **after** the scan. clamd matches EICAR (and many real signatures) only at the start of a file, so a type check in front of the scan would report malware as merely "not a PDF". The check also refuses a renamed non-PDF that is clean. ## Consequences - One more long-running service. clamd holds its signatures in memory (about 1 GB idle). `ConcurrentDatabaseReload no` stops a signature reload from holding a second copy, but clamd pauses scans for the few seconds a reload takes. Compose caps it at `mem_limit: 2g`, and the chart requests 1200Mi. This counts against the verify-stack runner's memory ceiling (#182). - The first start downloads about 300 MB of signatures from the ClamAV CDN, so the runner and the cluster node need outbound internet (as `seed-zaaktype` already does). The CDN rate-limits by IP. A CI runner that starts fresh often can get throttled, and then the health check doesn't go green. If that happens, mirror the signatures (`cvdupdate`) rather than retrying. - Tests use the EICAR test string, built from two halves so the repo itself does not trip an on-access scanner. No real malware is ever committed. - Infected or non-PDF uploads are refused with 422 and a business message. We don't keep a quarantine copy: a refused file is simply not stored.