docs(arch): ADR-0036 — scan uploads with ClamAV in the domain, fail closed (refs #191, refs #190)
CI / k8s (pull_request) Successful in 8s
CI / build (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m56s
CI / docs (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m21s
CI / frontend (pull_request) Successful in 2m22s
CI / mutation (pull_request) Successful in 4m47s
CI / verify-stack (pull_request) Skipped
CI / k8s (pull_request) Successful in 8s
CI / build (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m56s
CI / docs (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m21s
CI / frontend (pull_request) Successful in 2m22s
CI / mutation (pull_request) Successful in 4m47s
CI / verify-stack (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: <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 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.
|
||||
@@ -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/<name>` |
|
||||
|
||||
Reference in New Issue
Block a user