feat(domain): virus-scan and PDF-check uploaded diplomas with ClamAV (closes #192) #194

Open
not wants to merge 13 commits from feat/192-scan-uploads into main
Contributor

Uploaded diplomas are now virus-scanned and type-checked before they reach OpenZaak (ADR-0036). Builds on #193 (clamd infra, merged).

What changes

  • Domain: a new IDocumentScanner port, implemented by ClamdDocumentScanner (clamd INSTREAM over TcpClient, no NuGet package). ProvideDocuments checks ownership, then scans, then checks for %PDF-, and only then stores the file and completes the wait. New outcomes: Infected, NotAPdf, ScannerUnavailable. The API answers 422 {reason} or 503.
  • Fail closed: an unreachable clamd, a timeout or an ERROR reply all count as Unavailable. The upload is refused, nothing is stored and the wait stays open.
  • The scan runs before the PDF check: clamd only matches EICAR at the start of a file (verified against a real clamd: %PDF-1.4\n + EICAR scans OK), so a PDF check in front of the scan would report malware as "not a PDF".
  • BFF: relays 422 with the reason and 503. openapi.json and the generated client are regenerated.
  • Self-service portal: a specific, accessible role="alert" message for infected, not-a-PDF and scanner-unavailable.
  • Acceptance: EenDiplomaAanleveren.feature has four scenarios (clean, infected, not a PDF, scanner down).

Verified locally: every .NET unit and acceptance suite is green, as are the self-service vitest + axe tests and dotnet format. The adapter was run against a real clamd 1.4.6: clean gives Clean, EICAR gives Infected, a closed port gives Unavailable. The existing e2e and local-flow uploads begin with %PDF- and are clean, so the happy path is unchanged.
Not run: the full verify-stack (main only) and a Playwright e2e for the infected path. That path is covered by unit, BFF, portal and acceptance tests, plus verify-clamav for the live engine.

closes #192

🤖 Generated with Claude Code

Uploaded diplomas are now virus-scanned and type-checked before they reach OpenZaak (ADR-0036). Builds on #193 (clamd infra, merged). **What changes** - **Domain:** a new `IDocumentScanner` port, implemented by `ClamdDocumentScanner` (clamd INSTREAM over `TcpClient`, no NuGet package). `ProvideDocuments` checks ownership, then scans, then checks for `%PDF-`, and only then stores the file and completes the wait. New outcomes: `Infected`, `NotAPdf`, `ScannerUnavailable`. The API answers 422 `{reason}` or 503. - **Fail closed:** an unreachable clamd, a timeout or an ERROR reply all count as `Unavailable`. The upload is refused, nothing is stored and the wait stays open. - **The scan runs before the PDF check:** clamd only matches EICAR at the start of a file (verified against a real clamd: `%PDF-1.4\n` + EICAR scans `OK`), so a PDF check in front of the scan would report malware as "not a PDF". - **BFF:** relays 422 with the reason and 503. `openapi.json` and the generated client are regenerated. - **Self-service portal:** a specific, accessible `role="alert"` message for infected, not-a-PDF and scanner-unavailable. - **Acceptance:** `EenDiplomaAanleveren.feature` has four scenarios (clean, infected, not a PDF, scanner down). **Verified locally:** every .NET unit and acceptance suite is green, as are the self-service vitest + axe tests and `dotnet format`. The adapter was run against a real clamd 1.4.6: clean gives Clean, EICAR gives Infected, a closed port gives Unavailable. The existing e2e and local-flow uploads begin with `%PDF-` and are clean, so the happy path is unchanged. **Not run:** the full verify-stack (main only) and a Playwright e2e for the infected path. That path is covered by unit, BFF, portal and acceptance tests, plus `verify-clamav` for the live engine. closes #192 🤖 Generated with [Claude Code](https://claude.com/claude-code)
not added this to the Iteration 6 — Production Posture milestone 2026-10-02 07:30:31 +00:00
not added the type:slicearea:domainarea:portal-self-servicearea:bff labels 2026-10-02 07:30:33 +00:00
not changed target branch from build/191-clamav to main 2026-10-02 07:53:33 +00:00
not added 13 commits 2026-10-02 07:53:33 +00:00
Red: ProvideDocuments takes the new IDocumentScanner port but ignores it, so
infected, non-PDF and scanner-unavailable uploads are still Accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Red: the stub adapter never connects and always answers Clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Verified against a real clamd 1.4.6: clean → Clean, EICAR → Infected,
closed port → Unavailable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
openapi.json and the generated portal client regenerated from the served spec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
clamd matches EICAR only at the start of a file, so a %PDF- check ahead of the
scan made the infected path unreachable for the EICAR test file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test(domain): cover clamd timeout, caller cancellation, null content and defaults (refs #192)
CI / verify-stack (pull_request) Blocked by required conditions
CI / k8s (pull_request) Successful in 18s
CI / build (pull_request) Successful in 5m23s
CI / lint (pull_request) Successful in 5m55s
CI / docs (pull_request) Successful in 1m3s
CI / unit (pull_request) Successful in 1m33s
CI / mutation (pull_request) In progress
CI / frontend (pull_request) Successful in 5m31s
e39a46febc
Domain mutation score 89.87% → 91.98%, back above the 90% break.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
not closed this pull request 2026-10-02 07:53:50 +00:00
not reopened this pull request 2026-10-02 07:53:50 +00:00
Some checks are pending
CI / verify-stack (pull_request) Blocked by required conditions
CI / k8s (pull_request) Successful in 18s
CI / build (pull_request) Successful in 5m23s
Required
Details
CI / lint (pull_request) Successful in 5m55s
Required
Details
CI / docs (pull_request) Successful in 1m3s
CI / unit (pull_request) Successful in 1m33s
Required
Details
CI / mutation (pull_request) In progress
Required
Details
CI / frontend (pull_request) Successful in 5m31s
Required
Details
Some required checks are missing.
This pull request is blocked because it's outdated.
You are not authorized to merge this pull request.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/192-scan-uploads:feat/192-scan-uploads
git checkout feat/192-scan-uploads
Sign in to join this conversation.