diff --git a/docs/architecture/adr-0036-scan-uploads-with-clamav.md b/docs/architecture/adr-0036-scan-uploads-with-clamav.md new file mode 100644 index 0000000..62d5f92 --- /dev/null +++ b/docs/architecture/adr-0036-scan-uploads-with-clamav.md @@ -0,0 +1,54 @@ +# 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 start with `%PDF-`. This refuses a renamed executable + before the scan, and it costs nothing. + +## 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. diff --git a/docs/runbooks/kubernetes-talos.md b/docs/runbooks/kubernetes-talos.md index 72091ec..615d27d 100644 --- a/docs/runbooks/kubernetes-talos.md +++ b/docs/runbooks/kubernetes-talos.md @@ -356,6 +356,7 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job` | Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value | | A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness | | Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` | +| `clamav` not Ready for minutes | first start downloads ~300 MB of signatures, which needs outbound internet. A `429`/`cool-down` in `kubectl -n big logs deploy/clamav` means the ClamAV CDN is throttling this IP (ADR-0036) | | `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` | | Pods `Evicted` / `OOMKilled` | the VM is too small (§0) | | A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/` | diff --git a/mkdocs.yml b/mkdocs.yml index 7a3f010..272e583 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md - "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md - "ADR-0035: Public access through the labs Caddy": architecture/adr-0035-public-access-through-the-labs-caddy.md + - "ADR-0036: Scan uploads with ClamAV": architecture/adr-0036-scan-uploads-with-clamav.md - FDS-architectuur: - Overzicht: architecture/fds/README.md - Componentview (L3): architecture/fds/c4-component-view.md