From 3e9bda90319040f8df3d0cfd94a50011ba0dd946 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 2 Oct 2026 09:01:01 +0200 Subject: [PATCH] test(infra): clamd detects EICAR and passes a clean stream over INSTREAM (refs #191) Red: no clamav service exists yet, so verify-clamav finds no container. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/ci.yaml | 5 +++++ Makefile | 8 +++++++- infra/clamav-check.py | 40 +++++++++++++++++++++++++++++++++++++++ infra/run-clamav-check.sh | 21 ++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 infra/clamav-check.py create mode 100644 infra/run-clamav-check.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d8e13f8..759f081 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") |" diff --git a/Makefile b/Makefile index c6d3828..f4a397b 100644 --- a/Makefile +++ b/Makefile @@ -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/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/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