directus-extension-file-scanner
v1.0.0
Published
Automatic ClamAV-powered malware scanning for every file uploaded to Directus. Configurable actions, audit logging, and admin webhooks.
Maintainers
Readme
directus-extension-file-scanner
Automatic ClamAV-powered malware scanning for every file uploaded to Directus 11. Configurable actions, audit logging, and admin webhooks.
⚠️ Requires a ClamAV daemon reachable from your Directus host. Self-hosted Directus only — see Self-hosted only for deployment shapes (sibling container, remote ClamAV, Unix socket, local binary).
Why this extension
Directus has no built-in malware scanning for user-uploaded files. Any deployment exposed to untrusted users (public CMS, customer portals, job boards, intranet upload forms) ends up storing whatever files attackers push: malware, ransomware payloads, web shells, and infected documents. This is also a hard blocker for compliance frameworks (GDPR, ISO 27001, SOC 2, HIPAA) that require malware scanning on user inputs.
This extension fills that gap: it hooks into files.upload, scans each fresh upload with ClamAV, and applies a configurable action (delete, quarantine, log-only) when a threat is detected. Every scan is recorded in a Directus collection so the audit trail is just an API call away.
Features
- Automatic scanning of every uploaded file via ClamAV (TCP, Unix socket, or local binary).
- Configurable infection action: delete, quarantine, or log-only.
- Persistent audit log in the
virus_scan_logscollection, queryable through the standard Directus API. - Outbound webhooks on infection — fan out to multiple receivers in parallel (Slack + SIEM + Zapier + …) with per-URL HMAC-SHA256 secrets and optional per-URL custom HTTP headers (Authorization, API keys, etc.).
- In-app Directus notifications to specific admin users on every infection.
- Foreign-key-backed relations from the audit log to
directus_filesanddirectus_users, withON DELETE SET NULLso audit history survives file/user deletion. - Bypass rules by MIME allowlist/blocklist, folder UUID, role UUID, or file size.
- Configurable retry, timeout, and fail-open vs fail-closed behaviour.
- Production-grade error handling — the upload pipeline never breaks because of a scanner outage.
- Opt-in
FILE_SCANNER_RESET_METAfor adopting new field-meta defaults on existing installs without clobbering hand-tuned UI.
Self-hosted only
This extension needs the ClamAV daemon reachable from the Directus process. Three deployment shapes are supported:
| Shape | FILE_SCANNER_MODE | When to use |
|---|---|---|
| Sibling container in the same Compose / Pod / network | tcp (default) | Easiest — start with the Quick start below. |
| Centralised remote ClamAV on its own host (a security team's shared scanner, dedicated AV VM, k8s service in another namespace) | tcp with FILE_SCANNER_HOST / FILE_SCANNER_PORT set to wherever clamd lives | Many Directus instances → one ClamAV cluster. |
| Unix socket mounted into the Directus container | socket with FILE_SCANNER_SOCKET | Same host, lowest latency, no TCP overhead. |
| Local binary (clamscan / clamdscan baked into the Directus image) | local | Single-container deployments without a separate daemon. |
Directus Cloud is not supported. The managed cloud doesn't let you run a ClamAV daemon next to it, doesn't expose the local-disk path the scanner needs, and doesn't allow custom outbound rules to reach a remote ClamAV box. Use this on self-hosted Directus only — Docker Compose, Kubernetes, bare metal, or any container platform you control.
For a remote ClamAV deployment, point the host/port at it:
environment:
FILE_SCANNER_MODE: tcp
FILE_SCANNER_HOST: clamav.security.internal
FILE_SCANNER_PORT: "3310"Make sure the daemon's clamd.conf allows TCP from your Directus host (TCPAddr 0.0.0.0 and matching firewall rules), and bump StreamMaxLength if you scan files larger than 25 MB.
Installation
# pick whichever package manager your project uses
pnpm add directus-extension-file-scanner
# or
npm install directus-extension-file-scannerAdd the package to the extensions directory mounted into your Directus container.
Quick start (Docker Compose)
- Copy
docker-compose.ymlfrom this repo into your project asdocker-compose.yml. - Set
FILE_SCANNER_*env vars to taste (defaults are sensible). - Run
docker compose up -d. ClamAV may take 1–2 minutes on first boot to download virus signatures. - Upload a file in the Directus File Library — a row should appear in
virus_scan_logs.
Configuration
All configuration is via environment variables. No UI is required (or shipped).
| Env var | Default | Description |
|---|---|---|
| FILE_SCANNER_ENABLED | true | Master kill switch. |
| FILE_SCANNER_BACKEND | clamav | Only clamav is supported. |
| FILE_SCANNER_MODE | tcp | ClamAV mode: local | tcp | socket. |
| FILE_SCANNER_HOST | clamav | TCP host (matches the canonical Compose service name). |
| FILE_SCANNER_PORT | 3310 | TCP port. |
| FILE_SCANNER_SOCKET | — | Unix socket path (used when FILE_SCANNER_MODE=socket). |
| FILE_SCANNER_CLAMSCAN_PATH | /usr/bin/clamscan | Path to local clamscan binary. |
| FILE_SCANNER_CLAMDSCAN_PATH | /usr/bin/clamdscan | Path to clamdscan binary. |
| FILE_SCANNER_PREFER_DAEMON | true | Prefer clamdscan over clamscan when both are available. |
| FILE_SCANNER_TIMEOUT_MS | 60000 | Per-scan timeout (milliseconds). |
| FILE_SCANNER_MAX_FILE_SIZE_BYTES | 104857600 (100 MB) | Skip scanning files larger than this many bytes; logged as skipped. |
| FILE_SCANNER_ON_INFECTED | delete | delete | quarantine | log-only. |
| FILE_SCANNER_ON_SCAN_ERROR | allow | allow (fail-open) | delete (fail-closed). |
| FILE_SCANNER_RETRY_COUNT | 2 | Connection-error retries before giving up. |
| FILE_SCANNER_RETRY_DELAY_MS | 2000 | Delay between retries (milliseconds). |
| FILE_SCANNER_MIME_ALLOWLIST | — | Comma-separated MIME types — only these are scanned (empty = scan everything). |
| FILE_SCANNER_MIME_BLOCKLIST | — | Comma-separated MIME types to skip scanning. |
| FILE_SCANNER_BYPASS_FOLDERS | — | Comma-separated Directus folder UUIDs whose uploads skip scanning. |
| FILE_SCANNER_BYPASS_ROLES | — | Comma-separated Directus role UUIDs whose uploads skip scanning. |
| FILE_SCANNER_WEBHOOK_URL | — | Optional webhook URL(s). Comma-separated to fan out to multiple receivers (Slack + SIEM + Zapier, etc.). |
| FILE_SCANNER_WEBHOOK_SECRET | — | HMAC-SHA256 signing secret(s). One value = shared across all URLs. Comma-separated with same count as URLs = positional, one secret per URL. |
| FILE_SCANNER_CONFIG_FILE | — | Optional path to a JSON file with advanced per-URL config (custom HTTP headers, etc.). See "Advanced webhook config" below. |
| FILE_SCANNER_NOTIFY_USERS | — | Comma-separated Directus user UUIDs to notify in-app on every infection. |
| FILE_SCANNER_RESET_META | false | One-shot meta refresh on existing installs (see "Migrations & schema updates" below). |
| FILE_SCANNER_DEBUG | false | Verbose logging. |
If FILE_SCANNER_BACKEND is set to anything other than clamav, the extension logs a warning and falls back to clamav.
Audit log schema
The extension creates a virus_scan_logs collection on first start (if it does not already exist). Every scan attempt — clean, infected, error, or skipped — produces one row.
| Field | Type | Notes |
|---|---|---|
| id | uuid (PK) | |
| file_id | uuid (m2o → directus_files.id) | nullable — the file may have been deleted |
| filename | string | snapshot of payload.filename_download |
| filename_disk | string | snapshot of payload.filename_disk |
| mime_type | string | |
| file_size | bigInteger | bytes |
| scan_result | string | clean | infected | scan_error | skipped |
| viruses | json | array of virus names (when infected) |
| action_taken | string | none | deleted | quarantined | logged |
| scanner_backend | string | which backend ran the scan (always clamav) |
| scanner_version | string | e.g. ClamAV 1.4.2 |
| scan_duration_ms | integer | |
| scanned_at | timestamp | |
| error_message | text | populated on scan_error and on skipped (bypass:<reason>) |
| triggered_by | uuid (m2o → directus_users.id) | uploader |
A boolean quarantine column is added to directus_files on first start, used by the quarantine action.
Webhook configuration
Webhooks have three layered configurations to keep the simple cases simple and the complex cases possible:
Tier 1 — single URL, single secret (the 90% case)
FILE_SCANNER_WEBHOOK_URL: https://hooks.slack.com/services/T00/B00/XXX
FILE_SCANNER_WEBHOOK_SECRET: my-shared-secretTier 2 — multiple URLs in env
URLs comma-separated. Secrets either 1 (shared) or exactly N (positional).
# All three URLs share the same secret:
FILE_SCANNER_WEBHOOK_URL: https://slack/x,https://siem/in,https://zapier/y
FILE_SCANNER_WEBHOOK_SECRET: shared-secret
# One secret per URL, by position:
FILE_SCANNER_WEBHOOK_URL: https://slack/x,https://siem/in,https://zapier/y
FILE_SCANNER_WEBHOOK_SECRET: slack-sec,siem-sec,zapier-secIf the secret count is neither 1 nor matches the URL count, the extension logs a warning and sends env URLs unsigned (refusing to sign with a possibly-wrong key is safer than guessing).
Tier 3 — config file (custom HTTP headers, full per-URL control)
Some receivers want bearer tokens, API keys, custom routing headers — env vars get awkward fast for that. Set FILE_SCANNER_CONFIG_FILE to a JSON file mounted into the container. A ready-to-edit example ships at samples/file-scanner.config.example.json.
volumes:
- ./file-scanner.config.json:/directus/extensions/directus-extension-file-scanner/config.json:ro
environment:
FILE_SCANNER_CONFIG_FILE: /directus/extensions/directus-extension-file-scanner/config.jsonSchema (file-scanner.config.json):
{
"webhooks": [
{
"url": "https://siem.example.com/ingest",
"secret": "siem-shared-key",
"headers": {
"Authorization": "Bearer abc123",
"X-API-Key": "xyz"
}
},
{
"url": "https://internal-bot.example.com/alerts"
}
]
}url(required, string).secret(optional, string) — per-URL HMAC secret. Independent ofFILE_SCANNER_WEBHOOK_SECRETfor env URLs; missing here means this URL is sent unsigned.headers(optional, object) — extra request headers, merged into the default set. Cannot overrideContent-Type,User-Agent, orX-File-Scanner-Signature— those three are framework-controlled.
You can mix tiers freely. Env entries are sent first, then file entries; both fire in parallel for each infection.
Webhook payload
When a file is flagged infected and at least one webhook target is configured, the extension POSTs (in parallel, once per configured target):
{
"event": "file_scanner.infected",
"scan_log_id": "9f1f5...",
"file_id": "61b23...",
"filename": "evil.exe",
"mime_type": "application/x-msdownload",
"file_size": 13525,
"viruses": ["Win.Test.EICAR_HDB-1"],
"action_taken": "deleted",
"triggered_by": "27f0a...",
"scanner_backend": "clamav",
"scanner_version": "ClamAV 1.4.2",
"scanned_at": "2026-05-07T12:34:56.789Z",
"directus_url": "https://cms.example.com"
}If a target has a secret (via env or the config file), the JSON body is signed with that target's HMAC-SHA256 key and the signature is sent in the X-File-Scanner-Signature header (lowercase hex, prefixed sha256=).
Verifying the signature (Node.js)
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
if (!header?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = header.slice('sha256='.length);
if (received.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}Migrations & schema updates
The extension creates virus_scan_logs and the quarantine column on first start. After that, it never touches your collection's field metadata unless you opt in. This is deliberate — many users hand-tune the admin UI (groups, widths, labels, custom interfaces) and we don't want to clobber that on every restart.
When a new release ships with improved field defaults (better widths, new displays, friendlier interfaces), you have two options:
Option 1 — Keep your current layout
Do nothing. Your existing fields stay exactly as you've configured them. New fields added in future releases are still auto-created.
Option 2 — Adopt the new defaults (one-shot reset)
Set the env var and restart Directus once:
FILE_SCANNER_RESET_META: "true"The extension will:
- Log a warning at startup so you don't leave it on by accident.
- Loop every field in
virus_scan_logsand callFieldsService.updateFieldwith the shipped meta — widths, displays, interfaces, options, sort order, the lot. - Leave the data, FK relations, and the
quarantinecolumn ondirectus_filesuntouched.
After the boot completes, remove or set FILE_SCANNER_RESET_META: "false" — leaving it on means every restart overwrites any hand-tuning you do afterwards.
This is the same pattern Directus core uses for its own bootstrap-time schema reseeding: a deliberate, opt-in, one-time operation.
The primary-key
idfield cannot be updated throughFieldsService(Directus rejects PK changes). The reset run logs aFailed to refresh meta for virus_scan_logs.idwarning and continues with the remaining 14 fields. This is expected.
In-app notifications
When FILE_SCANNER_NOTIFY_USERS is set to a comma-separated list of Directus user UUIDs, the extension creates one row per user in directus_notifications on every infection. The recipients see them in the bell icon in the Directus admin UI and via Directus's native notification preferences (in-app, email, etc., depending on each user's settings).
The notification links to directus_files/<file_id> when the file row still exists (e.g. quarantine mode), otherwise to virus_scan_logs/<scan_log_id>.
This complements the webhook: webhooks fan out to external systems (Slack, SIEM, automation), while notifications target individual humans inside Directus.
EICAR test
Use the EICAR antivirus test file to verify the integration. The simplest path:
- Save the EICAR test string into a file:
into a file namedX5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*eicar.com.txt. - Upload it through the Directus File Library.
- With the default config (
FILE_SCANNER_ON_INFECTED=delete):- the file is removed from File Library, and
- a row appears in
virus_scan_logswithscan_result=infected,action_taken=deleted, andvirusescontaining anEICARentry.
- If you configured a webhook, you should also see one POST to your endpoint.
Compatibility
- Directus
^11.0.0(verified against11.17.4). - ClamAV
>=1.0(any modern release with daemon support; verified againstclamav:stable). - Node.js
>=20(Directus 11.17 ships Node 22 in its container image).
What's verified end-to-end
Beyond the 87-test unit suite (run on every CI build), the following paths have been smoke-tested live against a Directus 11.17.4 + Postgres 16 + ClamAV stable stack:
| Path | Verified |
|---|---|
| TCP scanner mode (sibling container) | ✅ EICAR detected, scan duration < 100 ms |
| Clean file → scan_result=clean audit row | ✅ |
| EICAR delete action → file gone + audit row + file_id=null (FK preserved) | ✅ |
| EICAR quarantine action → quarantine=true on directus_files, file retained | ✅ |
| Scanner outage (ClamAV down) → scan_error row, fail-open keeps file | ✅ |
| Oversize bypass → skipped row, file retained | ✅ |
| Role bypass → skipped row, file retained, EICAR not deleted | ✅ |
| virus_scan_logs collection + 15 fields + 2 FK relations auto-created | ✅ |
| Orphan-reference cleanup before adding FK constraints | ✅ |
| FILE_SCANNER_RESET_META refresh of 14 fields (PK skipped, expected) | ✅ |
| In-app directus_notifications row on infection, links to audit log | ✅ |
| Multi-URL webhook fan-out (2 env URLs + 1 file URL → 3 parallel POSTs) | ✅ |
| Positional secrets (SECRET=a,b matched 1:1 with URL=x,y) → distinct sigs per URL | ✅ |
| HMAC-SHA256 signature correctness — each delivery verifies against its own secret | ✅ |
| Custom HTTP headers from config file (Authorization, X-API-Key) arrive on the wire | ✅ |
| Reserved-header protection — config-file attempts to override Content-Type / User-Agent / X-File-Scanner-Signature are ignored | ✅ |
| Single JSON body across fanned-out targets (byte-identical) | ✅ |
Not verified live but logic-tested in the unit suite: socket mode, local-binary mode, malformed config file, mismatched secret count, webhook retry on 5xx. Volunteers welcome.
Local development
This repo uses pnpm (version pinned via packageManager in package.json).
git clone https://github.com/khanahmad4527/directus-extension-file-scanner.git
cd directus-extension-file-scanner
pnpm install --frozen-lockfile
pnpm dev # rebuilds dist/ on save
pnpm run link # symlinks into a local Directus install
pnpm test # runs the unit tests (vitest)
pnpm test:watch # vitest in watch mode
pnpm test:coverage # full coverage report → ./coverage/index.html
pnpm typecheck # tsc --noEmit
pnpm build # production build → dist/index.jsIf you don't have pnpm installed:
corepack enable # ships with Node 16+
corepack prepare pnpm@latest --activateThe extension is built with @directus/extensions-sdk, which handles bundling and emits dist/index.js.
Troubleshooting
ECONNREFUSED clamav:3310— the ClamAV container is not yet running or healthy. ClamAV can take 1–2 minutes on first boot to load its signature database. The provided Compose file usesdepends_on.condition: service_healthyto wait it out.Empty database — clamd database is empty— ClamAV signature DB has not been downloaded yet. Wait forfreshclamto complete on first boot, or look at theclamavcontainer logs.- Scans take very long — increase
FILE_SCANNER_TIMEOUT_MS, or restrict scanning withFILE_SCANNER_MIME_ALLOWLIST/FILE_SCANNER_MAX_FILE_SIZE_BYTES. scan_result=skippedwitherror_message=bypass:non-local-storage— only files written to thelocalstorage adapter are scanned. S3/R2/GCS uploads are not supported.- The audit collection never appears — check the Directus logs at startup for
[file-scanner]entries. The collection is created at boot via Directus init hooks (routes.custom.after/app.after), and as a belt-and-brace fallback on the first upload. The Directus admin role must have schema-modification permissions for the bootstrap to succeed. Failed to refresh meta for virus_scan_logs.idafter settingFILE_SCANNER_RESET_META=true— expected. The primary-key field is rejected byFieldsService.updateField. The other 14 fields are still refreshed.Webhook delivery failed after retries— receiver returned non-2xx or threw on both attempts. Each URL is independent — other webhooks still deliver. Check the receiver, not Directus.FILE_SCANNER_WEBHOOK_SECRET has N entries but FILE_SCANNER_WEBHOOK_URL has M URL(s)— secret count must be 1 (shared) or exactly equal to the URL count (positional). On mismatch the extension refuses to sign rather than guessing.
License
Author
Ahmad Khan — GitHub
