diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d8e13f8..e965ac6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -248,6 +248,9 @@ jobs: - name: RegisterRecord objecttype registered + published id: registerrecord run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord + - name: ClamAV scans a stream (EICAR found, clean OK) + id: clamav + run: CLAMAV_TIMEOUT=120 make verify-clamav - name: ACL ↔ OpenZaak integration tests id: acl run: make verify-acl @@ -287,6 +290,7 @@ jobs: OBJECTEN: ${{ steps.objecten.outcome }} REGISTERRECORD: ${{ steps.registerrecord.outcome }} OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }} + CLAMAV: ${{ steps.clamav.outcome }} ACL: ${{ steps.acl.outcome }} NRC: ${{ steps.nrc.outcome }} PROJECTION: ${{ steps.projection.outcome }} @@ -309,6 +313,7 @@ jobs: echo "| Objecten API + token | $(icon "$OBJECTEN") |" echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |" echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |" + echo "| ClamAV INSTREAM scan | $(icon "$CLAMAV") |" echo "| ACL ↔ OpenZaak | $(icon "$ACL") |" echo "| OpenZaak → NRC | $(icon "$NRC") |" echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |" @@ -328,7 +333,7 @@ jobs: # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init clamav tempo prometheus grafana 2>&1 || true - name: Tear down if: always() run: make down diff --git a/Makefile b/Makefile index c6d3828..c50e297 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml # Long-running services with a healthcheck — the smoke polls these for readiness # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. -WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten +WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten clamav # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # into external named volumes via `docker cp` (infra/seed-config.sh) instead of # bind-mounted, because bind mounts don't reach sibling containers on the @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit mutation frontend docs integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help +.PHONY: ci lint build unit mutation frontend docs integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-clamav verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help ## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -222,6 +222,11 @@ verify-registerrecord: verify-objecten-notifications: bash infra/run-objecten-notifications-check.sh +## verify-clamav: assert clamd detects EICAR and passes a clean stream over INSTREAM (S-28), +## against the already-running stack. +verify-clamav: + bash infra/run-clamav-check.sh + ## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. @@ -230,6 +235,7 @@ verify: docker compose -f $(COMPOSE) up -d --build @bash -c 'set -e; rc=0; \ WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \ + && bash infra/run-clamav-check.sh \ && bash infra/run-acl-integration.sh \ && bash infra/run-notification-check.sh \ && bash infra/run-projection-check.sh \ 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..90947bc --- /dev/null +++ b/docs/architecture/adr-0036-scan-uploads-with-clamav.md @@ -0,0 +1,56 @@ +# 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. 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/infra/clamav-check.py b/infra/clamav-check.py new file mode 100644 index 0000000..f6044d4 --- /dev/null +++ b/infra/clamav-check.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""S-28 (#191): prove clamd is up, has signatures loaded, and scans a stream over INSTREAM. + +The EICAR test file must come back FOUND and a clean payload OK — the same protocol the domain's +scanner adapter will speak (ADR-0036). EICAR is assembled from two halves so this file itself is +not flagged by an on-access scanner on a developer laptop. Stdlib only (python:3-slim). +""" +import os +import socket +import struct +import sys +import time + +HOST = os.environ["CLAMAV"] +TIMEOUT = int(os.environ.get("CLAMAV_TIMEOUT", "60")) +EICAR = (r"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + r"EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*").encode() + + +def instream(payload): + with socket.create_connection((HOST, 3310), timeout=30) as s: + s.sendall(b"zINSTREAM\0" + struct.pack(">I", len(payload)) + payload + struct.pack(">I", 0)) + return s.recv(4096).rstrip(b"\0").decode() + + +deadline = time.time() + TIMEOUT +while True: + try: + clean, infected = instream(b"%PDF-1.4 clean"), instream(EICAR) + break + except OSError as e: + if time.time() > deadline: + sys.exit(f"FAIL: clamd at {HOST}:3310 unreachable: {e}") + time.sleep(3) + +print(f"clean → {clean!r}; eicar → {infected!r}") +if clean != "stream: OK": + sys.exit("FAIL: clean payload was not reported OK") +if not infected.endswith("FOUND"): + sys.exit("FAIL: EICAR was not detected") +print("OK: clamd detects EICAR and passes a clean stream") diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index c90de0b..2e7fdba 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -751,6 +751,26 @@ services: condition: service_completed_successfully networks: [cg] + # ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM + # protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of + # signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory + # (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents + # that, at the cost of clamd pausing scans during a signature reload. + clamav: + image: docker.io/clamav/clamav:1.4.6 + environment: + CLAMD_CONF_ConcurrentDatabaseReload: "no" + # The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so + # wait-healthy sees it as soon as the signatures are loaded. + healthcheck: + test: ["CMD-SHELL", "clamdcheck.sh"] + interval: 5s + start_period: 360s + mem_limit: 2g + volumes: + - clamav-db:/var/lib/clamav + networks: [cg] + volumes: oz-db: nrc-db: @@ -758,6 +778,7 @@ volumes: projection-db: objecttypen-db: objecten-db: + clamav-db: # Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL. seed-env: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 6ac6fae..f737d9f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -785,6 +785,26 @@ services: condition: service_completed_successfully networks: [cg] + # ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM + # protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of + # signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory + # (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents + # that, at the cost of clamd pausing scans during a signature reload. + clamav: + image: docker.io/clamav/clamav:1.4.6 + environment: + CLAMD_CONF_ConcurrentDatabaseReload: "no" + # The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so + # wait-healthy sees it as soon as the signatures are loaded. + healthcheck: + test: ["CMD-SHELL", "clamdcheck.sh"] + interval: 5s + start_period: 360s + mem_limit: 2g + volumes: + - clamav-db:/var/lib/clamav + networks: [cg] + # ── Observability backplane (S-16a, ADR-0023) ────────────────────────────── # Grafana-native stack: Tempo ingests OTLP traces (the .NET services export # straight to it — no collector hop, S-16b), Prometheus scrapes service @@ -836,6 +856,7 @@ volumes: projection-db: objecttypen-db: objecten-db: + clamav-db: # Config volumes — created and populated out-of-band by infra/seed-config.sh # (docker cp), because bind mounts don't reach sibling containers on the CI # runner. `external` keeps the names deterministic; the seed step manages them. diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml index a3b057b..6da465e 100644 --- a/infra/helm/big-reference/values.yaml +++ b/infra/helm/big-reference/values.yaml @@ -588,6 +588,24 @@ workloads: envFrom: [objecten] waitFor: [objecten-db:5432, objecten-redis:6379] + # ── ClamAV (S-28, ADR-0036) ───────────────────────────────────────────────── + # The domain scans uploaded diplomas over clamd's INSTREAM protocol (S-29). + # First start pulls ~300 MB of signatures, so the node needs outbound internet + # (like seed-zaaktype); the data volume keeps them when persistence is on. + clamav: + image: docker.io/clamav/clamav:1.4.6 + env: + CLAMD_CONF_ConcurrentDatabaseReload: "no" + ports: [{ name: clamd, port: 3310 }] + data: { mountPath: /var/lib/clamav, size: 1Gi } + probe: + exec: { command: [clamdcheck.sh] } + periodSeconds: 5 + failureThreshold: 72 + resources: + requests: { memory: 1200Mi } + limits: { memory: 2Gi } + # ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ── # Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the # server-assigned URLs are host-consistent. The ACL then resolves them by diff --git a/infra/run-clamav-check.sh b/infra/run-clamav-check.sh new file mode 100644 index 0000000..47ec9eb --- /dev/null +++ b/infra/run-clamav-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# S-28 (#191): assert clamd scans over INSTREAM (EICAR → FOUND, clean → OK), against an +# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the +# runner can't reach published ports — gitea-actions-gotchas.md §5/§6). +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +av="$(docker ps -q --filter 'name=[-_]clamav[-_][0-9]+$' | head -1)" +[ -n "$av" ] || { echo "ERROR: no running clamav container — bring the stack up first" >&2; exit 1; } +net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$av" | head -1)" +ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$av")" +echo ">> network=$net clamav=$ip" + +cid="$(docker create --network "$net" -e "CLAMAV=$ip" -e "CLAMAV_TIMEOUT=${CLAMAV_TIMEOUT:-60}" \ + python:3-slim python /clamav-check.py)" +docker cp "$here/clamav-check.py" "$cid:/clamav-check.py" >/dev/null +rc=0; docker start -a "$cid" || rc=$? +docker rm -f "$cid" >/dev/null +exit $rc 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