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
|
|
|
/**
|
|
|
|
|
* POST /api/upload — accept a file, scan it, hand back a signed scan report.
|
|
|
|
|
*
|
|
|
|
|
* This endpoint is the floor of the submit path. It does not publish anything
|
|
|
|
|
* on chain, it does not collect payment, it does not need the publisher's
|
|
|
|
|
* identity. All it does is:
|
|
|
|
|
*
|
|
|
|
|
* 1. Receive the file bytes (multipart/form-data, `file` field).
|
|
|
|
|
* 2. Spool to a temp file while hashing to SHA-256.
|
|
|
|
|
* 3. Hand the file to clamd (or MOCK_SCAN) for a verdict.
|
|
|
|
|
* 4. Keep the file if clean, discard it otherwise.
|
|
|
|
|
* 5. Sign the verdict with the service key and return it.
|
|
|
|
|
*
|
|
|
|
|
* A clean upload leaves `uploads/<sha256>.bin` on disk plus a
|
|
|
|
|
* `uploads/<sha256>.json` sidecar with the metadata. The signed report in
|
|
|
|
|
* the response is the artifact the manifest-signing step will attach to the
|
|
|
|
|
* APS1 record later.
|
|
|
|
|
*
|
|
|
|
|
* The file-size cap is deliberately generous — apps can be big — but every
|
|
|
|
|
* byte over the cap is dropped and the upload rejected: letting one request
|
|
|
|
|
* fill the disk is worse than making a publisher try again.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createHash } from 'node:crypto';
|
|
|
|
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
|
|
|
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { resolve, dirname } from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
import { pipeline } from 'node:stream/promises';
|
|
|
|
|
import { PassThrough } from 'node:stream';
|
|
|
|
|
import Busboy from 'busboy';
|
|
|
|
|
import { scanStream } from './clamd.js';
|
|
|
|
|
import { signReport } from './signing.js';
|
|
|
|
|
|
|
|
|
|
const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR
|
|
|
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads');
|
|
|
|
|
|
|
|
|
|
// 2 GiB — the ceiling, not the typical size. Change via env rather than code
|
|
|
|
|
// so an operator can tighten it without a redeploy.
|
|
|
|
|
const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES)
|
|
|
|
|
|| 2 * 1024 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Express-style handler attached to `/api/upload`.
|
|
|
|
|
*/
|
|
|
|
|
export async function handleUpload(req, res) {
|
|
|
|
|
if (req.method !== 'POST') {
|
|
|
|
|
return respond(res, 405, { ok: false, error: 'method not allowed' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contentType = String(req.headers['content-type'] || '');
|
|
|
|
|
if (!contentType.startsWith('multipart/form-data')) {
|
|
|
|
|
return respond(res, 415, {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: 'content-type must be multipart/form-data with a `file` field',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const tempId = randomUUID();
|
|
|
|
|
const tempPath = resolve(UPLOAD_DIR, `.tmp-${tempId}`);
|
|
|
|
|
|
|
|
|
|
let gotFile = false;
|
|
|
|
|
let truncated = false;
|
|
|
|
|
let originalName = '';
|
|
|
|
|
let mimeType = 'application/octet-stream';
|
|
|
|
|
|
|
|
|
|
// We let busboy drive. The one 'file' event we care about forwards straight
|
|
|
|
|
// to a hashing + write pipeline; any extra fields are ignored.
|
|
|
|
|
const busboy = Busboy({
|
|
|
|
|
headers: req.headers,
|
|
|
|
|
limits: { fileSize: MAX_FILE_BYTES, files: 1 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hasher = createHash('sha256');
|
|
|
|
|
let byteCount = 0;
|
|
|
|
|
|
|
|
|
|
// We'd like to pipe the busboy file into TWO sinks: the temp file and the
|
|
|
|
|
// hasher. PassThrough splits a stream in one line: one upstream, two
|
|
|
|
|
// downstreams, no buffering we have to manage ourselves.
|
|
|
|
|
const toHash = new PassThrough();
|
|
|
|
|
const toDisk = new PassThrough();
|
|
|
|
|
toHash.on('data', (chunk) => {
|
|
|
|
|
hasher.update(chunk);
|
|
|
|
|
byteCount += chunk.length;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const writeToDisk = pipeline(toDisk, createWriteStream(tempPath));
|
|
|
|
|
|
|
|
|
|
const fileDone = new Promise((resolveFile, rejectFile) => {
|
|
|
|
|
busboy.on('file', (fieldname, file, info) => {
|
|
|
|
|
gotFile = true;
|
|
|
|
|
originalName = sanitize(info.filename || 'upload');
|
|
|
|
|
mimeType = info.mimeType || mimeType;
|
|
|
|
|
|
|
|
|
|
file.on('limit', () => { truncated = true; });
|
|
|
|
|
|
|
|
|
|
file.on('data', (chunk) => {
|
|
|
|
|
toHash.write(chunk);
|
|
|
|
|
toDisk.write(chunk);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
file.on('end', () => {
|
|
|
|
|
toHash.end();
|
|
|
|
|
toDisk.end();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
file.on('error', rejectFile);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
busboy.on('finish', resolveFile);
|
|
|
|
|
busboy.on('error', rejectFile);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Pump the request through busboy. If anything throws here we clean up and
|
|
|
|
|
// reply with 400 — we never leave a half-written temp file behind.
|
|
|
|
|
try {
|
|
|
|
|
req.pipe(busboy);
|
|
|
|
|
await fileDone;
|
|
|
|
|
await writeToDisk;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
await safeUnlink(tempPath);
|
|
|
|
|
return respond(res, 400, { ok: false, error: String(err?.message || err) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!gotFile) {
|
|
|
|
|
await safeUnlink(tempPath);
|
|
|
|
|
return respond(res, 400, { ok: false, error: 'no `file` field in request' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (truncated) {
|
|
|
|
|
await safeUnlink(tempPath);
|
|
|
|
|
return respond(res, 413, {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: `file exceeded the ${MAX_FILE_BYTES}-byte cap`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sha256 = hasher.digest('hex');
|
|
|
|
|
|
|
|
|
|
// Scan from the on-disk copy. Scanning in parallel with receive would
|
|
|
|
|
// save latency on huge files, but doubles the error surface — we would
|
|
|
|
|
// need to coordinate two independent failure modes. On-disk-then-scan is
|
|
|
|
|
// what every mature scanning service does; we can revisit once we have
|
|
|
|
|
// a reason.
|
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 scan = await scanStream(createReadStream(tempPath), { 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
|
|
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const upload = {
|
|
|
|
|
hash: sha256,
|
|
|
|
|
size: byteCount,
|
|
|
|
|
name: originalName,
|
|
|
|
|
mime: mimeType,
|
|
|
|
|
received_at: now,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let stored = false;
|
|
|
|
|
let finalPath = null;
|
|
|
|
|
|
|
|
|
|
if (scan.verdict === 'clean') {
|
|
|
|
|
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
|
|
|
|
|
try {
|
|
|
|
|
await rename(tempPath, finalPath);
|
|
|
|
|
stored = true;
|
|
|
|
|
await writeFile(
|
|
|
|
|
resolve(UPLOAD_DIR, `${sha256}.json`),
|
|
|
|
|
JSON.stringify({ upload, scan }, null, 2),
|
|
|
|
|
'utf8',
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// Rare: another concurrent upload of the same bytes won the race.
|
|
|
|
|
// The existing file is already correct; drop ours and move on.
|
|
|
|
|
await safeUnlink(tempPath);
|
|
|
|
|
stored = true;
|
|
|
|
|
finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Infected or error — the bytes do not stay on our disk.
|
|
|
|
|
await safeUnlink(tempPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The signed report is what callers persist. It carries the upload metadata
|
|
|
|
|
// AND the scan outcome, so a downstream verifier can confirm this exact
|
|
|
|
|
// (hash, size, verdict) tuple came from this service and was not edited.
|
|
|
|
|
const reportPayload = {
|
|
|
|
|
v: 1,
|
|
|
|
|
kind: 'prometheus.scan-report',
|
|
|
|
|
service: 'prometheus',
|
|
|
|
|
issued_at: now,
|
|
|
|
|
upload: {
|
|
|
|
|
hash: upload.hash,
|
|
|
|
|
size: upload.size,
|
|
|
|
|
name: upload.name,
|
|
|
|
|
mime: upload.mime,
|
|
|
|
|
},
|
|
|
|
|
scan: {
|
|
|
|
|
verdict: scan.verdict,
|
|
|
|
|
engine: scan.engine,
|
|
|
|
|
signatures: scan.signatures,
|
|
|
|
|
duration_ms: scan.duration_ms,
|
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
|
|
|
...(scan.virustotal ? { virustotal: scan.virustotal } : {}),
|
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
|
|
|
},
|
|
|
|
|
stored,
|
|
|
|
|
};
|
|
|
|
|
const report = signReport(reportPayload);
|
|
|
|
|
|
|
|
|
|
return respond(res, scan.verdict === 'error' ? 502 : 200, {
|
|
|
|
|
ok: scan.verdict !== 'error',
|
|
|
|
|
upload,
|
|
|
|
|
scan: {
|
|
|
|
|
verdict: scan.verdict,
|
|
|
|
|
engine: scan.engine,
|
|
|
|
|
signatures: scan.signatures,
|
|
|
|
|
duration_ms: scan.duration_ms,
|
|
|
|
|
...(scan.error ? { error: scan.error } : {}),
|
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
|
|
|
...(scan.virustotal ? { virustotal: scan.virustotal } : {}),
|
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
|
|
|
},
|
|
|
|
|
stored,
|
|
|
|
|
report,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------- helpers ----------------------- */
|
|
|
|
|
|
|
|
|
|
function respond(res, status, body) {
|
|
|
|
|
const json = JSON.stringify(body);
|
|
|
|
|
res.writeHead(status, {
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
'content-length': Buffer.byteLength(json),
|
|
|
|
|
'cache-control': 'no-store',
|
|
|
|
|
});
|
|
|
|
|
res.end(json);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitize(name) {
|
|
|
|
|
// Keep the publisher's filename for display, but strip path separators and
|
|
|
|
|
// control characters — nothing we write to disk is derived from this; it is
|
|
|
|
|
// just metadata for the UI.
|
|
|
|
|
return String(name)
|
|
|
|
|
.replace(/[\\/\x00-\x1f]/g, '_')
|
|
|
|
|
.slice(0, 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function safeUnlink(path) {
|
|
|
|
|
try { await unlink(path); } catch { /* already gone */ }
|
|
|
|
|
}
|