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.
This commit is contained in:
Local Dev 2026-10-05 22:41:55 +02:00
parent 2c5df40d77
commit c4566b4ea3
8 changed files with 742 additions and 18 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ db.sqlite-wal
db.sqlite-shm db.sqlite-shm
build/ build/
uploads/ uploads/
.keys/

38
package-lock.json generated Normal file
View file

@ -0,0 +1,38 @@
{
"name": "prometheus",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prometheus",
"version": "0.1.0",
"license": "MPL-2.0",
"dependencies": {
"busboy": "^1.6.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
}
}
}

View file

@ -10,5 +10,8 @@
}, },
"scripts": { "scripts": {
"start": "node service/server.js" "start": "node service/server.js"
},
"dependencies": {
"busboy": "^1.6.0"
} }
} }

222
service/clamd.js Normal file
View file

@ -0,0 +1,222 @@
/**
* 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';
import { PassThrough } from 'node:stream';
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.
*
* @param {NodeJS.ReadableStream} source the file bytes
* @returns {Promise<{verdict: 'clean'|'infected'|'error', engine: string, engine_version?: string, signatures: string[], raw: string, duration_ms: number}>}
*/
export async function scanStream(source) {
const mock = process.env.MOCK_SCAN;
if (mock) {
return mockScan(mock, source);
}
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);
});
});
});
}
/* ---------------------------------------------------------------------- */
/**
* 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')));
});
}

View file

@ -364,32 +364,89 @@ export function submit() {
<div class="wrap"> <div class="wrap">
<section class="plain"> <section class="plain">
<div class="note warn"><b>The submit UI is being wired right now.</b> Upload <div class="note warn"><b>Milestone 1 of 4: upload + scan.</b> This step is
+ scan is the next milestone (see live and testable. The next milestone wires the manifest and APS1 record
<a href="/publish">the publishers' walkthrough</a> for the full flow). This (see <a href="/publish">the publishers' walkthrough</a>); payment and the
page becomes the first screen of the submit wizard once the upload endpoint on-chain publish come after that.</div>
lands.</div>
<ol class="steps" style="margin-top:1.4rem"> <ol class="steps" style="margin-top:1.4rem">
<li><b>1. Upload the binary.</b> <li><b>1. Upload the binary.</b>
<span>Drag-and-drop or file picker. The server scans with ClamAV on the <span>Pick a file (any file — we don't care what the format is, only
way in and returns a signed scan report. Scan verdict and SHA-256 appear what ClamAV says about the bytes). The server scans it, hashes it,
on the next screen.</span></li> and hands back a signed scan report.</span></li>
<li><b>2. Fill the manifest.</b> <li><b>2. Fill the manifest.</b>
<span>Name, description, category, version, icon URL. Scan verdict is <span>Name, description, category, version, icon URL. Scan verdict is
attached automatically. Preview on the right as you type.</span></li> attached automatically. <em>Not wired yet.</em></span></li>
<li><b>3. Sign with your BNS key.</b> <li><b>3. Sign with your BNS key.</b>
<span>The service shows the APS1 payload. Sign in Theseus (if your wallet <span>The service shows the APS1 payload. Sign in Theseus, paste a
is there), paste a BIP-137 signature from any BCH wallet, or import your BIP-137 signature from any BCH wallet, or import your name's mnemonic
name's mnemonic once in the page (wiped on close). The signed TX is yours once in the page (wiped on close). <em>Not wired yet.</em></span></li>
regardless of whether we broadcast it.</span></li>
<li><b>4. Pay the listing fee (eventually).</b> <li><b>4. Pay the listing fee (eventually).</b>
<span>During the free wave: skip. Later: a BCH invoice for the per-listing <span>Free during the opening wave; a BCH invoice for the per-listing
fee at the day's rate. Service broadcasts the APS1 TX on confirmation; if fee once storage costs cross a threshold. <em>Not wired yet.</em></span></li>
the broadcast fails you still have the signed TX and can relay it yourself.</span></li>
</ol> </ol>
</section> </section>
<section>
<h2>Upload a file to try it</h2>
<p class="lede">Pick any file to send through the scanner. The upload is
accepted, SHA-256'd, and either kept (clean) or discarded (infected or
error). Nothing is published; this is just the scan step.</p>
<form id="up" style="display:grid;gap:12px;max-width:520px;margin-top:1rem">
<input type="file" name="file" required
style="padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink)">
<button class="btn acid" type="submit">Upload &amp; scan</button>
<div id="status" style="color:var(--mut);font-size:14px;min-height:1.4em"></div>
</form>
<pre id="result" style="display:none;max-height:480px;overflow:auto;margin-top:1.2rem"></pre>
</section>
<script>
(() => {
const form = document.getElementById('up');
const status = document.getElementById('status');
const result = document.getElementById('result');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const file = form.file.files[0];
if (!file) return;
status.textContent = \`Uploading \${file.name} (\${fmtBytes(file.size)})…\`;
result.style.display = 'none';
result.textContent = '';
const body = new FormData();
body.append('file', file);
const started = performance.now();
let json;
try {
const r = await fetch('/api/upload', { method: 'POST', body });
json = await r.json();
const elapsed = Math.round(performance.now() - started);
const v = json?.scan?.verdict;
const emoji = v === 'clean' ? '✅' : v === 'infected' ? '🚨' : '⚠️';
status.innerHTML = \`\${emoji} <b>\${v || 'done'}</b> — \${elapsed} ms round trip.\`;
} catch (err) {
status.innerHTML = '⚠️ <b>network error</b> — ' + String(err?.message || err);
return;
}
result.style.display = 'block';
result.textContent = JSON.stringify(json, null, 2);
});
function fmtBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MiB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GiB';
}
})();
</script>
</div>`, </div>`,
}; };
} }

View file

@ -13,16 +13,22 @@
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createReadStream, statSync } from 'node:fs'; import { createReadStream, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join, normalize, resolve } from 'node:path'; import { dirname, normalize, resolve } from 'node:path';
import { layout } from './layout.js'; import { layout } from './layout.js';
import * as pages from './pages.js'; import * as pages from './pages.js';
import { handleUpload } from './uploads.js';
import { clamdPing } from './clamd.js';
import { publicKeyBase64, loadServiceKey } from './signing.js';
const PORT = Number(process.env.PORT) || 3700; const PORT = Number(process.env.PORT) || 3700;
const BIND = process.env.BIND || '127.0.0.1'; const BIND = process.env.BIND || '127.0.0.1';
// Load the service key on startup so a missing-key problem shows up in the
// terminal when the operator reads the launch log, not under a POST later.
loadServiceKey();
const SELF = fileURLToPath(import.meta.url); const SELF = fileURLToPath(import.meta.url);
const SERVICE_DIR = dirname(SELF); const SERVICE_DIR = dirname(SELF);
const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public'); const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public');
@ -65,6 +71,12 @@ const server = createServer(async (req, res) => {
try { try {
if (url.startsWith('/static/')) { if (url.startsWith('/static/')) {
await serveStatic(url.slice('/static/'.length), res); await serveStatic(url.slice('/static/'.length), res);
} else if (url === '/api/upload') {
await handleUpload(req, res);
} else if (url === '/healthz') {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {
serveServiceKey(res);
} else if (PAGE_ROUTES[url]) { } else if (PAGE_ROUTES[url]) {
servePage(PAGE_ROUTES[url], res); servePage(PAGE_ROUTES[url], res);
} else { } else {
@ -118,6 +130,43 @@ function servePage(fn, res, status = 200, req = null) {
res.end(html); res.end(html);
} }
/**
* `/healthz` — JSON probe for operators and uptime checks. Says whether
* clamd (or the mock) is reachable; the HTTP status alone is enough for
* an opinionated monitor.
*/
async function serveHealth(res) {
const scanner = await clamdPing();
const ok = scanner.ok === true;
const body = JSON.stringify({ ok, scanner });
res.writeHead(ok ? 200 : 503, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
/**
* `/.well-known/prometheus-service-key.json` — the public key callers use to
* verify scan reports. Lives at a well-known path so clients can cache it by
* origin.
*/
function serveServiceKey(res) {
const body = JSON.stringify({
service: 'prometheus',
alg: 'ed25519',
pubkey: publicKeyBase64(),
issued_by: 'https://prometheus.x',
}, null, 2);
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=3600',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
/** /**
* Serve a file from PUBLIC_DIR by relative path. The caller passes a * Serve a file from PUBLIC_DIR by relative path. The caller passes a
* path with no leading slash and no `..`; this double-checks via * path with no leading slash and no `..`; this double-checks via

108
service/signing.js Normal file
View file

@ -0,0 +1,108 @@
/**
* Service key — the identity that signs scan reports.
*
* This is NOT the publisher's BNS key and NOT the operator's BCH wallet. It
* is the service's own identity, used to attest "Prometheus's scanner saw
* this file and this is what it reported." Readers' clients verify the
* signature against the service's public key (published out of band — see
* `/.well-known/prometheus-service-key.json`) to tell a report that came
* from this service apart from one somebody fabricated.
*
* ed25519 was chosen over secp256k1 BIP-137 (the BCH convention) because the
* scan report isn't a Bitcoin thing. The key never signs value, never signs
* on behalf of a user, and never touches the chain. ed25519 keeps the surface
* small and the signatures short.
*
* The private key lives at `KEY_DIR/service.key` (defaults to a directory
* next to the service). It is generated on first start if absent. The matching
* public key is served alongside signed reports and at
* `/.well-known/prometheus-service-key.json` so clients can cache it.
*/
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const KEY_DIR = process.env.PROMETHEUS_KEY_DIR
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', '.keys');
const KEY_PATH = resolve(KEY_DIR, 'service.key');
let cachedPrivate = null;
let cachedPublic = null;
let cachedPublicBase64 = null;
/**
* Load (or generate) the service key. Idempotent.
*/
export function loadServiceKey() {
if (cachedPrivate) return;
if (!existsSync(KEY_PATH)) {
mkdirSync(KEY_DIR, { recursive: true });
const { privateKey } = generateKeyPairSync('ed25519');
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
// Restrictive mode intentional — the file is a secret.
writeFileSync(KEY_PATH, pem, { mode: 0o600 });
console.log(`[prometheus] generated new service key at ${KEY_PATH}`);
}
const pem = readFileSync(KEY_PATH, 'utf8');
cachedPrivate = createPrivateKey(pem);
cachedPublic = createPublicKey(cachedPrivate);
// Raw 32-byte Ed25519 public key, base64url-encoded. Matches the shape
// the client-side verifier wants.
const der = cachedPublic.export({ type: 'spki', format: 'der' });
// The 32 bytes of the key sit at the end of the DER SPKI envelope.
const raw = der.subarray(der.length - 32);
cachedPublicBase64 = raw.toString('base64url');
}
/**
* The service's public key, base64url-encoded (raw 32-byte Ed25519).
*/
export function publicKeyBase64() {
loadServiceKey();
return cachedPublicBase64;
}
/**
* Sign a scan-report payload. The payload is a JSON object; we canonicalise
* it (keys sorted at every level, no whitespace) before signing so verifiers
* can reproduce the signed bytes without ambiguity.
*
* @param {object} payload
* @returns {{alg: 'ed25519', payload: string, signature: string, pubkey: string}}
*/
export function signReport(payload) {
loadServiceKey();
const canonical = canonicalJson(payload);
const signature = nodeSign(null, Buffer.from(canonical, 'utf8'), cachedPrivate);
return {
alg: 'ed25519',
payload: Buffer.from(canonical, 'utf8').toString('base64url'),
signature: signature.toString('base64url'),
pubkey: cachedPublicBase64,
};
}
/**
* JSON with keys sorted at every level and no whitespace. Equivalent to RFC
* 8785's "JCS" shape for the subset we use (plain data: null / boolean /
* number / string / array / object). We don't carry anything exotic (dates
* are ISO strings before this is called, numbers are integers), so a short
* walker beats a 500 KB dependency.
*/
export function canonicalJson(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalJson).join(',') + ']';
}
const keys = Object.keys(value).sort();
const body = keys
.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k]))
.join(',');
return '{' + body + '}';
}

246
service/uploads.js Normal file
View file

@ -0,0 +1,246 @@
/**
* 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.
const scan = await scanStream(createReadStream(tempPath));
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,
},
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 } : {}),
},
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 */ }
}