Files
register-referentie/docs/architecture/adr-0036-scan-uploads-with-clamav.md
T
notandClaude Opus 5.5 5fdcbd27c0
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
build(infra): run ClamAV in compose and on the cluster (closes #191) (#193)
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>
2026-10-02 07:53:16 +00:00

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

  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: <name> 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.