@better-media/plugin-virus-scan 0.3.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 +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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
|
|
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;
|
package/dist/index.mjs.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":[],"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.
|
|
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.
|
|
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
|
|
36
|
+
"homepage": "https://better-media.dev/",
|
|
37
37
|
"bugs": {
|
|
38
38
|
"url": "https://github.com/AbenezerAtnafu/better-media/issues"
|
|
39
39
|
},
|