prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
/**
|
|
|
|
|
* A minimal client for clamd — ClamAV's long-running daemon.
|
|
|
|
|
*
|
|
|
|
|
* We speak the INSTREAM command over TCP. The protocol is small enough that
|
|
|
|
|
* reaching for a third-party clamd client would obscure what we do more than
|
|
|
|
|
* explain it:
|
|
|
|
|
*
|
|
|
|
|
* client → server: "zINSTREAM\0"
|
|
|
|
|
* client → server: <4-byte big-endian length><chunk> (repeated)
|
|
|
|
|
* client → server: <4-byte 0> (end-of-stream)
|
|
|
|
|
* client ← server: "stream: OK\0" (clean)
|
|
|
|
|
* or "stream: <SignatureName> FOUND\0" (infected)
|
|
|
|
|
* or "ERROR: <message>\0" (operational problem)
|
|
|
|
|
*
|
|
|
|
|
* Reference: https://docs.clamav.net/manual/Usage/Scanning.html#clamd-protocol
|
|
|
|
|
*
|
|
|
|
|
* The `MOCK_SCAN` env var short-circuits all of this for local development on a
|
|
|
|
|
* box without ClamAV installed. Values: `clean`, `infected`, `error`.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { connect } from 'node:net';
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
import { createHash } from 'node:crypto';
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
import { PassThrough } from 'node:stream';
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
import { lookupBySha256 as vtLookupBySha256, isEnabled as vtEnabled } from './virustotal.js';
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
|
|
|
|
|
const CLAMD_HOST = process.env.CLAMD_HOST || '127.0.0.1';
|
|
|
|
|
const CLAMD_PORT = Number(process.env.CLAMD_PORT) || 3310;
|
|
|
|
|
const CLAMD_CHUNK = 65536; // 64 KiB per INSTREAM frame
|
|
|
|
|
const CLAMD_SOCKET_TIMEOUT = 60_000; // 60 s per scan — generous for big files
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scan a stream through clamd and return the verdict.
|
|
|
|
|
*
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
* When `opts.sha256` is passed (uploads.js always does, since the hash is
|
|
|
|
|
* computed on the way in), and VIRUSTOTAL_API_KEY is set, we query VirusTotal
|
|
|
|
|
* by that hash after the clamd scan and attach the finding as `virustotal`.
|
|
|
|
|
* The call never blocks or changes the verdict — enrichment is informational.
|
|
|
|
|
*
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
* @param {NodeJS.ReadableStream} source the file bytes
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
* @param {{sha256?: string}} [opts]
|
|
|
|
|
* @returns {Promise<{verdict: 'clean'|'infected'|'error', engine: string, engine_version?: string, signatures: string[], raw: string, duration_ms: number, virustotal?: object}>}
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
*/
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
export async function scanStream(source, opts = {}) {
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
const mock = process.env.MOCK_SCAN;
|
|
|
|
|
if (mock) {
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
const result = await mockScan(mock, source);
|
|
|
|
|
return attachVt(result, opts.sha256);
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
}
|
|
|
|
|
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
const result = await clamdScan(source);
|
|
|
|
|
return attachVt(result, opts.sha256);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enrich a scan result with a VirusTotal verdict when enrichment is enabled
|
|
|
|
|
* and we have a hash to query by. Never raises — the enrichment is best-effort.
|
|
|
|
|
*/
|
|
|
|
|
async function attachVt(result, sha256) {
|
|
|
|
|
if (!vtEnabled() || !sha256) return result;
|
|
|
|
|
if (result.verdict === 'error') return result; // nothing useful to enrich
|
|
|
|
|
try {
|
|
|
|
|
const vt = await vtLookupBySha256(sha256);
|
|
|
|
|
if (vt) result.virustotal = vt;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
result.virustotal = { status: 'error', reason: String(err?.message || err) };
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clamdScan(source) {
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
const started = Date.now();
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const sock = connect({ host: CLAMD_HOST, port: CLAMD_PORT });
|
|
|
|
|
sock.setTimeout(CLAMD_SOCKET_TIMEOUT);
|
|
|
|
|
|
|
|
|
|
let response = Buffer.alloc(0);
|
|
|
|
|
let settled = false;
|
|
|
|
|
|
|
|
|
|
const finish = (result) => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
sock.destroy();
|
|
|
|
|
result.duration_ms = Date.now() - started;
|
|
|
|
|
resolve(result);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fail = (err) => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
sock.destroy();
|
|
|
|
|
// Operational problems (daemon down, network error, timeout) don't make
|
|
|
|
|
// the file infected — they make the scan inconclusive. The caller decides
|
|
|
|
|
// what to do with that; we never pretend a scan happened when it didn't.
|
|
|
|
|
resolve({
|
|
|
|
|
verdict: 'error',
|
|
|
|
|
engine: 'clamav',
|
|
|
|
|
signatures: [],
|
|
|
|
|
raw: '',
|
|
|
|
|
error: String(err?.message || err),
|
|
|
|
|
duration_ms: Date.now() - started,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
sock.on('error', fail);
|
|
|
|
|
sock.on('timeout', () => fail(new Error('clamd socket timeout')));
|
|
|
|
|
|
|
|
|
|
sock.on('data', (chunk) => {
|
|
|
|
|
response = Buffer.concat([response, chunk]);
|
|
|
|
|
// Response is a single null-terminated string.
|
|
|
|
|
if (response.includes(0)) {
|
|
|
|
|
const text = response.subarray(0, response.indexOf(0)).toString('utf8');
|
|
|
|
|
finish(parseVerdict(text));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sock.on('close', () => {
|
|
|
|
|
if (!settled) {
|
|
|
|
|
fail(new Error('clamd closed connection without a verdict'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sock.on('connect', () => {
|
|
|
|
|
sock.write('zINSTREAM\0');
|
|
|
|
|
|
|
|
|
|
// Forward the source through the framing format clamd expects.
|
|
|
|
|
source.on('error', fail);
|
|
|
|
|
|
|
|
|
|
source.on('data', (chunk) => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
|
|
|
// One source chunk may be bigger than our 64 KiB frame cap; slice it.
|
|
|
|
|
for (let i = 0; i < buf.length; i += CLAMD_CHUNK) {
|
|
|
|
|
const slice = buf.subarray(i, Math.min(i + CLAMD_CHUNK, buf.length));
|
|
|
|
|
const header = Buffer.alloc(4);
|
|
|
|
|
header.writeUInt32BE(slice.length, 0);
|
|
|
|
|
sock.write(header);
|
|
|
|
|
sock.write(slice);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
source.on('end', () => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
const terminator = Buffer.alloc(4); // 0 means end-of-stream
|
|
|
|
|
sock.write(terminator);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
|
|
prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY
After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
2026-10-08 21:44:34 +02:00
|
|
|
// The hash we would compute during a scan if callers did not pass sha256.
|
|
|
|
|
// Not used yet — kept here so the import stays alive and the shape is obvious
|
|
|
|
|
// if we ever need to hash in-stream (e.g. a /api/scan endpoint that takes a
|
|
|
|
|
// stream directly without storing it on disk first).
|
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
|
|
|
function _makeHasher() { return createHash('sha256'); }
|
|
|
|
|
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
/**
|
|
|
|
|
* Translate clamd's response line into a structured verdict.
|
|
|
|
|
*/
|
|
|
|
|
function parseVerdict(text) {
|
|
|
|
|
const trimmed = text.trim();
|
|
|
|
|
|
|
|
|
|
if (/\bOK\b/.test(trimmed) && !/FOUND/i.test(trimmed)) {
|
|
|
|
|
return { verdict: 'clean', engine: 'clamav', signatures: [], raw: trimmed };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const foundMatch = trimmed.match(/stream:\s*(.+?)\s+FOUND/i);
|
|
|
|
|
if (foundMatch) {
|
|
|
|
|
return {
|
|
|
|
|
verdict: 'infected',
|
|
|
|
|
engine: 'clamav',
|
|
|
|
|
signatures: [foundMatch[1].trim()],
|
|
|
|
|
raw: trimmed,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Anything that didn't match the two happy shapes is an operational error.
|
|
|
|
|
return {
|
|
|
|
|
verdict: 'error',
|
|
|
|
|
engine: 'clamav',
|
|
|
|
|
signatures: [],
|
|
|
|
|
raw: trimmed,
|
|
|
|
|
error: trimmed,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Local-dev short-circuit. Drains the source so the caller's upstream code
|
|
|
|
|
* (SHA computation, byte accounting) still gets the data.
|
|
|
|
|
*/
|
|
|
|
|
async function mockScan(mode, source) {
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
// Drain the source — the caller's hash/size pipeline needs the bytes to
|
|
|
|
|
// flow through end-to-end even when we aren't really scanning.
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
const sink = new PassThrough();
|
|
|
|
|
source.on('error', reject);
|
|
|
|
|
sink.on('error', reject);
|
|
|
|
|
sink.on('finish', resolve);
|
|
|
|
|
source.pipe(sink);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const duration_ms = Date.now() - started;
|
|
|
|
|
if (mode === 'infected') {
|
|
|
|
|
return {
|
|
|
|
|
verdict: 'infected',
|
|
|
|
|
engine: 'mock',
|
|
|
|
|
signatures: ['Mock.Test.Sample'],
|
|
|
|
|
raw: 'MOCK_SCAN=infected',
|
|
|
|
|
duration_ms,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (mode === 'error') {
|
|
|
|
|
return {
|
|
|
|
|
verdict: 'error',
|
|
|
|
|
engine: 'mock',
|
|
|
|
|
signatures: [],
|
|
|
|
|
raw: 'MOCK_SCAN=error',
|
|
|
|
|
error: 'MOCK_SCAN=error forced an operational failure',
|
|
|
|
|
duration_ms,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
verdict: 'clean',
|
|
|
|
|
engine: 'mock',
|
|
|
|
|
signatures: [],
|
|
|
|
|
raw: 'MOCK_SCAN=clean',
|
|
|
|
|
duration_ms,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One-shot "is clamd alive" check, for the healthcheck endpoint.
|
|
|
|
|
*/
|
|
|
|
|
export async function clamdPing() {
|
|
|
|
|
if (process.env.MOCK_SCAN) {
|
|
|
|
|
return { ok: true, engine: 'mock' };
|
|
|
|
|
}
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const sock = connect({ host: CLAMD_HOST, port: CLAMD_PORT });
|
|
|
|
|
sock.setTimeout(2000);
|
|
|
|
|
let data = '';
|
|
|
|
|
const done = (ok, err = '') => {
|
|
|
|
|
sock.destroy();
|
|
|
|
|
resolve(ok ? { ok: true, engine: 'clamav' } : { ok: false, error: err });
|
|
|
|
|
};
|
|
|
|
|
sock.on('connect', () => sock.write('zPING\0'));
|
|
|
|
|
sock.on('data', (c) => {
|
|
|
|
|
data += c.toString('utf8');
|
|
|
|
|
if (data.includes('PONG')) done(true);
|
|
|
|
|
});
|
|
|
|
|
sock.on('error', (e) => done(false, String(e?.message || e)));
|
|
|
|
|
sock.on('timeout', () => done(false, 'timeout'));
|
|
|
|
|
sock.on('close', () => done(data.includes('PONG')));
|
|
|
|
|
});
|
|
|
|
|
}
|