npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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_logs collection, 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_files and directus_users, with ON DELETE SET NULL so 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_META for 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-scanner

Add the package to the extensions directory mounted into your Directus container.

Quick start (Docker Compose)

  1. Copy docker-compose.yml from this repo into your project as docker-compose.yml.
  2. Set FILE_SCANNER_* env vars to taste (defaults are sensible).
  3. Run docker compose up -d. ClamAV may take 1–2 minutes on first boot to download virus signatures.
  4. 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-secret

Tier 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-sec

If 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.json

Schema (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 of FILE_SCANNER_WEBHOOK_SECRET for env URLs; missing here means this URL is sent unsigned.
  • headers (optional, object) — extra request headers, merged into the default set. Cannot override Content-Type, User-Agent, or X-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:

  1. Log a warning at startup so you don't leave it on by accident.
  2. Loop every field in virus_scan_logs and call FieldsService.updateField with the shipped meta — widths, displays, interfaces, options, sort order, the lot.
  3. Leave the data, FK relations, and the quarantine column on directus_files untouched.

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 id field cannot be updated through FieldsService (Directus rejects PK changes). The reset run logs a Failed to refresh meta for virus_scan_logs.id warning 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:

  1. Save the EICAR test string into a file:
    X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
    into a file named eicar.com.txt.
  2. Upload it through the Directus File Library.
  3. With the default config (FILE_SCANNER_ON_INFECTED=delete):
    • the file is removed from File Library, and
    • a row appears in virus_scan_logs with scan_result=infected, action_taken=deleted, and viruses containing an EICAR entry.
  4. If you configured a webhook, you should also see one POST to your endpoint.

Compatibility

  • Directus ^11.0.0 (verified against 11.17.4).
  • ClamAV >=1.0 (any modern release with daemon support; verified against clamav: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.js

If you don't have pnpm installed:

corepack enable      # ships with Node 16+
corepack prepare pnpm@latest --activate

The 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 uses depends_on.condition: service_healthy to wait it out.
  • Empty database — clamd database is empty — ClamAV signature DB has not been downloaded yet. Wait for freshclam to complete on first boot, or look at the clamav container logs.
  • Scans take very long — increase FILE_SCANNER_TIMEOUT_MS, or restrict scanning with FILE_SCANNER_MIME_ALLOWLIST / FILE_SCANNER_MAX_FILE_SIZE_BYTES.
  • scan_result=skipped with error_message=bypass:non-local-storage — only files written to the local storage 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.id after setting FILE_SCANNER_RESET_META=true — expected. The primary-key field is rejected by FieldsService.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

MIT

Author

Ahmad KhanGitHub