A small REST service, written in Python, that scans files on demand across one or
more categories (axes of judgment, e.g. malware) using pluggable
scanners, and reports a per-category verdict plus a per-scanner breakdown.
The app and worker only ever see a normalized verdict, never the engine. The
built-in scanners all feed the malware category: clamav / exav (the
clamd wire protocol —
exav gives richer verdicts and never marks a
skipped file clean) and jcop (the cyber.gouv.fr HTTP service). A request picks
work by category and/or scanner (?categories=malware, ?scanners=clamav,jcop,
which union); otherwise DEFAULT_CATEGORIES is used. See
docs/categories.md and
docs/scanner-backends.md.
The service is stateless by default: a synchronous scan returns its verdict in
the HTTP response; an asynchronous scan delivers its verdict via a webhook
callback. Optionally (WORKER_RESULT_TTL > 0) it also keeps a TTL-bounded result in
Redis so callers can poll a job by id instead of receiving a webhook.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/v1.0/scan |
JWT | Synchronous scan of an uploaded file → per-category + per-scanner report. |
POST |
/api/v1.0/scan-async |
JWT | Async scan of a file fetched from a URL; result delivered to a webhook (and/or polled). |
GET |
/api/v1.0/jobs/{job_id} |
JWT | Poll an async job's result (only when WORKER_RESULT_TTL > 0, else 404). |
GET |
/check, / |
— | Liveness: 200 Service OK when the scanners answer, else 503. |
GET |
/.well-known/jwks.json |
— | Webhook-signing public key(s) (JWK Set) for receivers to verify signed callbacks. |
GET |
/metrics |
— | Prometheus exposition, incl. signature freshness (see Monitoring). |
Full request/response schemas: docs/api.md.
Requires Docker. make help lists every target.
make bootstrap # scaffold env files + build + start app + worker + clamav + redis
docker compose logs -f clamav # wait for the signature database to loadmake bootstrap is the one-time setup; afterwards use make start / make stop
to bring the stack up and down (both read config from deploy/env/, see
Configuration).
Scan the harmless EICAR test file. Auth is a request-bound JWT (Auth &
callers), so mint a short-lived token for the throwaway dev
caller dev-issuer with the mint-token.py helper (run in the app container) and
send it as a Bearer token:
printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.txt
# Throwaway dev private key for caller "dev-issuer" (matches JWT_ISSUER_KEYS in deploy/env/app.defaults).
DEV_PRIV=Higc3cLT742BJB5GiPnW5Ypg0xCGoVYY-s07ssMVlsg
TOKEN=$(docker compose exec -T app python deploy/scripts/mint-token.py "$DEV_PRIV" dev-issuer POST /api/v1.0/scan)
curl -sf -H "Authorization: Bearer $TOKEN" -F "file=@/tmp/eicar.txt" \
http://localhost:8090/api/v1.0/scan
# {"malware": true,
# "scanners": [{"scanner": "clamav", "category": "malware", "kind": "malware",
# "reason": "Eicar-Test-Signature", "time": 0.003}]}make test # run the test suite in docker
make lint # ruff check + format check
make start # (re)start the stack after the initial bootstrap
make stop # stop the stack| Service | URL / Port | Description | Credentials |
|---|---|---|---|
| app (web) | http://localhost:8090 | FastAPI REST API + /metrics |
JWT — dev caller dev-issuer (throwaway key, see Auth & callers) |
| worker | — | dramatiq worker — async scans (scans queue) + webhook delivery (webhooks queue) |
— |
| clamav | localhost:3310 |
ClamAV / exav daemon (clamd protocol) | none |
| redis | localhost:6380 |
dramatiq broker (Redis Streams) | none |
Scan endpoints authenticate with a short-lived EdDSA (Ed25519) Bearer JWT.
Callers sign the token with their private key; the service verifies it with
their public key, selected by the token's iss claim (which also
identifies the caller in logs and the api_client metric, e.g. drive,
transfers). Because the service stores only public keys, a leak of its config
can't forge caller tokens.
Configure the accepted callers with JWT_ISSUER_KEYS — iss:pubkey pairs, each
the base64url raw Ed25519 public key:
JWT_ISSUER_KEYS="drive:<drive-pubkey>,transfers:<transfers-pubkey>"Onboard a new caller with make new-issuer NAME=<iss> — it generates an
Ed25519 keypair and prints the caller's private key (hand it over securely) plus
the iss:pubkey line to append here.
The token binds the request — method + target, plus a SHA-256 of the JSON
body on the async endpoint — so a captured token can't be replayed on a
different call or with a swapped webhook_url. Mint one per request (see the
quick start for a dev example). Outgoing webhooks are signed with
the service's own key (JWT_SIGNING_KEY, an Ed25519 seed you generate once —
see docs/deployment.md)
and are verifiable at /.well-known/jwks.json. Requests without a valid token
get 401. Full model: docs/security.md.
GET /metricsexposes Prometheus metrics: default process metrics plusfilescanner_scans_total{scanner,category,verdict,api_client}andfilescanner_scan_duration_seconds{scanner,api_client}(api_clientis the calling service's JWTiss, so scans break down per consumer). SetPROMETHEUS_API_KEYto requireAuthorization: Bearer <key>(unset = open, so isolate it at the network layer — and note theapi_clientlabel exposes caller identities). Sync scans are counted in the web process; the worker process counts async scans (scrape it separately, or use prometheus multiprocess mode).- Queue dashboard. The broker
(
dramatiq-redis-streams) ships a dashboard for inspecting/replaying/deleting queued jobs. Upstream it is destructive and unauthenticated, souvicorn app:appserves it at/dashboardbehind a mandatory Basic-auth + optional IP-allowlist guard (src/dashboard.py) — and only whenWORKER_DASHBOARD_PASSWORDis set (unset ⇒ it isn't mounted; the path isWORKER_DASHBOARD_PATH). It's highly sensitive — it renders queued/dead-letter task args, which include the source URL and, for encrypted sources, the decryption key — so treat access as broker-equivalent. LeavingWORKER_DASHBOARD_PASSWORDunset (the default) disables it entirely (no route, no code). The dev compose enables it at http://localhost:8090/dashboard (any username /dev-dashboard-password). In production set a strong password, restrict withWORKER_DASHBOARD_ALLOWED_IPS(orWORKER_DASHBOARD_FORWARDED_IP_HEADERbehind a trusted proxy), purge the DLQ, and keep the broker private (WORKER_BROKER_URL=redis://:PASSWORD@host:6379/0, Redis bound internally). See docs/deployment.md.
src/ application code (FastAPI app, scanners, dramatiq worker, SSRF guard, …)
tests/ pytest suite (one file per area)
deploy/env/ per-service env files: committed *.defaults + gitignored *.local (make create-env-files)
deploy/scripts/ dev/ops CLIs: JWT issuer keygen (new-issuer.py) + token minting (mint-token.py)
deploy/docker/ image build helpers (strip-python.sh)
docs/ reference documentation (see below)
- API: FastAPI + uvicorn
- Async scans: dramatiq over the
dramatiq-redis-streamsbroker (Redis) - Scanners:
clamav/exav(clamd protocol) andjcop(HTTP) - Metrics: Prometheus (
/metrics) - Image: multi-stage, distroless (uv-managed CPython 3.14), nonroot
| Document | Contents |
|---|---|
| docs/architecture.md | Components, request flows, statelessness. |
| docs/categories.md | The category model: request grammar, multi-axis verdicts, config. |
| docs/api.md | Full endpoint reference and payload schemas. |
| docs/scanner-backends.md | clamav / exav / jcop and the extended verdicts. |
| docs/deployment.md | Configuration, process types, running it. |
| docs/security.md | SSRF protection, resource limits, threat model. |
See CONTRIBUTING.md. Commits follow the gitmoji convention. Report vulnerabilities per SECURITY.md.
This project stands on a decade of prior work. Its lineage, oldest first:
- solita/clamav-java — a minimal Java client for ClamAV's clamd protocol.
- solita/clamav-rest — a Java REST proxy (INSTREAM + PING) built on clamav-java, by Solita.
- uktrade/dit-clamav-rest — a Python reimplementation by the UK Department for International Trade, inspired by solita/clamav-rest. The original repo is no longer online; a mirror survives at heikipikker/dit-clamav-rest.
- betagouv/clamav-service — a fork of DIT ClamAV REST by beta.gouv.fr, adding GitHub Actions and Scalingo deployment. The direct predecessor of this repository.
- This repository (La Suite Numérique) — reworked into a FastAPI service with a pluggable scanner interface, dramatiq async scanning, SSRF hardening, and a distroless image.
It also adapts code and patterns from sibling La Suite Numérique projects:
- the SSRF guard (
ssrf.py) is vendored from suitenumerique/messages, whose distroless/uv Docker build this image also mirrors; - the dramatiq broker/worker setup follows suitenumerique/st-home;
- the
jcopbackend mirrors suitenumerique/django-lasuite; - it integrates exav and the dramatiq-redis-streams broker.