av-scanner-v6 is the Node 22/TypeScript replacement for
submission-scanner-processor. It consumes the existing avscan.action.scan
contract with @platformatic/kafka, streams S3 objects through a ClamAV
sidecar, optionally moves them to clean or quarantine storage, and delivers the
same Bus API or webhook result contract.
The /health endpoint is intentionally ClamAV-dependent. Every request sends a
new bounded PING command to clamd; connection refusal, timeout, or any
response other than PONG returns HTTP 503. Kafka is excluded from this check
so broker reconnects and group rebalances do not cause ECS task churn.
- Node.js
22.23.1, pinned by.nvmrcand the container image manifest digest. - ClamAV
1.5.3, pinned by the sidecar image manifest digest. - TypeScript with ESM output.
@platformatic/kafkawithautocommit: false, committed offsets, and the legacylatestfallback for a new consumer group.- AWS SDK v3
HeadObjectbeforeGetObject; object bodies are never buffered. - Partition-aware Kafka scheduling preserves offset order within each partition
while allowing up to
SCAN_CONCURRENCYrecords from different partitions to progress. A second FIFO semaphore applies the same hard limit at clamd. - Clamd
INSTREAMframes are bounded to 64 KiB and the complete scan has a configurable timeout. - Invalid JSON, envelope-topic mismatches, and deterministic schema failures are logged, discarded, and committed so one poison event cannot restart-loop the task. Operational failures stop intake and leave the failed offset uncommitted; already-active work in other partitions is allowed to settle.
Always select the project Node version first:
nvm use
pnpm install --frozen-lockfile
pnpm lint
pnpm build
pnpm test
pnpm startFor local ClamAV plus the app, copy .env.example to .env, supply Kafka and
AWS/Auth0 values, then run:
docker compose up --buildClamAV may take several minutes to download and load its initial signature
database. The Compose and ECS examples use a 180-second health start period.
Compose health checks provide readiness gating and health visibility; an
unhealthy status alone does not trigger restart: unless-stopped. That restart
policy acts when a container process exits. depends_on waits for the initial
ClamAV healthy state before starting the app, but does not restart the app if
ClamAV later becomes unhealthy.
The active review-api-v6 and admin UI producers send this shape:
{
"topic": "avscan.action.scan",
"originator": "review-api-v6",
"timestamp": "2026-07-19T00:00:00.000Z",
"mime-type": "application/json",
"payload": {
"submissionId": "submission-id",
"url": "https://s3.amazonaws.com/source-bucket/submission.zip",
"fileName": "submission.zip",
"moveFile": true,
"cleanDestinationBucket": "clean-bucket",
"quarantineDestinationBucket": "quarantine-bucket",
"callbackOption": "kafka",
"callbackKafkaTopic": "submission.scan.complete"
}
}The envelope is strict. Unknown payload business fields are retained. Callback mode, webhook method, and webhook authentication values are normalized to lowercase.
Supported callback modes are:
kafka: post a complete result envelope toBUSAPI_EVENTS_URL, authenticated with an Auth0 client-credentials token. Bus API publishes it tocallbackKafkaTopic.webhook: send the result payload directly withgetorpostandno-auth,bearer,basic, orapi-keyauthentication.no-callback: complete scanning and optional movement without notification.
When moveFile is true, both destination buckets are required. The source is
copied to the selected bucket under fileName, the callback succeeds, and the
Kafka offset commits before the original source is deleted. This ordering keeps
the source available if callback delivery or commit must retry. Whitelists
restrict values when configured; an empty list retains legacy unrestricted
behavior.
A normal callback to submission.scan.complete retains the existing envelope:
{
"topic": "submission.scan.complete",
"originator": "file-scanner-processor",
"timestamp": "2026-07-19T00:00:01.000Z",
"mime-type": "application/json",
"payload": {
"submissionId": "submission-id",
"url": "https://s3.amazonaws.com/clean-bucket/submission.zip",
"fileName": "submission.zip",
"status": "scanned",
"isInfected": false
}
}The callback payload omits scanner controls (moveFile, destination buckets,
and callback settings). Infected objects use isInfected: true and are routed
to quarantine when movement is enabled.
Moved result URLs percent-encode each fileName path segment. This intentionally
hardens the legacy raw-string URL for keys containing spaces, Unicode, #, ?,
or %; downstream consumers should treat the value as a URL rather than compare
it byte-for-byte with the request filename.
The default maximum is exactly 500 MiB. A larger S3 object is rejected from
metadata without opening its body. A clamd INSTREAM size-limit error is
handled the same way. Both produce a fail-closed result:
{
"status": "scan-failed",
"isInfected": true,
"scanError": "file-size-exceeded",
"scanErrorMessage": "diagnostic detail"
}S3, ClamAV, destination-copy, callback, and commit errors leave the failed Kafka offset uncommitted. Per-partition ordering prevents a later record in that partition from committing past it. Callback or commit failure leaves the source object in place; repeating the destination copy is safe because it targets the same bucket and key. After a successful commit, source deletion is best-effort: a deletion failure is logged and can leave the original copy behind, but the completed event is not replayed.
Kafka processing and external callbacks are intentionally at-least-once. A
process crash or forced task stop after a remote callback succeeds but before
its offset commits can deliver that callback again on replay. Callback
consumers should deduplicate using stable business identifiers such as
submissionId; no Kafka consumer can make an arbitrary HTTP side effect and a
Kafka offset commit atomic.
GET /health and HEAD /health return 200 only after a fresh clamd PING:
{ "status": "ok", "checks": { "clamav": { "status": "up" } } }If clamd is dead, starting, unreachable, slow, or returns malformed protocol data, the endpoint returns 503:
{ "status": "unhealthy", "checks": { "clamav": { "status": "down" } } }The app and ClamAV containers must both be essential in the ECS task. ECS does
not import a Dockerfile health check automatically, so the registered task
definition must declare the app container health command. Ready-to-adapt
artifacts are provided as a registration-ready Fargate skeleton in
deployment/ecs-task-definition.example.json and as a container-only fragment
in deployment/ecs-container-definitions.example.json. Replace their account,
region, role, image, logging, environment, and secret placeholders before use.
Remove optional secret entries that the deployment does not use, such as mTLS
material when that feature is disabled.
The examples set the app container's stopTimeout to the Fargate maximum of
120 seconds. This allows the SIGTERM handler's bounded in-flight Kafka drain
and resource-close phases to finish before ECS sends SIGKILL. ECS reverses the
startup dependency during a normal task shutdown, so the app is stopped before
its filescanner dependency and clamd remains available while active scans
settle. Use Fargate Linux platform version 1.3.0 or later for stopTimeout;
this ordering cannot preserve clamd if the sidecar itself crashes.
On SIGTERM, the app closes Kafka intake, skips prefetched work that has not
started, and gives already-running handlers up to 100 seconds to finish and
commit. Resource close then has a separate 10-second deadline. A single scan
may be configured for longer than that window (the default scan deadline is 300
seconds), so stopTimeout is a bounded best effort rather than an exactly-once
guarantee: ECS can still force-stop a long-running handler, leaving its offset
for the replacement task under the at-least-once behavior above.
The health command checks /health every 30 seconds with a five-second ECS
timeout; the application probe itself times out sooner
(CLAMAV_HEALTH_TIMEOUT_MS, default 2000 ms). After three failures, ECS marks
the essential app container unhealthy. An ECS Service with a desired count
greater than zero then stops and replaces that task. Health failure alone does
not stop or replace a standalone run-task task; it remains unhealthy until the
container recovers, exits, or external automation stops it. A ClamAV container
exit stops the task because the sidecar is essential. Confirm that the live
Service is using the newly registered task-definition revision—the example
files do not deploy themselves.
For ECS awsvpc tasks, set CLAMAV_HOST=127.0.0.1 because containers share a
network namespace. Docker Compose uses the filescanner service hostname.
Clamd binds 0.0.0.0:3310 so it is reachable across the Docker Compose network;
Compose deliberately publishes no host port for it. The same bind also places
the unauthenticated listener on an ECS task's awsvpc ENI even though the task
definition declares no port mapping for 3310. Attach a security group with no
inbound rule for port 3310 (and do not add a public-facing mapping); allow only
the traffic needed for the app health endpoint or load balancer.
Before registering the task definition, provide real values for every
placeholder, ensure the execution role can pull images and resolve referenced
secrets, and ensure the task role can read, copy, and delete the configured S3
objects. Run the scanner as an ECS Service when unhealthy-task replacement is
required, and alarm separately on Kafka consumer errors or lag because Kafka is
intentionally excluded from /health.
| Variable | Default | Purpose |
|---|---|---|
LOG_LEVEL |
info |
JSON logger level |
HOST / PORT |
0.0.0.0 / 3000 |
Health server bind address |
KAFKA_URL |
localhost:9092 |
Broker list; kafka:// and kafka+ssl:// cannot be mixed |
KAFKA_CLIENT_ID |
av-scanner-v6 |
Kafka client identifier |
KAFKA_GROUP_ID |
file-scanner-processor |
Existing consumer group |
KAFKA_ORIGINATOR |
file-scanner-processor |
Bus callback originator |
AVSCAN_TOPIC |
avscan.action.scan |
Input topic |
KAFKA_CLIENT_CERT / KAFKA_CLIENT_CERT_KEY |
unset | Optional mTLS PEM or file paths; must be paired |
KAFKA_CA_CERT |
unset | Optional CA PEM or file path |
KAFKA_CLIENT_CA |
unset | Legacy alias for KAFKA_CA_CERT |
KAFKA_REJECT_UNAUTHORIZED |
unset | TLS verification override; setting it also enables TLS |
KAFKA_SESSION_TIMEOUT_MS |
30000 |
Consumer group session timeout |
KAFKA_HEARTBEAT_INTERVAL_MS |
3000 |
Consumer heartbeat interval |
KAFKA_MAX_BYTES |
10485760 |
Maximum Kafka fetch size |
KAFKA_MAX_WAIT_TIME_MS |
500 |
Maximum Kafka fetch wait |
AWS_REGION |
us-east-1 |
Required S3 URL and client region |
MAX_SCAN_FILE_SIZE_BYTES |
524288000 |
Preflight object limit |
SCAN_CONCURRENCY |
1 |
Concurrent scans per task |
CLAMAV_HOST / CLAMAV_PORT |
filescanner / 3310 |
Clamd TCP endpoint |
CLAMAV_HEALTH_TIMEOUT_MS |
2000 |
Per-request PING timeout |
CLAMAV_SCAN_TIMEOUT_MS |
300000 |
Complete scan deadline |
WHITELISTED_CLEAN_BUCKETS |
empty | Comma-separated clean buckets |
WHITELISTED_QUARANTINE_BUCKETS |
empty | Comma-separated quarantine buckets |
WHITELISTED_KAFKA_TOPICS |
empty | Comma-separated callback topics |
BUSAPI_EVENTS_URL |
Topcoder dev events URL | Complete Bus API events endpoint |
AUTH0_URL |
unset | Complete Auth0 token endpoint |
AUTH0_AUDIENCE |
unset | Bus API token audience |
AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET |
unset | Client credentials for kafka callbacks |
AUTH0_PROXY_SERVER_URL |
unset | Optional Topcoder token proxy endpoint |
TOKEN_CACHE_TIME |
300 |
Maximum token cache lifetime in seconds |
WEBHOOK_TIMEOUT_MS |
15000 |
HTTP callback/Auth0 timeout |
kafka+ssl:// enables TLS and defaults to peer verification with Node's system
trust store. Bare brokers alongside it inherit the same TLS transport because
the Kafka client has one transport setting; explicit kafka:// and
kafka+ssl:// brokers therefore cannot be mixed. Setting
KAFKA_REJECT_UNAUTHORIZED by itself also enables TLS. Supplying legacy TLS
material to a bare or kafka:// broker remains supported and retains the
historical false verification default when no override is set. Production
deployments using that compatibility path should supply a trusted CA and set
KAFKA_REJECT_UNAUTHORIZED=true; CA-only server-authenticated TLS is supported
without a client certificate.
AWS credentials use the standard AWS SDK provider chain and are not scanner configuration.