@better-media/plugin-virus-scan 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,4 +31,4 @@ const media = createBetterMedia({
31
31
  });
32
32
  ```
33
33
 
34
- See [better-media-platform.vercel.app/docs/plugins/virus-scan](https://better-media-platform.vercel.app/docs/plugins/virus-scan) for all strategies and options.
34
+ See [better-media-platform.vercel.app/docs/plugins/virus-scan](https://better-media.dev/docs/plugins/virus-scan) for all strategies and options.
package/dist/index.js CHANGED
@@ -87,6 +87,7 @@ async function recordScanResult(database, record) {
87
87
  status,
88
88
  threats: record.viruses,
89
89
  scanner: record.scannerName,
90
+ metadata: { durationMs: record.durationMs },
90
91
  createdAt: record.scannedAt
91
92
  };
92
93
  const existing = await database.findOne({
@@ -141,6 +142,7 @@ async function runVirusScan(context, api, scanner, opts) {
141
142
  if (!buffer && !tempPath) {
142
143
  throw new Error("Unable to resolve file content for virus scanning");
143
144
  }
145
+ const startTime = Date.now();
144
146
  let infected;
145
147
  let viruses;
146
148
  try {
@@ -157,13 +159,16 @@ async function runVirusScan(context, api, scanner, opts) {
157
159
  const message = err instanceof Error ? err.message : String(err);
158
160
  return { valid: false, message: `Virus scan failed: ${message}` };
159
161
  }
162
+ const durationMs = Date.now() - startTime;
160
163
  const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
161
164
  const record = {
162
165
  recordId: context.recordId,
163
166
  infected,
164
167
  viruses,
165
168
  scannedAt,
166
- scannerName: scanner.name};
169
+ scannerName: scanner.name,
170
+ durationMs
171
+ };
167
172
  await recordScanResult(database, record);
168
173
  api.emitMetadata({ infected, viruses, scannedAt });
169
174
  if (!infected) return;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/scanners/clam.scanner.ts","../src/runtime/runner.ts","../src/scanners/virustotal.scanner.ts","../src/index.ts"],"names":["stream","Readable","randomUUID","fs"],"mappings":"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","file":"index.js","sourcesContent":["import { Readable } from \"node:stream\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\n/** ClamAV daemon connection options. */\nexport interface ClamScannerOptions {\n /** Remove infected files after detection. Default: false. */\n removeInfected?: boolean;\n /** Quarantine path for infected files. */\n quarantinePath?: string;\n /** Log scan output to console. Default: false. */\n debugMode?: boolean;\n /** Path to clamscan binary. */\n clamscanPath?: string;\n /** Path to clamdscan binary. */\n clamdscanPath?: string;\n /** ClamAV daemon connection settings. */\n clamdscan?: {\n /** Use clamd for scanning. Default: true. */\n active?: boolean;\n /** Daemon host. Default: \"127.0.0.1\". */\n host?: string;\n /** Daemon port. Default: 3310. */\n port?: number;\n /** Socket path (overrides host/port). */\n socket?: string;\n /** Connection timeout in ms. Default: 5000. */\n timeout?: number;\n };\n}\n\ninterface ClamScanInstance {\n isInfected(filePath: string): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n scanStream(stream: Readable): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n}\n\ninterface ClamScanConstructor {\n new (): { init(options: Record<string, unknown>): Promise<ClamScanInstance> };\n}\n\n/**\n * ClamAV implementation of the VirusScanner interface.\n * Wraps the `clamscan` npm package.\n */\nexport class ClamScanner implements VirusScanner {\n readonly name = \"clamav\";\n private instance: ClamScanInstance | null = null;\n private readonly options: ClamScannerOptions;\n\n constructor(options: ClamScannerOptions = {}) {\n this.options = options;\n }\n\n async init(): Promise<void> {\n if (this.instance) return;\n\n // Dynamic import to avoid hard dependency when using a different scanner\n const NodeClam = (await import(\"clamscan\")).default as unknown as ClamScanConstructor;\n this.instance = await new NodeClam().init(this.buildClamConfig());\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const stream = Readable.from(buffer);\n const result = await scanner.scanStream(stream);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const result = await scanner.isInfected(filePath);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n private async getScanner(): Promise<ClamScanInstance> {\n if (!this.instance) await this.init();\n return this.instance!;\n }\n\n private buildClamConfig(): Record<string, unknown> {\n return {\n removeInfected: this.options.removeInfected ?? false,\n quarantineInfected: this.options.quarantinePath ?? false,\n debugMode: this.options.debugMode ?? false,\n clamscan: {\n path: this.options.clamscanPath ?? \"/usr/bin/clamscan\",\n },\n clamdscan: {\n path: this.options.clamdscanPath ?? \"/usr/bin/clamdscan\",\n active: this.options.clamdscan?.active ?? true,\n host: this.options.clamdscan?.host ?? \"127.0.0.1\",\n port: this.options.clamdscan?.port ?? 3310,\n socket: this.options.clamdscan?.socket ?? null,\n timeout: this.options.clamdscan?.timeout ?? 5000,\n },\n };\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type {\n PipelineContext,\n ValidationResult,\n DatabaseAdapter,\n PluginApi,\n} from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"../interfaces/options.interface\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanRecord } from \"../interfaces/scan-result.interface\";\n\nasync function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Wrap a promise with a timeout.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);\n promise.then(\n (val) => {\n clearTimeout(timer);\n resolve(val);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n });\n}\n\nasync function recordScanResult(database: DatabaseAdapter, record: ScanRecord): Promise<void> {\n const model = \"media_virus_scan_results\";\n const status = record.infected ? \"infected\" : \"clean\";\n\n const data = {\n mediaId: record.recordId,\n status,\n threats: record.viruses,\n scanner: record.scannerName,\n createdAt: record.scannedAt,\n };\n\n const existing = await database.findOne({\n model,\n where: [{ field: \"mediaId\", value: record.recordId }],\n });\n\n if (existing) {\n await database.update({\n model,\n where: [{ field: \"id\", value: existing.id }],\n update: data,\n });\n } else {\n await database.create({\n model,\n data: { id: randomUUID(), ...data },\n });\n }\n}\n\n/**\n * Execute the scan with retry logic for transient failures.\n */\nasync function scanWithRetry(\n scanner: VirusScanner,\n buffer: Buffer,\n tempPath: string | undefined,\n opts: VirusScanPluginOptions\n): Promise<{ infected: boolean; viruses: string[] }> {\n const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;\n const delayMs = opts.retryOptions?.delayMs ?? 1000;\n const backoff = opts.retryOptions?.backoff ?? \"exponential\";\n const timeoutMs = opts.scanTimeoutMs ?? 30_000;\n\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);\n\n const result = await withTimeout(scanPromise, timeoutMs, \"Virus scan\");\n return { infected: result.infected, viruses: result.viruses };\n } catch (err: unknown) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (attempt < maxAttempts) {\n const wait =\n backoff === \"exponential\" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;\n await sleep(wait);\n }\n }\n }\n\n throw lastError ?? new Error(\"Virus scan failed after retries\");\n}\n\n/**\n * Core virus scan runner. Called from the plugin's hook tap.\n */\nexport async function runVirusScan(\n context: PipelineContext,\n api: PluginApi,\n scanner: VirusScanner,\n opts: VirusScanPluginOptions\n): Promise<void | ValidationResult> {\n const { file, database } = context;\n const fileKey = file.key;\n const fileContent = context.utilities?.fileContent;\n\n if (!fileContent || (!fileContent.buffer && !fileContent.tempPath)) {\n throw new Error(\n \"Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities\"\n );\n }\n\n // Prefer tempPath for scanFile; only read buffer when no tempPath is available\n const tempPath = fileContent.tempPath;\n const buffer = tempPath ? undefined : (fileContent.buffer ?? null);\n\n if (!buffer && !tempPath) {\n throw new Error(\"Unable to resolve file content for virus scanning\");\n }\n\n const startTime = Date.now();\n let infected: boolean;\n let viruses: string[];\n\n try {\n const result = await scanWithRetry(\n scanner,\n buffer ?? Buffer.alloc(0), // scanWithRetry will use tempPath if available\n tempPath,\n opts\n );\n infected = result.infected;\n viruses = result.viruses;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n // Scanner failure is treated as an abort — we don't know if the file is safe\n return { valid: false, message: `Virus scan failed: ${message}` };\n }\n\n const durationMs = Date.now() - startTime;\n\n const scannedAt = new Date().toISOString();\n\n // Persist scan result to DB\n const record: ScanRecord = {\n recordId: context.recordId,\n fileKey,\n infected,\n viruses,\n scannedAt,\n scannerName: scanner.name,\n durationMs,\n };\n await recordScanResult(database, record);\n\n // Write scan metadata to plugin metadata via PluginApi\n api.emitMetadata({ infected, viruses, scannedAt });\n\n if (!infected) return;\n\n // Infected file — handle according to failure mode\n const failureMessage = `Virus detected in \"${fileKey}\": ${viruses.join(\", \")}`;\n\n switch (opts.onFailure ?? \"abort\") {\n case \"continue\":\n return;\n case \"custom\":\n if (opts.onFailureCallback) {\n const customResult = await opts.onFailureCallback(fileKey, viruses);\n if (customResult && customResult.valid === false) return customResult;\n }\n return;\n case \"abort\":\n default:\n return { valid: false, message: failureMessage };\n }\n}\n","import fs from \"node:fs/promises\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\nexport interface VirusTotalScannerOptions {\n /** VirusTotal API Key */\n apiKey: string;\n /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */\n pollingTimeoutMs?: number;\n /** Polling interval in ms. Default: 5000 (5 seconds) */\n pollingIntervalMs?: number;\n}\n\n/**\n * VirusTotal API v3 implementation of the VirusScanner interface.\n * Uses the native global `fetch` API available in Node 18+.\n */\nexport class VirusTotalScanner implements VirusScanner {\n readonly name = \"virustotal\";\n private readonly options: VirusTotalScannerOptions;\n\n constructor(options: VirusTotalScannerOptions) {\n if (!options.apiKey) {\n throw new Error(\"VirusTotalScanner requires an apiKey\");\n }\n this.options = {\n pollingTimeoutMs: 120_000,\n pollingIntervalMs: 5_000,\n ...options,\n };\n }\n\n async init(): Promise<void> {\n // REST API doesn't require initialization connection\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const blob = new Blob([new Uint8Array(buffer)]);\n return this.uploadAndAnalyze(blob, \"upload.bin\");\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const buffer = await fs.readFile(filePath);\n const blob = new Blob([new Uint8Array(buffer)]);\n const filename = filePath.split(\"/\").pop() || \"upload.bin\";\n return this.uploadAndAnalyze(blob, filename);\n }\n\n private async uploadAndAnalyze(blob: Blob, filename: string): Promise<ScanResult> {\n const size = blob.size;\n let uploadUrl = \"https://www.virustotal.com/api/v3/files\";\n\n // VirusTotal v3 requires a special URL for files > 32MB\n if (size > 32 * 1024 * 1024) {\n const urlRes = await fetch(\"https://www.virustotal.com/api/v3/files/upload_url\", {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n if (!urlRes.ok) {\n throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);\n }\n const urlData = await urlRes.json();\n uploadUrl = urlData.data;\n }\n\n const formData = new FormData();\n formData.append(\"file\", blob, filename);\n\n const uploadRes = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"x-apikey\": this.options.apiKey },\n body: formData,\n });\n\n if (!uploadRes.ok) {\n const errText = await uploadRes.text().catch(() => \"\");\n throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);\n }\n\n const uploadData = await uploadRes.json();\n const analysisId = uploadData.data.id;\n\n return this.pollAnalysis(analysisId);\n }\n\n private async pollAnalysis(analysisId: string): Promise<ScanResult> {\n const startTime = Date.now();\n const timeout = this.options.pollingTimeoutMs!;\n const interval = this.options.pollingIntervalMs!;\n\n while (Date.now() - startTime < timeout) {\n const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n\n if (!res.ok) {\n throw new Error(`VirusTotal polling failed: ${res.statusText}`);\n }\n\n const data = await res.json();\n const status = data.data.attributes.status;\n\n if (status === \"completed\") {\n const stats = data.data.attributes.stats;\n let infected = false;\n const viruses: string[] = [];\n\n if (stats.malicious > 0 || stats.suspicious > 0) {\n infected = true;\n const results = data.data.attributes.results;\n for (const [engine, info] of Object.entries<{ category: string; result: string | null }>(\n results\n )) {\n if (info.category === \"malicious\" || info.category === \"suspicious\") {\n viruses.push(`${engine}:${info.result}`);\n }\n }\n }\n\n return {\n infected,\n viruses,\n raw: data.data,\n };\n }\n\n // Wait interval before polling again\n await new Promise((resolve) => setTimeout(resolve, interval));\n }\n\n throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);\n }\n}\n","import type { PipelinePlugin, MediaRuntime, PipelineContext, PluginApi } from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nimport { ClamScanner } from \"./scanners/clam.scanner\";\nimport { runVirusScan } from \"./runtime/runner\";\n\nexport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nexport type { VirusScanner } from \"./interfaces/scanner.interface\";\nexport type { ScanResult, ScanRecord } from \"./interfaces/scan-result.interface\";\nexport { ClamScanner } from \"./scanners/clam.scanner\";\nexport type { ClamScannerOptions } from \"./scanners/clam.scanner\";\nexport { VirusTotalScanner } from \"./scanners/virustotal.scanner\";\nexport type { VirusTotalScannerOptions } from \"./scanners/virustotal.scanner\";\n\n/**\n * Virus scan plugin for the Better Media pipeline.\n *\n * Scans uploaded files for malware using a configurable scanner engine.\n * Defaults to ClamAV via the built-in ClamScanner.\n *\n * @example\n * ```ts\n * import { virusScanPlugin } from \"@better-media/plugin-virus-scan\";\n *\n * // With defaults (ClamAV daemon on localhost:3310)\n * const plugin = virusScanPlugin();\n *\n * // With custom scanner\n * const plugin = virusScanPlugin({ scanner: myCustomScanner });\n * ```\n */\nexport function virusScanPlugin(opts: VirusScanPluginOptions = {}): PipelinePlugin {\n const executionMode = opts.executionMode ?? \"background\";\n const isBackground = executionMode === \"background\";\n const scanner = opts.scanner ?? new ClamScanner();\n\n return {\n name: \"virus-scan\",\n runtimeManifest: {\n id: \"better-media-virus-scan\",\n version: \"1.0.0\",\n trustLevel: \"untrusted\",\n capabilities: [\"file.read\", \"metadata.write.own\", \"processing.write.own\"],\n namespace: \"antivirus\",\n },\n executionMode,\n intensive: isBackground,\n\n apply(runtime: MediaRuntime) {\n // Initialize scanner eagerly on first apply\n let initPromise: Promise<void> | null = null;\n\n runtime.hooks[\"scan:run\"].tap(\n \"virus-scan\",\n async (context: PipelineContext, api: PluginApi) => {\n // Lazy init — only once across all invocations\n if (!initPromise) {\n initPromise = scanner.init();\n }\n await initPromise;\n\n return runVirusScan(context, api, scanner, opts);\n },\n { mode: executionMode }\n );\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/scanners/clam.scanner.ts","../src/runtime/runner.ts","../src/scanners/virustotal.scanner.ts","../src/index.ts"],"names":["stream","Readable","randomUUID","fs"],"mappings":"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","file":"index.js","sourcesContent":["import { Readable } from \"node:stream\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\n/** ClamAV daemon connection options. */\nexport interface ClamScannerOptions {\n /** Remove infected files after detection. Default: false. */\n removeInfected?: boolean;\n /** Quarantine path for infected files. */\n quarantinePath?: string;\n /** Log scan output to console. Default: false. */\n debugMode?: boolean;\n /** Path to clamscan binary. */\n clamscanPath?: string;\n /** Path to clamdscan binary. */\n clamdscanPath?: string;\n /** ClamAV daemon connection settings. */\n clamdscan?: {\n /** Use clamd for scanning. Default: true. */\n active?: boolean;\n /** Daemon host. Default: \"127.0.0.1\". */\n host?: string;\n /** Daemon port. Default: 3310. */\n port?: number;\n /** Socket path (overrides host/port). */\n socket?: string;\n /** Connection timeout in ms. Default: 5000. */\n timeout?: number;\n };\n}\n\ninterface ClamScanInstance {\n isInfected(filePath: string): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n scanStream(stream: Readable): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n}\n\ninterface ClamScanConstructor {\n new (): { init(options: Record<string, unknown>): Promise<ClamScanInstance> };\n}\n\n/**\n * ClamAV implementation of the VirusScanner interface.\n * Wraps the `clamscan` npm package.\n */\nexport class ClamScanner implements VirusScanner {\n readonly name = \"clamav\";\n private instance: ClamScanInstance | null = null;\n private readonly options: ClamScannerOptions;\n\n constructor(options: ClamScannerOptions = {}) {\n this.options = options;\n }\n\n async init(): Promise<void> {\n if (this.instance) return;\n\n // Dynamic import to avoid hard dependency when using a different scanner\n const NodeClam = (await import(\"clamscan\")).default as unknown as ClamScanConstructor;\n this.instance = await new NodeClam().init(this.buildClamConfig());\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const stream = Readable.from(buffer);\n const result = await scanner.scanStream(stream);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const result = await scanner.isInfected(filePath);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n private async getScanner(): Promise<ClamScanInstance> {\n if (!this.instance) await this.init();\n return this.instance!;\n }\n\n private buildClamConfig(): Record<string, unknown> {\n return {\n removeInfected: this.options.removeInfected ?? false,\n quarantineInfected: this.options.quarantinePath ?? false,\n debugMode: this.options.debugMode ?? false,\n clamscan: {\n path: this.options.clamscanPath ?? \"/usr/bin/clamscan\",\n },\n clamdscan: {\n path: this.options.clamdscanPath ?? \"/usr/bin/clamdscan\",\n active: this.options.clamdscan?.active ?? true,\n host: this.options.clamdscan?.host ?? \"127.0.0.1\",\n port: this.options.clamdscan?.port ?? 3310,\n socket: this.options.clamdscan?.socket ?? null,\n timeout: this.options.clamdscan?.timeout ?? 5000,\n },\n };\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type {\n PipelineContext,\n ValidationResult,\n DatabaseAdapter,\n PluginApi,\n} from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"../interfaces/options.interface\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanRecord } from \"../interfaces/scan-result.interface\";\n\nasync function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Wrap a promise with a timeout.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);\n promise.then(\n (val) => {\n clearTimeout(timer);\n resolve(val);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n });\n}\n\nasync function recordScanResult(database: DatabaseAdapter, record: ScanRecord): Promise<void> {\n const model = \"media_virus_scan_results\";\n const status = record.infected ? \"infected\" : \"clean\";\n\n const data = {\n mediaId: record.recordId,\n status,\n threats: record.viruses,\n scanner: record.scannerName,\n metadata: { durationMs: record.durationMs },\n createdAt: record.scannedAt,\n };\n\n const existing = await database.findOne({\n model,\n where: [{ field: \"mediaId\", value: record.recordId }],\n });\n\n if (existing) {\n await database.update({\n model,\n where: [{ field: \"id\", value: existing.id }],\n update: data,\n });\n } else {\n await database.create({\n model,\n data: { id: randomUUID(), ...data },\n });\n }\n}\n\n/**\n * Execute the scan with retry logic for transient failures.\n */\nasync function scanWithRetry(\n scanner: VirusScanner,\n buffer: Buffer,\n tempPath: string | undefined,\n opts: VirusScanPluginOptions\n): Promise<{ infected: boolean; viruses: string[] }> {\n const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;\n const delayMs = opts.retryOptions?.delayMs ?? 1000;\n const backoff = opts.retryOptions?.backoff ?? \"exponential\";\n const timeoutMs = opts.scanTimeoutMs ?? 30_000;\n\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);\n\n const result = await withTimeout(scanPromise, timeoutMs, \"Virus scan\");\n return { infected: result.infected, viruses: result.viruses };\n } catch (err: unknown) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (attempt < maxAttempts) {\n const wait =\n backoff === \"exponential\" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;\n await sleep(wait);\n }\n }\n }\n\n throw lastError ?? new Error(\"Virus scan failed after retries\");\n}\n\n/**\n * Core virus scan runner. Called from the plugin's hook tap.\n */\nexport async function runVirusScan(\n context: PipelineContext,\n api: PluginApi,\n scanner: VirusScanner,\n opts: VirusScanPluginOptions\n): Promise<void | ValidationResult> {\n const { file, database } = context;\n const fileKey = file.key;\n const fileContent = context.utilities?.fileContent;\n\n if (!fileContent || (!fileContent.buffer && !fileContent.tempPath)) {\n throw new Error(\n \"Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities\"\n );\n }\n\n // Prefer tempPath for scanFile; only read buffer when no tempPath is available\n const tempPath = fileContent.tempPath;\n const buffer = tempPath ? undefined : (fileContent.buffer ?? null);\n\n if (!buffer && !tempPath) {\n throw new Error(\"Unable to resolve file content for virus scanning\");\n }\n\n const startTime = Date.now();\n let infected: boolean;\n let viruses: string[];\n\n try {\n const result = await scanWithRetry(\n scanner,\n buffer ?? Buffer.alloc(0), // scanWithRetry will use tempPath if available\n tempPath,\n opts\n );\n infected = result.infected;\n viruses = result.viruses;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n // Scanner failure is treated as an abort — we don't know if the file is safe\n return { valid: false, message: `Virus scan failed: ${message}` };\n }\n\n const durationMs = Date.now() - startTime;\n\n const scannedAt = new Date().toISOString();\n\n // Persist scan result to DB\n const record: ScanRecord = {\n recordId: context.recordId,\n fileKey,\n infected,\n viruses,\n scannedAt,\n scannerName: scanner.name,\n durationMs,\n };\n await recordScanResult(database, record);\n\n // Write scan metadata to plugin metadata via PluginApi\n api.emitMetadata({ infected, viruses, scannedAt });\n\n if (!infected) return;\n\n // Infected file — handle according to failure mode\n const failureMessage = `Virus detected in \"${fileKey}\": ${viruses.join(\", \")}`;\n\n switch (opts.onFailure ?? \"abort\") {\n case \"continue\":\n return;\n case \"custom\":\n if (opts.onFailureCallback) {\n const customResult = await opts.onFailureCallback(fileKey, viruses);\n if (customResult && customResult.valid === false) return customResult;\n }\n return;\n case \"abort\":\n default:\n return { valid: false, message: failureMessage };\n }\n}\n","import fs from \"node:fs/promises\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\nexport interface VirusTotalScannerOptions {\n /** VirusTotal API Key */\n apiKey: string;\n /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */\n pollingTimeoutMs?: number;\n /** Polling interval in ms. Default: 5000 (5 seconds) */\n pollingIntervalMs?: number;\n}\n\n/**\n * VirusTotal API v3 implementation of the VirusScanner interface.\n * Uses the native global `fetch` API available in Node 18+.\n */\nexport class VirusTotalScanner implements VirusScanner {\n readonly name = \"virustotal\";\n private readonly options: VirusTotalScannerOptions;\n\n constructor(options: VirusTotalScannerOptions) {\n if (!options.apiKey) {\n throw new Error(\"VirusTotalScanner requires an apiKey\");\n }\n this.options = {\n pollingTimeoutMs: 120_000,\n pollingIntervalMs: 5_000,\n ...options,\n };\n }\n\n async init(): Promise<void> {\n // REST API doesn't require initialization connection\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const blob = new Blob([new Uint8Array(buffer)]);\n return this.uploadAndAnalyze(blob, \"upload.bin\");\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const buffer = await fs.readFile(filePath);\n const blob = new Blob([new Uint8Array(buffer)]);\n const filename = filePath.split(\"/\").pop() || \"upload.bin\";\n return this.uploadAndAnalyze(blob, filename);\n }\n\n private async uploadAndAnalyze(blob: Blob, filename: string): Promise<ScanResult> {\n const size = blob.size;\n let uploadUrl = \"https://www.virustotal.com/api/v3/files\";\n\n // VirusTotal v3 requires a special URL for files > 32MB\n if (size > 32 * 1024 * 1024) {\n const urlRes = await fetch(\"https://www.virustotal.com/api/v3/files/upload_url\", {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n if (!urlRes.ok) {\n throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);\n }\n const urlData = await urlRes.json();\n uploadUrl = urlData.data;\n }\n\n const formData = new FormData();\n formData.append(\"file\", blob, filename);\n\n const uploadRes = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"x-apikey\": this.options.apiKey },\n body: formData,\n });\n\n if (!uploadRes.ok) {\n const errText = await uploadRes.text().catch(() => \"\");\n throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);\n }\n\n const uploadData = await uploadRes.json();\n const analysisId = uploadData.data.id;\n\n return this.pollAnalysis(analysisId);\n }\n\n private async pollAnalysis(analysisId: string): Promise<ScanResult> {\n const startTime = Date.now();\n const timeout = this.options.pollingTimeoutMs!;\n const interval = this.options.pollingIntervalMs!;\n\n while (Date.now() - startTime < timeout) {\n const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n\n if (!res.ok) {\n throw new Error(`VirusTotal polling failed: ${res.statusText}`);\n }\n\n const data = await res.json();\n const status = data.data.attributes.status;\n\n if (status === \"completed\") {\n const stats = data.data.attributes.stats;\n let infected = false;\n const viruses: string[] = [];\n\n if (stats.malicious > 0 || stats.suspicious > 0) {\n infected = true;\n const results = data.data.attributes.results;\n for (const [engine, info] of Object.entries<{ category: string; result: string | null }>(\n results\n )) {\n if (info.category === \"malicious\" || info.category === \"suspicious\") {\n viruses.push(`${engine}:${info.result}`);\n }\n }\n }\n\n return {\n infected,\n viruses,\n raw: data.data,\n };\n }\n\n // Wait interval before polling again\n await new Promise((resolve) => setTimeout(resolve, interval));\n }\n\n throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);\n }\n}\n","import type { PipelinePlugin, MediaRuntime, PipelineContext, PluginApi } from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nimport { ClamScanner } from \"./scanners/clam.scanner\";\nimport { runVirusScan } from \"./runtime/runner\";\n\nexport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nexport type { VirusScanner } from \"./interfaces/scanner.interface\";\nexport type { ScanResult, ScanRecord } from \"./interfaces/scan-result.interface\";\nexport { ClamScanner } from \"./scanners/clam.scanner\";\nexport type { ClamScannerOptions } from \"./scanners/clam.scanner\";\nexport { VirusTotalScanner } from \"./scanners/virustotal.scanner\";\nexport type { VirusTotalScannerOptions } from \"./scanners/virustotal.scanner\";\n\n/**\n * Virus scan plugin for the Better Media pipeline.\n *\n * Scans uploaded files for malware using a configurable scanner engine.\n * Defaults to ClamAV via the built-in ClamScanner.\n *\n * @example\n * ```ts\n * import { virusScanPlugin } from \"@better-media/plugin-virus-scan\";\n *\n * // With defaults (ClamAV daemon on localhost:3310)\n * const plugin = virusScanPlugin();\n *\n * // With custom scanner\n * const plugin = virusScanPlugin({ scanner: myCustomScanner });\n * ```\n */\nexport function virusScanPlugin(opts: VirusScanPluginOptions = {}): PipelinePlugin {\n const executionMode = opts.executionMode ?? \"background\";\n const isBackground = executionMode === \"background\";\n const scanner = opts.scanner ?? new ClamScanner();\n\n return {\n name: \"virus-scan\",\n runtimeManifest: {\n id: \"better-media-virus-scan\",\n version: \"1.0.0\",\n trustLevel: \"untrusted\",\n capabilities: [\"file.read\", \"metadata.write.own\", \"processing.write.own\"],\n namespace: \"antivirus\",\n },\n executionMode,\n intensive: isBackground,\n\n apply(runtime: MediaRuntime) {\n // Initialize scanner eagerly on first apply\n let initPromise: Promise<void> | null = null;\n\n runtime.hooks[\"scan:run\"].tap(\n \"virus-scan\",\n async (context: PipelineContext, api: PluginApi) => {\n // Lazy init — only once across all invocations\n if (!initPromise) {\n initPromise = scanner.init();\n }\n await initPromise;\n\n return runVirusScan(context, api, scanner, opts);\n },\n { mode: executionMode }\n );\n },\n };\n}\n"]}
package/dist/index.mjs CHANGED
@@ -81,6 +81,7 @@ async function recordScanResult(database, record) {
81
81
  status,
82
82
  threats: record.viruses,
83
83
  scanner: record.scannerName,
84
+ metadata: { durationMs: record.durationMs },
84
85
  createdAt: record.scannedAt
85
86
  };
86
87
  const existing = await database.findOne({
@@ -135,6 +136,7 @@ async function runVirusScan(context, api, scanner, opts) {
135
136
  if (!buffer && !tempPath) {
136
137
  throw new Error("Unable to resolve file content for virus scanning");
137
138
  }
139
+ const startTime = Date.now();
138
140
  let infected;
139
141
  let viruses;
140
142
  try {
@@ -151,13 +153,16 @@ async function runVirusScan(context, api, scanner, opts) {
151
153
  const message = err instanceof Error ? err.message : String(err);
152
154
  return { valid: false, message: `Virus scan failed: ${message}` };
153
155
  }
156
+ const durationMs = Date.now() - startTime;
154
157
  const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
155
158
  const record = {
156
159
  recordId: context.recordId,
157
160
  infected,
158
161
  viruses,
159
162
  scannedAt,
160
- scannerName: scanner.name};
163
+ scannerName: scanner.name,
164
+ durationMs
165
+ };
161
166
  await recordScanResult(database, record);
162
167
  api.emitMetadata({ infected, viruses, scannedAt });
163
168
  if (!infected) return;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/scanners/clam.scanner.ts","../src/runtime/runner.ts","../src/scanners/virustotal.scanner.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import { Readable } from \"node:stream\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\n/** ClamAV daemon connection options. */\nexport interface ClamScannerOptions {\n /** Remove infected files after detection. Default: false. */\n removeInfected?: boolean;\n /** Quarantine path for infected files. */\n quarantinePath?: string;\n /** Log scan output to console. Default: false. */\n debugMode?: boolean;\n /** Path to clamscan binary. */\n clamscanPath?: string;\n /** Path to clamdscan binary. */\n clamdscanPath?: string;\n /** ClamAV daemon connection settings. */\n clamdscan?: {\n /** Use clamd for scanning. Default: true. */\n active?: boolean;\n /** Daemon host. Default: \"127.0.0.1\". */\n host?: string;\n /** Daemon port. Default: 3310. */\n port?: number;\n /** Socket path (overrides host/port). */\n socket?: string;\n /** Connection timeout in ms. Default: 5000. */\n timeout?: number;\n };\n}\n\ninterface ClamScanInstance {\n isInfected(filePath: string): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n scanStream(stream: Readable): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n}\n\ninterface ClamScanConstructor {\n new (): { init(options: Record<string, unknown>): Promise<ClamScanInstance> };\n}\n\n/**\n * ClamAV implementation of the VirusScanner interface.\n * Wraps the `clamscan` npm package.\n */\nexport class ClamScanner implements VirusScanner {\n readonly name = \"clamav\";\n private instance: ClamScanInstance | null = null;\n private readonly options: ClamScannerOptions;\n\n constructor(options: ClamScannerOptions = {}) {\n this.options = options;\n }\n\n async init(): Promise<void> {\n if (this.instance) return;\n\n // Dynamic import to avoid hard dependency when using a different scanner\n const NodeClam = (await import(\"clamscan\")).default as unknown as ClamScanConstructor;\n this.instance = await new NodeClam().init(this.buildClamConfig());\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const stream = Readable.from(buffer);\n const result = await scanner.scanStream(stream);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const result = await scanner.isInfected(filePath);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n private async getScanner(): Promise<ClamScanInstance> {\n if (!this.instance) await this.init();\n return this.instance!;\n }\n\n private buildClamConfig(): Record<string, unknown> {\n return {\n removeInfected: this.options.removeInfected ?? false,\n quarantineInfected: this.options.quarantinePath ?? false,\n debugMode: this.options.debugMode ?? false,\n clamscan: {\n path: this.options.clamscanPath ?? \"/usr/bin/clamscan\",\n },\n clamdscan: {\n path: this.options.clamdscanPath ?? \"/usr/bin/clamdscan\",\n active: this.options.clamdscan?.active ?? true,\n host: this.options.clamdscan?.host ?? \"127.0.0.1\",\n port: this.options.clamdscan?.port ?? 3310,\n socket: this.options.clamdscan?.socket ?? null,\n timeout: this.options.clamdscan?.timeout ?? 5000,\n },\n };\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type {\n PipelineContext,\n ValidationResult,\n DatabaseAdapter,\n PluginApi,\n} from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"../interfaces/options.interface\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanRecord } from \"../interfaces/scan-result.interface\";\n\nasync function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Wrap a promise with a timeout.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);\n promise.then(\n (val) => {\n clearTimeout(timer);\n resolve(val);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n });\n}\n\nasync function recordScanResult(database: DatabaseAdapter, record: ScanRecord): Promise<void> {\n const model = \"media_virus_scan_results\";\n const status = record.infected ? \"infected\" : \"clean\";\n\n const data = {\n mediaId: record.recordId,\n status,\n threats: record.viruses,\n scanner: record.scannerName,\n createdAt: record.scannedAt,\n };\n\n const existing = await database.findOne({\n model,\n where: [{ field: \"mediaId\", value: record.recordId }],\n });\n\n if (existing) {\n await database.update({\n model,\n where: [{ field: \"id\", value: existing.id }],\n update: data,\n });\n } else {\n await database.create({\n model,\n data: { id: randomUUID(), ...data },\n });\n }\n}\n\n/**\n * Execute the scan with retry logic for transient failures.\n */\nasync function scanWithRetry(\n scanner: VirusScanner,\n buffer: Buffer,\n tempPath: string | undefined,\n opts: VirusScanPluginOptions\n): Promise<{ infected: boolean; viruses: string[] }> {\n const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;\n const delayMs = opts.retryOptions?.delayMs ?? 1000;\n const backoff = opts.retryOptions?.backoff ?? \"exponential\";\n const timeoutMs = opts.scanTimeoutMs ?? 30_000;\n\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);\n\n const result = await withTimeout(scanPromise, timeoutMs, \"Virus scan\");\n return { infected: result.infected, viruses: result.viruses };\n } catch (err: unknown) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (attempt < maxAttempts) {\n const wait =\n backoff === \"exponential\" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;\n await sleep(wait);\n }\n }\n }\n\n throw lastError ?? new Error(\"Virus scan failed after retries\");\n}\n\n/**\n * Core virus scan runner. Called from the plugin's hook tap.\n */\nexport async function runVirusScan(\n context: PipelineContext,\n api: PluginApi,\n scanner: VirusScanner,\n opts: VirusScanPluginOptions\n): Promise<void | ValidationResult> {\n const { file, database } = context;\n const fileKey = file.key;\n const fileContent = context.utilities?.fileContent;\n\n if (!fileContent || (!fileContent.buffer && !fileContent.tempPath)) {\n throw new Error(\n \"Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities\"\n );\n }\n\n // Prefer tempPath for scanFile; only read buffer when no tempPath is available\n const tempPath = fileContent.tempPath;\n const buffer = tempPath ? undefined : (fileContent.buffer ?? null);\n\n if (!buffer && !tempPath) {\n throw new Error(\"Unable to resolve file content for virus scanning\");\n }\n\n const startTime = Date.now();\n let infected: boolean;\n let viruses: string[];\n\n try {\n const result = await scanWithRetry(\n scanner,\n buffer ?? Buffer.alloc(0), // scanWithRetry will use tempPath if available\n tempPath,\n opts\n );\n infected = result.infected;\n viruses = result.viruses;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n // Scanner failure is treated as an abort — we don't know if the file is safe\n return { valid: false, message: `Virus scan failed: ${message}` };\n }\n\n const durationMs = Date.now() - startTime;\n\n const scannedAt = new Date().toISOString();\n\n // Persist scan result to DB\n const record: ScanRecord = {\n recordId: context.recordId,\n fileKey,\n infected,\n viruses,\n scannedAt,\n scannerName: scanner.name,\n durationMs,\n };\n await recordScanResult(database, record);\n\n // Write scan metadata to plugin metadata via PluginApi\n api.emitMetadata({ infected, viruses, scannedAt });\n\n if (!infected) return;\n\n // Infected file — handle according to failure mode\n const failureMessage = `Virus detected in \"${fileKey}\": ${viruses.join(\", \")}`;\n\n switch (opts.onFailure ?? \"abort\") {\n case \"continue\":\n return;\n case \"custom\":\n if (opts.onFailureCallback) {\n const customResult = await opts.onFailureCallback(fileKey, viruses);\n if (customResult && customResult.valid === false) return customResult;\n }\n return;\n case \"abort\":\n default:\n return { valid: false, message: failureMessage };\n }\n}\n","import fs from \"node:fs/promises\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\nexport interface VirusTotalScannerOptions {\n /** VirusTotal API Key */\n apiKey: string;\n /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */\n pollingTimeoutMs?: number;\n /** Polling interval in ms. Default: 5000 (5 seconds) */\n pollingIntervalMs?: number;\n}\n\n/**\n * VirusTotal API v3 implementation of the VirusScanner interface.\n * Uses the native global `fetch` API available in Node 18+.\n */\nexport class VirusTotalScanner implements VirusScanner {\n readonly name = \"virustotal\";\n private readonly options: VirusTotalScannerOptions;\n\n constructor(options: VirusTotalScannerOptions) {\n if (!options.apiKey) {\n throw new Error(\"VirusTotalScanner requires an apiKey\");\n }\n this.options = {\n pollingTimeoutMs: 120_000,\n pollingIntervalMs: 5_000,\n ...options,\n };\n }\n\n async init(): Promise<void> {\n // REST API doesn't require initialization connection\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const blob = new Blob([new Uint8Array(buffer)]);\n return this.uploadAndAnalyze(blob, \"upload.bin\");\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const buffer = await fs.readFile(filePath);\n const blob = new Blob([new Uint8Array(buffer)]);\n const filename = filePath.split(\"/\").pop() || \"upload.bin\";\n return this.uploadAndAnalyze(blob, filename);\n }\n\n private async uploadAndAnalyze(blob: Blob, filename: string): Promise<ScanResult> {\n const size = blob.size;\n let uploadUrl = \"https://www.virustotal.com/api/v3/files\";\n\n // VirusTotal v3 requires a special URL for files > 32MB\n if (size > 32 * 1024 * 1024) {\n const urlRes = await fetch(\"https://www.virustotal.com/api/v3/files/upload_url\", {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n if (!urlRes.ok) {\n throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);\n }\n const urlData = await urlRes.json();\n uploadUrl = urlData.data;\n }\n\n const formData = new FormData();\n formData.append(\"file\", blob, filename);\n\n const uploadRes = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"x-apikey\": this.options.apiKey },\n body: formData,\n });\n\n if (!uploadRes.ok) {\n const errText = await uploadRes.text().catch(() => \"\");\n throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);\n }\n\n const uploadData = await uploadRes.json();\n const analysisId = uploadData.data.id;\n\n return this.pollAnalysis(analysisId);\n }\n\n private async pollAnalysis(analysisId: string): Promise<ScanResult> {\n const startTime = Date.now();\n const timeout = this.options.pollingTimeoutMs!;\n const interval = this.options.pollingIntervalMs!;\n\n while (Date.now() - startTime < timeout) {\n const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n\n if (!res.ok) {\n throw new Error(`VirusTotal polling failed: ${res.statusText}`);\n }\n\n const data = await res.json();\n const status = data.data.attributes.status;\n\n if (status === \"completed\") {\n const stats = data.data.attributes.stats;\n let infected = false;\n const viruses: string[] = [];\n\n if (stats.malicious > 0 || stats.suspicious > 0) {\n infected = true;\n const results = data.data.attributes.results;\n for (const [engine, info] of Object.entries<{ category: string; result: string | null }>(\n results\n )) {\n if (info.category === \"malicious\" || info.category === \"suspicious\") {\n viruses.push(`${engine}:${info.result}`);\n }\n }\n }\n\n return {\n infected,\n viruses,\n raw: data.data,\n };\n }\n\n // Wait interval before polling again\n await new Promise((resolve) => setTimeout(resolve, interval));\n }\n\n throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);\n }\n}\n","import type { PipelinePlugin, MediaRuntime, PipelineContext, PluginApi } from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nimport { ClamScanner } from \"./scanners/clam.scanner\";\nimport { runVirusScan } from \"./runtime/runner\";\n\nexport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nexport type { VirusScanner } from \"./interfaces/scanner.interface\";\nexport type { ScanResult, ScanRecord } from \"./interfaces/scan-result.interface\";\nexport { ClamScanner } from \"./scanners/clam.scanner\";\nexport type { ClamScannerOptions } from \"./scanners/clam.scanner\";\nexport { VirusTotalScanner } from \"./scanners/virustotal.scanner\";\nexport type { VirusTotalScannerOptions } from \"./scanners/virustotal.scanner\";\n\n/**\n * Virus scan plugin for the Better Media pipeline.\n *\n * Scans uploaded files for malware using a configurable scanner engine.\n * Defaults to ClamAV via the built-in ClamScanner.\n *\n * @example\n * ```ts\n * import { virusScanPlugin } from \"@better-media/plugin-virus-scan\";\n *\n * // With defaults (ClamAV daemon on localhost:3310)\n * const plugin = virusScanPlugin();\n *\n * // With custom scanner\n * const plugin = virusScanPlugin({ scanner: myCustomScanner });\n * ```\n */\nexport function virusScanPlugin(opts: VirusScanPluginOptions = {}): PipelinePlugin {\n const executionMode = opts.executionMode ?? \"background\";\n const isBackground = executionMode === \"background\";\n const scanner = opts.scanner ?? new ClamScanner();\n\n return {\n name: \"virus-scan\",\n runtimeManifest: {\n id: \"better-media-virus-scan\",\n version: \"1.0.0\",\n trustLevel: \"untrusted\",\n capabilities: [\"file.read\", \"metadata.write.own\", \"processing.write.own\"],\n namespace: \"antivirus\",\n },\n executionMode,\n intensive: isBackground,\n\n apply(runtime: MediaRuntime) {\n // Initialize scanner eagerly on first apply\n let initPromise: Promise<void> | null = null;\n\n runtime.hooks[\"scan:run\"].tap(\n \"virus-scan\",\n async (context: PipelineContext, api: PluginApi) => {\n // Lazy init — only once across all invocations\n if (!initPromise) {\n initPromise = scanner.init();\n }\n await initPromise;\n\n return runVirusScan(context, api, scanner, opts);\n },\n { mode: executionMode }\n );\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/scanners/clam.scanner.ts","../src/runtime/runner.ts","../src/scanners/virustotal.scanner.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import { Readable } from \"node:stream\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\n/** ClamAV daemon connection options. */\nexport interface ClamScannerOptions {\n /** Remove infected files after detection. Default: false. */\n removeInfected?: boolean;\n /** Quarantine path for infected files. */\n quarantinePath?: string;\n /** Log scan output to console. Default: false. */\n debugMode?: boolean;\n /** Path to clamscan binary. */\n clamscanPath?: string;\n /** Path to clamdscan binary. */\n clamdscanPath?: string;\n /** ClamAV daemon connection settings. */\n clamdscan?: {\n /** Use clamd for scanning. Default: true. */\n active?: boolean;\n /** Daemon host. Default: \"127.0.0.1\". */\n host?: string;\n /** Daemon port. Default: 3310. */\n port?: number;\n /** Socket path (overrides host/port). */\n socket?: string;\n /** Connection timeout in ms. Default: 5000. */\n timeout?: number;\n };\n}\n\ninterface ClamScanInstance {\n isInfected(filePath: string): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n scanStream(stream: Readable): Promise<{\n isInfected: boolean | null;\n viruses: string[];\n }>;\n}\n\ninterface ClamScanConstructor {\n new (): { init(options: Record<string, unknown>): Promise<ClamScanInstance> };\n}\n\n/**\n * ClamAV implementation of the VirusScanner interface.\n * Wraps the `clamscan` npm package.\n */\nexport class ClamScanner implements VirusScanner {\n readonly name = \"clamav\";\n private instance: ClamScanInstance | null = null;\n private readonly options: ClamScannerOptions;\n\n constructor(options: ClamScannerOptions = {}) {\n this.options = options;\n }\n\n async init(): Promise<void> {\n if (this.instance) return;\n\n // Dynamic import to avoid hard dependency when using a different scanner\n const NodeClam = (await import(\"clamscan\")).default as unknown as ClamScanConstructor;\n this.instance = await new NodeClam().init(this.buildClamConfig());\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const stream = Readable.from(buffer);\n const result = await scanner.scanStream(stream);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const scanner = await this.getScanner();\n const result = await scanner.isInfected(filePath);\n\n return {\n infected: result.isInfected === true,\n viruses: result.viruses ?? [],\n };\n }\n\n private async getScanner(): Promise<ClamScanInstance> {\n if (!this.instance) await this.init();\n return this.instance!;\n }\n\n private buildClamConfig(): Record<string, unknown> {\n return {\n removeInfected: this.options.removeInfected ?? false,\n quarantineInfected: this.options.quarantinePath ?? false,\n debugMode: this.options.debugMode ?? false,\n clamscan: {\n path: this.options.clamscanPath ?? \"/usr/bin/clamscan\",\n },\n clamdscan: {\n path: this.options.clamdscanPath ?? \"/usr/bin/clamdscan\",\n active: this.options.clamdscan?.active ?? true,\n host: this.options.clamdscan?.host ?? \"127.0.0.1\",\n port: this.options.clamdscan?.port ?? 3310,\n socket: this.options.clamdscan?.socket ?? null,\n timeout: this.options.clamdscan?.timeout ?? 5000,\n },\n };\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type {\n PipelineContext,\n ValidationResult,\n DatabaseAdapter,\n PluginApi,\n} from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"../interfaces/options.interface\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanRecord } from \"../interfaces/scan-result.interface\";\n\nasync function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Wrap a promise with a timeout.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);\n promise.then(\n (val) => {\n clearTimeout(timer);\n resolve(val);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n }\n );\n });\n}\n\nasync function recordScanResult(database: DatabaseAdapter, record: ScanRecord): Promise<void> {\n const model = \"media_virus_scan_results\";\n const status = record.infected ? \"infected\" : \"clean\";\n\n const data = {\n mediaId: record.recordId,\n status,\n threats: record.viruses,\n scanner: record.scannerName,\n metadata: { durationMs: record.durationMs },\n createdAt: record.scannedAt,\n };\n\n const existing = await database.findOne({\n model,\n where: [{ field: \"mediaId\", value: record.recordId }],\n });\n\n if (existing) {\n await database.update({\n model,\n where: [{ field: \"id\", value: existing.id }],\n update: data,\n });\n } else {\n await database.create({\n model,\n data: { id: randomUUID(), ...data },\n });\n }\n}\n\n/**\n * Execute the scan with retry logic for transient failures.\n */\nasync function scanWithRetry(\n scanner: VirusScanner,\n buffer: Buffer,\n tempPath: string | undefined,\n opts: VirusScanPluginOptions\n): Promise<{ infected: boolean; viruses: string[] }> {\n const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;\n const delayMs = opts.retryOptions?.delayMs ?? 1000;\n const backoff = opts.retryOptions?.backoff ?? \"exponential\";\n const timeoutMs = opts.scanTimeoutMs ?? 30_000;\n\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);\n\n const result = await withTimeout(scanPromise, timeoutMs, \"Virus scan\");\n return { infected: result.infected, viruses: result.viruses };\n } catch (err: unknown) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (attempt < maxAttempts) {\n const wait =\n backoff === \"exponential\" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;\n await sleep(wait);\n }\n }\n }\n\n throw lastError ?? new Error(\"Virus scan failed after retries\");\n}\n\n/**\n * Core virus scan runner. Called from the plugin's hook tap.\n */\nexport async function runVirusScan(\n context: PipelineContext,\n api: PluginApi,\n scanner: VirusScanner,\n opts: VirusScanPluginOptions\n): Promise<void | ValidationResult> {\n const { file, database } = context;\n const fileKey = file.key;\n const fileContent = context.utilities?.fileContent;\n\n if (!fileContent || (!fileContent.buffer && !fileContent.tempPath)) {\n throw new Error(\n \"Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities\"\n );\n }\n\n // Prefer tempPath for scanFile; only read buffer when no tempPath is available\n const tempPath = fileContent.tempPath;\n const buffer = tempPath ? undefined : (fileContent.buffer ?? null);\n\n if (!buffer && !tempPath) {\n throw new Error(\"Unable to resolve file content for virus scanning\");\n }\n\n const startTime = Date.now();\n let infected: boolean;\n let viruses: string[];\n\n try {\n const result = await scanWithRetry(\n scanner,\n buffer ?? Buffer.alloc(0), // scanWithRetry will use tempPath if available\n tempPath,\n opts\n );\n infected = result.infected;\n viruses = result.viruses;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n // Scanner failure is treated as an abort — we don't know if the file is safe\n return { valid: false, message: `Virus scan failed: ${message}` };\n }\n\n const durationMs = Date.now() - startTime;\n\n const scannedAt = new Date().toISOString();\n\n // Persist scan result to DB\n const record: ScanRecord = {\n recordId: context.recordId,\n fileKey,\n infected,\n viruses,\n scannedAt,\n scannerName: scanner.name,\n durationMs,\n };\n await recordScanResult(database, record);\n\n // Write scan metadata to plugin metadata via PluginApi\n api.emitMetadata({ infected, viruses, scannedAt });\n\n if (!infected) return;\n\n // Infected file — handle according to failure mode\n const failureMessage = `Virus detected in \"${fileKey}\": ${viruses.join(\", \")}`;\n\n switch (opts.onFailure ?? \"abort\") {\n case \"continue\":\n return;\n case \"custom\":\n if (opts.onFailureCallback) {\n const customResult = await opts.onFailureCallback(fileKey, viruses);\n if (customResult && customResult.valid === false) return customResult;\n }\n return;\n case \"abort\":\n default:\n return { valid: false, message: failureMessage };\n }\n}\n","import fs from \"node:fs/promises\";\nimport type { VirusScanner } from \"../interfaces/scanner.interface\";\nimport type { ScanResult } from \"../interfaces/scan-result.interface\";\n\nexport interface VirusTotalScannerOptions {\n /** VirusTotal API Key */\n apiKey: string;\n /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */\n pollingTimeoutMs?: number;\n /** Polling interval in ms. Default: 5000 (5 seconds) */\n pollingIntervalMs?: number;\n}\n\n/**\n * VirusTotal API v3 implementation of the VirusScanner interface.\n * Uses the native global `fetch` API available in Node 18+.\n */\nexport class VirusTotalScanner implements VirusScanner {\n readonly name = \"virustotal\";\n private readonly options: VirusTotalScannerOptions;\n\n constructor(options: VirusTotalScannerOptions) {\n if (!options.apiKey) {\n throw new Error(\"VirusTotalScanner requires an apiKey\");\n }\n this.options = {\n pollingTimeoutMs: 120_000,\n pollingIntervalMs: 5_000,\n ...options,\n };\n }\n\n async init(): Promise<void> {\n // REST API doesn't require initialization connection\n }\n\n async scanBuffer(buffer: Buffer): Promise<ScanResult> {\n const blob = new Blob([new Uint8Array(buffer)]);\n return this.uploadAndAnalyze(blob, \"upload.bin\");\n }\n\n async scanFile(filePath: string): Promise<ScanResult> {\n const buffer = await fs.readFile(filePath);\n const blob = new Blob([new Uint8Array(buffer)]);\n const filename = filePath.split(\"/\").pop() || \"upload.bin\";\n return this.uploadAndAnalyze(blob, filename);\n }\n\n private async uploadAndAnalyze(blob: Blob, filename: string): Promise<ScanResult> {\n const size = blob.size;\n let uploadUrl = \"https://www.virustotal.com/api/v3/files\";\n\n // VirusTotal v3 requires a special URL for files > 32MB\n if (size > 32 * 1024 * 1024) {\n const urlRes = await fetch(\"https://www.virustotal.com/api/v3/files/upload_url\", {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n if (!urlRes.ok) {\n throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);\n }\n const urlData = await urlRes.json();\n uploadUrl = urlData.data;\n }\n\n const formData = new FormData();\n formData.append(\"file\", blob, filename);\n\n const uploadRes = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"x-apikey\": this.options.apiKey },\n body: formData,\n });\n\n if (!uploadRes.ok) {\n const errText = await uploadRes.text().catch(() => \"\");\n throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);\n }\n\n const uploadData = await uploadRes.json();\n const analysisId = uploadData.data.id;\n\n return this.pollAnalysis(analysisId);\n }\n\n private async pollAnalysis(analysisId: string): Promise<ScanResult> {\n const startTime = Date.now();\n const timeout = this.options.pollingTimeoutMs!;\n const interval = this.options.pollingIntervalMs!;\n\n while (Date.now() - startTime < timeout) {\n const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {\n headers: { \"x-apikey\": this.options.apiKey },\n });\n\n if (!res.ok) {\n throw new Error(`VirusTotal polling failed: ${res.statusText}`);\n }\n\n const data = await res.json();\n const status = data.data.attributes.status;\n\n if (status === \"completed\") {\n const stats = data.data.attributes.stats;\n let infected = false;\n const viruses: string[] = [];\n\n if (stats.malicious > 0 || stats.suspicious > 0) {\n infected = true;\n const results = data.data.attributes.results;\n for (const [engine, info] of Object.entries<{ category: string; result: string | null }>(\n results\n )) {\n if (info.category === \"malicious\" || info.category === \"suspicious\") {\n viruses.push(`${engine}:${info.result}`);\n }\n }\n }\n\n return {\n infected,\n viruses,\n raw: data.data,\n };\n }\n\n // Wait interval before polling again\n await new Promise((resolve) => setTimeout(resolve, interval));\n }\n\n throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);\n }\n}\n","import type { PipelinePlugin, MediaRuntime, PipelineContext, PluginApi } from \"@better-media/core\";\nimport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nimport { ClamScanner } from \"./scanners/clam.scanner\";\nimport { runVirusScan } from \"./runtime/runner\";\n\nexport type { VirusScanPluginOptions } from \"./interfaces/options.interface\";\nexport type { VirusScanner } from \"./interfaces/scanner.interface\";\nexport type { ScanResult, ScanRecord } from \"./interfaces/scan-result.interface\";\nexport { ClamScanner } from \"./scanners/clam.scanner\";\nexport type { ClamScannerOptions } from \"./scanners/clam.scanner\";\nexport { VirusTotalScanner } from \"./scanners/virustotal.scanner\";\nexport type { VirusTotalScannerOptions } from \"./scanners/virustotal.scanner\";\n\n/**\n * Virus scan plugin for the Better Media pipeline.\n *\n * Scans uploaded files for malware using a configurable scanner engine.\n * Defaults to ClamAV via the built-in ClamScanner.\n *\n * @example\n * ```ts\n * import { virusScanPlugin } from \"@better-media/plugin-virus-scan\";\n *\n * // With defaults (ClamAV daemon on localhost:3310)\n * const plugin = virusScanPlugin();\n *\n * // With custom scanner\n * const plugin = virusScanPlugin({ scanner: myCustomScanner });\n * ```\n */\nexport function virusScanPlugin(opts: VirusScanPluginOptions = {}): PipelinePlugin {\n const executionMode = opts.executionMode ?? \"background\";\n const isBackground = executionMode === \"background\";\n const scanner = opts.scanner ?? new ClamScanner();\n\n return {\n name: \"virus-scan\",\n runtimeManifest: {\n id: \"better-media-virus-scan\",\n version: \"1.0.0\",\n trustLevel: \"untrusted\",\n capabilities: [\"file.read\", \"metadata.write.own\", \"processing.write.own\"],\n namespace: \"antivirus\",\n },\n executionMode,\n intensive: isBackground,\n\n apply(runtime: MediaRuntime) {\n // Initialize scanner eagerly on first apply\n let initPromise: Promise<void> | null = null;\n\n runtime.hooks[\"scan:run\"].tap(\n \"virus-scan\",\n async (context: PipelineContext, api: PluginApi) => {\n // Lazy init — only once across all invocations\n if (!initPromise) {\n initPromise = scanner.init();\n }\n await initPromise;\n\n return runVirusScan(context, api, scanner, opts);\n },\n { mode: executionMode }\n );\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-media/plugin-virus-scan",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "Virus scanning plugin for Better Media pipeline",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -17,7 +17,7 @@
17
17
  "README.md"
18
18
  ],
19
19
  "dependencies": {
20
- "@better-media/core": "0.2.0"
20
+ "@better-media/core": "0.5.0"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "clamscan": "^2.4.0"
@@ -33,7 +33,7 @@
33
33
  "type": "git",
34
34
  "url": "git+https://github.com/AbenezerAtnafu/better-media.git"
35
35
  },
36
- "homepage": "https://better-media-platform.vercel.app/",
36
+ "homepage": "https://better-media.dev/",
37
37
  "bugs": {
38
38
  "url": "https://github.com/AbenezerAtnafu/better-media/issues"
39
39
  },