Deploy to Talos / deploy (push) Successful in 2m40s
CI / verify-stack (push) Blocked by required conditions
CI / k8s (push) Successful in 7s
CI / build (push) Successful in 4m57s
CI / lint (push) Successful in 5m24s
CI / docs (push) Successful in 1m8s
CI / frontend (push) In progress
CI / unit (push) Successful in 1m33s
CI / mutation (push) In progress
Runs a ClamAV daemon (clamav/clamav:1.4.6) in both compose stacks and the Helm chart, health-gated, with a verify-clamav check (EICAR found, clean OK) in verify-stack. ADR-0036 records the scan-in-domain, fail-closed decision (#190). closes #191 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3.4 KiB
3.4 KiB
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; clamd deployed in #191 (S-28), scanning wired in #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
- Engine: the ClamAV daemon (
clamd), official imageclamav/clamav, pinned tag, as its own service in compose and in the Helm chart.freshclamin the same container keeps the signatures current, and they live on a volume. - Where the check lives: in the Domain Service, behind an
IDocumentScannerport inBig.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. - Protocol: the adapter in
Big.Infrastructurespeaks clamd's INSTREAM protocol overTcpClient:zINSTREAM\0, length-prefixed chunks, a zero-length terminator, then astream: OKorstream: <name> FOUNDreply. That is a few lines of code, so we add no NuGet package for it (nClam and similar). - 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.
- 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 nostops a signature reload from holding a second copy, but clamd pauses scans for the few seconds a reload takes. Compose caps it atmem_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-zaaktypealready 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.