@better-media/plugin-virus-scan 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 abenezeratnafu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
20
+ ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @better-media/plugin-virus-scan
2
+
3
+ Virus scanning plugin for the Better Media framework.
4
+
5
+ ## Features
6
+
7
+ - **ClamAV Integration**: Local or remote ClamAV server scanning.
8
+ - **VirusTotal Integration**: API-based multi-engine scanning.
9
+ - **Background Execution**: Scan in the background to avoid blocking ingest.
10
+ - **Hook Integration**: Automatically blocks access to files containing malware.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pnpm add @better-media/plugin-virus-scan
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { virusScanPlugin } from "@better-media/plugin-virus-scan";
22
+
23
+ const media = createBetterMedia({
24
+ plugins: [
25
+ virusScanPlugin({
26
+ strategy: "clamav",
27
+ clamav: { host: "localhost", port: 3310 },
28
+ failureAction: "reject",
29
+ }),
30
+ ],
31
+ });
32
+ ```
33
+
34
+ See [better-media.dev/docs/plugins/virus-scan](https://better-media.dev/docs/plugins/virus-scan) for all strategies and options.
@@ -0,0 +1,166 @@
1
+ import { PipelinePlugin } from '@better-media/core';
2
+
3
+ /** Result of a single virus scan operation. */
4
+ interface ScanResult {
5
+ /** Whether the file was found to be infected. */
6
+ infected: boolean;
7
+ /** Names of viruses detected (empty if clean). */
8
+ viruses: string[];
9
+ /** Raw output from the scanner engine, if available. */
10
+ raw?: unknown;
11
+ }
12
+ /** Persisted scan record written to the database adapter. */
13
+ interface ScanRecord {
14
+ recordId: string;
15
+ fileKey: string;
16
+ infected: boolean;
17
+ viruses: string[];
18
+ scannedAt: string;
19
+ scannerName: string;
20
+ durationMs: number;
21
+ }
22
+
23
+ /** Abstract virus scanner contract. Implementations can wrap ClamAV, VirusTotal, etc. */
24
+ interface VirusScanner {
25
+ /** Human-readable name of the scanner engine (e.g. "clamav"). */
26
+ readonly name: string;
27
+ /** One-time initialization (connect to daemon, load definitions, etc.). */
28
+ init(): Promise<void>;
29
+ /** Scan a file buffer in memory. */
30
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
31
+ /** Scan a file on disk by absolute path. */
32
+ scanFile(filePath: string): Promise<ScanResult>;
33
+ }
34
+
35
+ /** Failure handling modes (mirrors validation plugin). */
36
+ type VirusScanFailureMode = "abort" | "continue" | "custom";
37
+ /** Callback invoked when onFailure is "custom". */
38
+ type VirusScanFailureCallback = (fileKey: string, viruses: string[]) => void | Promise<{
39
+ valid: boolean;
40
+ message?: string;
41
+ } | void>;
42
+ /**
43
+ * Configuration for the virus scan plugin.
44
+ * All fields are optional with sensible defaults.
45
+ */
46
+ interface VirusScanPluginOptions {
47
+ /**
48
+ * Execution mode: "sync" runs inline, "background" enqueues via job adapter.
49
+ * Default: "background".
50
+ */
51
+ executionMode?: "sync" | "background";
52
+ /**
53
+ * Behavior when a virus is detected:
54
+ * - "abort": return a failed ValidationResult, stopping the pipeline.
55
+ * - "continue": record to DB, let the pipeline proceed.
56
+ * - "custom": invoke onFailureCallback.
57
+ * Default: "abort".
58
+ */
59
+ onFailure?: VirusScanFailureMode;
60
+ /** Called when onFailure is "custom". */
61
+ onFailureCallback?: VirusScanFailureCallback;
62
+ /**
63
+ * Injectable scanner implementation satisfying the VirusScanner interface.
64
+ * Defaults to the built-in ClamAV scanner if omitted.
65
+ */
66
+ scanner?: VirusScanner;
67
+ /** Timeout in milliseconds for a single scan operation. Default: 30_000. */
68
+ scanTimeoutMs?: number;
69
+ /**
70
+ * Retry configuration for transient scanner failures (e.g. daemon unavailable).
71
+ */
72
+ retryOptions?: {
73
+ /** Maximum number of attempts. Default: 3. */
74
+ maxAttempts?: number;
75
+ /** Base delay between retries in ms. Default: 1000. */
76
+ delayMs?: number;
77
+ /** Backoff strategy. Default: "exponential". */
78
+ backoff?: "linear" | "exponential";
79
+ };
80
+ }
81
+
82
+ /** ClamAV daemon connection options. */
83
+ interface ClamScannerOptions {
84
+ /** Remove infected files after detection. Default: false. */
85
+ removeInfected?: boolean;
86
+ /** Quarantine path for infected files. */
87
+ quarantinePath?: string;
88
+ /** Log scan output to console. Default: false. */
89
+ debugMode?: boolean;
90
+ /** Path to clamscan binary. */
91
+ clamscanPath?: string;
92
+ /** Path to clamdscan binary. */
93
+ clamdscanPath?: string;
94
+ /** ClamAV daemon connection settings. */
95
+ clamdscan?: {
96
+ /** Use clamd for scanning. Default: true. */
97
+ active?: boolean;
98
+ /** Daemon host. Default: "127.0.0.1". */
99
+ host?: string;
100
+ /** Daemon port. Default: 3310. */
101
+ port?: number;
102
+ /** Socket path (overrides host/port). */
103
+ socket?: string;
104
+ /** Connection timeout in ms. Default: 5000. */
105
+ timeout?: number;
106
+ };
107
+ }
108
+ /**
109
+ * ClamAV implementation of the VirusScanner interface.
110
+ * Wraps the `clamscan` npm package.
111
+ */
112
+ declare class ClamScanner implements VirusScanner {
113
+ readonly name = "clamav";
114
+ private instance;
115
+ private readonly options;
116
+ constructor(options?: ClamScannerOptions);
117
+ init(): Promise<void>;
118
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
119
+ scanFile(filePath: string): Promise<ScanResult>;
120
+ private getScanner;
121
+ private buildClamConfig;
122
+ }
123
+
124
+ interface VirusTotalScannerOptions {
125
+ /** VirusTotal API Key */
126
+ apiKey: string;
127
+ /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */
128
+ pollingTimeoutMs?: number;
129
+ /** Polling interval in ms. Default: 5000 (5 seconds) */
130
+ pollingIntervalMs?: number;
131
+ }
132
+ /**
133
+ * VirusTotal API v3 implementation of the VirusScanner interface.
134
+ * Uses the native global `fetch` API available in Node 18+.
135
+ */
136
+ declare class VirusTotalScanner implements VirusScanner {
137
+ readonly name = "virustotal";
138
+ private readonly options;
139
+ constructor(options: VirusTotalScannerOptions);
140
+ init(): Promise<void>;
141
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
142
+ scanFile(filePath: string): Promise<ScanResult>;
143
+ private uploadAndAnalyze;
144
+ private pollAnalysis;
145
+ }
146
+
147
+ /**
148
+ * Virus scan plugin for the Better Media pipeline.
149
+ *
150
+ * Scans uploaded files for malware using a configurable scanner engine.
151
+ * Defaults to ClamAV via the built-in ClamScanner.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { virusScanPlugin } from "@better-media/plugin-virus-scan";
156
+ *
157
+ * // With defaults (ClamAV daemon on localhost:3310)
158
+ * const plugin = virusScanPlugin();
159
+ *
160
+ * // With custom scanner
161
+ * const plugin = virusScanPlugin({ scanner: myCustomScanner });
162
+ * ```
163
+ */
164
+ declare function virusScanPlugin(opts?: VirusScanPluginOptions): PipelinePlugin;
165
+
166
+ export { ClamScanner, type ClamScannerOptions, type ScanRecord, type ScanResult, type VirusScanPluginOptions, type VirusScanner, VirusTotalScanner, type VirusTotalScannerOptions, virusScanPlugin };
@@ -0,0 +1,166 @@
1
+ import { PipelinePlugin } from '@better-media/core';
2
+
3
+ /** Result of a single virus scan operation. */
4
+ interface ScanResult {
5
+ /** Whether the file was found to be infected. */
6
+ infected: boolean;
7
+ /** Names of viruses detected (empty if clean). */
8
+ viruses: string[];
9
+ /** Raw output from the scanner engine, if available. */
10
+ raw?: unknown;
11
+ }
12
+ /** Persisted scan record written to the database adapter. */
13
+ interface ScanRecord {
14
+ recordId: string;
15
+ fileKey: string;
16
+ infected: boolean;
17
+ viruses: string[];
18
+ scannedAt: string;
19
+ scannerName: string;
20
+ durationMs: number;
21
+ }
22
+
23
+ /** Abstract virus scanner contract. Implementations can wrap ClamAV, VirusTotal, etc. */
24
+ interface VirusScanner {
25
+ /** Human-readable name of the scanner engine (e.g. "clamav"). */
26
+ readonly name: string;
27
+ /** One-time initialization (connect to daemon, load definitions, etc.). */
28
+ init(): Promise<void>;
29
+ /** Scan a file buffer in memory. */
30
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
31
+ /** Scan a file on disk by absolute path. */
32
+ scanFile(filePath: string): Promise<ScanResult>;
33
+ }
34
+
35
+ /** Failure handling modes (mirrors validation plugin). */
36
+ type VirusScanFailureMode = "abort" | "continue" | "custom";
37
+ /** Callback invoked when onFailure is "custom". */
38
+ type VirusScanFailureCallback = (fileKey: string, viruses: string[]) => void | Promise<{
39
+ valid: boolean;
40
+ message?: string;
41
+ } | void>;
42
+ /**
43
+ * Configuration for the virus scan plugin.
44
+ * All fields are optional with sensible defaults.
45
+ */
46
+ interface VirusScanPluginOptions {
47
+ /**
48
+ * Execution mode: "sync" runs inline, "background" enqueues via job adapter.
49
+ * Default: "background".
50
+ */
51
+ executionMode?: "sync" | "background";
52
+ /**
53
+ * Behavior when a virus is detected:
54
+ * - "abort": return a failed ValidationResult, stopping the pipeline.
55
+ * - "continue": record to DB, let the pipeline proceed.
56
+ * - "custom": invoke onFailureCallback.
57
+ * Default: "abort".
58
+ */
59
+ onFailure?: VirusScanFailureMode;
60
+ /** Called when onFailure is "custom". */
61
+ onFailureCallback?: VirusScanFailureCallback;
62
+ /**
63
+ * Injectable scanner implementation satisfying the VirusScanner interface.
64
+ * Defaults to the built-in ClamAV scanner if omitted.
65
+ */
66
+ scanner?: VirusScanner;
67
+ /** Timeout in milliseconds for a single scan operation. Default: 30_000. */
68
+ scanTimeoutMs?: number;
69
+ /**
70
+ * Retry configuration for transient scanner failures (e.g. daemon unavailable).
71
+ */
72
+ retryOptions?: {
73
+ /** Maximum number of attempts. Default: 3. */
74
+ maxAttempts?: number;
75
+ /** Base delay between retries in ms. Default: 1000. */
76
+ delayMs?: number;
77
+ /** Backoff strategy. Default: "exponential". */
78
+ backoff?: "linear" | "exponential";
79
+ };
80
+ }
81
+
82
+ /** ClamAV daemon connection options. */
83
+ interface ClamScannerOptions {
84
+ /** Remove infected files after detection. Default: false. */
85
+ removeInfected?: boolean;
86
+ /** Quarantine path for infected files. */
87
+ quarantinePath?: string;
88
+ /** Log scan output to console. Default: false. */
89
+ debugMode?: boolean;
90
+ /** Path to clamscan binary. */
91
+ clamscanPath?: string;
92
+ /** Path to clamdscan binary. */
93
+ clamdscanPath?: string;
94
+ /** ClamAV daemon connection settings. */
95
+ clamdscan?: {
96
+ /** Use clamd for scanning. Default: true. */
97
+ active?: boolean;
98
+ /** Daemon host. Default: "127.0.0.1". */
99
+ host?: string;
100
+ /** Daemon port. Default: 3310. */
101
+ port?: number;
102
+ /** Socket path (overrides host/port). */
103
+ socket?: string;
104
+ /** Connection timeout in ms. Default: 5000. */
105
+ timeout?: number;
106
+ };
107
+ }
108
+ /**
109
+ * ClamAV implementation of the VirusScanner interface.
110
+ * Wraps the `clamscan` npm package.
111
+ */
112
+ declare class ClamScanner implements VirusScanner {
113
+ readonly name = "clamav";
114
+ private instance;
115
+ private readonly options;
116
+ constructor(options?: ClamScannerOptions);
117
+ init(): Promise<void>;
118
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
119
+ scanFile(filePath: string): Promise<ScanResult>;
120
+ private getScanner;
121
+ private buildClamConfig;
122
+ }
123
+
124
+ interface VirusTotalScannerOptions {
125
+ /** VirusTotal API Key */
126
+ apiKey: string;
127
+ /** Max time to poll for analysis completion in ms. Default: 120,000 (2 minutes) */
128
+ pollingTimeoutMs?: number;
129
+ /** Polling interval in ms. Default: 5000 (5 seconds) */
130
+ pollingIntervalMs?: number;
131
+ }
132
+ /**
133
+ * VirusTotal API v3 implementation of the VirusScanner interface.
134
+ * Uses the native global `fetch` API available in Node 18+.
135
+ */
136
+ declare class VirusTotalScanner implements VirusScanner {
137
+ readonly name = "virustotal";
138
+ private readonly options;
139
+ constructor(options: VirusTotalScannerOptions);
140
+ init(): Promise<void>;
141
+ scanBuffer(buffer: Buffer): Promise<ScanResult>;
142
+ scanFile(filePath: string): Promise<ScanResult>;
143
+ private uploadAndAnalyze;
144
+ private pollAnalysis;
145
+ }
146
+
147
+ /**
148
+ * Virus scan plugin for the Better Media pipeline.
149
+ *
150
+ * Scans uploaded files for malware using a configurable scanner engine.
151
+ * Defaults to ClamAV via the built-in ClamScanner.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { virusScanPlugin } from "@better-media/plugin-virus-scan";
156
+ *
157
+ * // With defaults (ClamAV daemon on localhost:3310)
158
+ * const plugin = virusScanPlugin();
159
+ *
160
+ * // With custom scanner
161
+ * const plugin = virusScanPlugin({ scanner: myCustomScanner });
162
+ * ```
163
+ */
164
+ declare function virusScanPlugin(opts?: VirusScanPluginOptions): PipelinePlugin;
165
+
166
+ export { ClamScanner, type ClamScannerOptions, type ScanRecord, type ScanResult, type VirusScanPluginOptions, type VirusScanner, VirusTotalScanner, type VirusTotalScannerOptions, virusScanPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ var stream = require('stream');
4
+ var crypto = require('crypto');
5
+ var fs = require('fs/promises');
6
+
7
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
8
+
9
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
10
+
11
+ // src/scanners/clam.scanner.ts
12
+ var ClamScanner = class {
13
+ name = "clamav";
14
+ instance = null;
15
+ options;
16
+ constructor(options = {}) {
17
+ this.options = options;
18
+ }
19
+ async init() {
20
+ if (this.instance) return;
21
+ const NodeClam = (await import('clamscan')).default;
22
+ this.instance = await new NodeClam().init(this.buildClamConfig());
23
+ }
24
+ async scanBuffer(buffer) {
25
+ const scanner = await this.getScanner();
26
+ const stream$1 = stream.Readable.from(buffer);
27
+ const result = await scanner.scanStream(stream$1);
28
+ return {
29
+ infected: result.isInfected === true,
30
+ viruses: result.viruses ?? []
31
+ };
32
+ }
33
+ async scanFile(filePath) {
34
+ const scanner = await this.getScanner();
35
+ const result = await scanner.isInfected(filePath);
36
+ return {
37
+ infected: result.isInfected === true,
38
+ viruses: result.viruses ?? []
39
+ };
40
+ }
41
+ async getScanner() {
42
+ if (!this.instance) await this.init();
43
+ return this.instance;
44
+ }
45
+ buildClamConfig() {
46
+ return {
47
+ removeInfected: this.options.removeInfected ?? false,
48
+ quarantineInfected: this.options.quarantinePath ?? false,
49
+ debugMode: this.options.debugMode ?? false,
50
+ clamscan: {
51
+ path: this.options.clamscanPath ?? "/usr/bin/clamscan"
52
+ },
53
+ clamdscan: {
54
+ path: this.options.clamdscanPath ?? "/usr/bin/clamdscan",
55
+ active: this.options.clamdscan?.active ?? true,
56
+ host: this.options.clamdscan?.host ?? "127.0.0.1",
57
+ port: this.options.clamdscan?.port ?? 3310,
58
+ socket: this.options.clamdscan?.socket ?? null,
59
+ timeout: this.options.clamdscan?.timeout ?? 5e3
60
+ }
61
+ };
62
+ }
63
+ };
64
+ async function sleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+ function withTimeout(promise, ms, label) {
68
+ return new Promise((resolve, reject) => {
69
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
70
+ promise.then(
71
+ (val) => {
72
+ clearTimeout(timer);
73
+ resolve(val);
74
+ },
75
+ (err) => {
76
+ clearTimeout(timer);
77
+ reject(err);
78
+ }
79
+ );
80
+ });
81
+ }
82
+ async function recordScanResult(database, record) {
83
+ const model = "media_virus_scan_results";
84
+ const status = record.infected ? "infected" : "clean";
85
+ const data = {
86
+ mediaId: record.recordId,
87
+ status,
88
+ threats: record.viruses,
89
+ scanner: record.scannerName,
90
+ createdAt: record.scannedAt
91
+ };
92
+ const existing = await database.findOne({
93
+ model,
94
+ where: [{ field: "mediaId", value: record.recordId }]
95
+ });
96
+ if (existing) {
97
+ await database.update({
98
+ model,
99
+ where: [{ field: "id", value: existing.id }],
100
+ update: data
101
+ });
102
+ } else {
103
+ await database.create({
104
+ model,
105
+ data: { id: crypto.randomUUID(), ...data }
106
+ });
107
+ }
108
+ }
109
+ async function scanWithRetry(scanner, buffer, tempPath, opts) {
110
+ const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;
111
+ const delayMs = opts.retryOptions?.delayMs ?? 1e3;
112
+ const backoff = opts.retryOptions?.backoff ?? "exponential";
113
+ const timeoutMs = opts.scanTimeoutMs ?? 3e4;
114
+ let lastError;
115
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
116
+ try {
117
+ const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);
118
+ const result = await withTimeout(scanPromise, timeoutMs, "Virus scan");
119
+ return { infected: result.infected, viruses: result.viruses };
120
+ } catch (err) {
121
+ lastError = err instanceof Error ? err : new Error(String(err));
122
+ if (attempt < maxAttempts) {
123
+ const wait = backoff === "exponential" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;
124
+ await sleep(wait);
125
+ }
126
+ }
127
+ }
128
+ throw lastError ?? new Error("Virus scan failed after retries");
129
+ }
130
+ async function runVirusScan(context, api, scanner, opts) {
131
+ const { file, database } = context;
132
+ const fileKey = file.key;
133
+ const fileContent = context.utilities?.fileContent;
134
+ if (!fileContent || !fileContent.buffer && !fileContent.tempPath) {
135
+ throw new Error(
136
+ "Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities"
137
+ );
138
+ }
139
+ const tempPath = fileContent.tempPath;
140
+ const buffer = tempPath ? void 0 : fileContent.buffer ?? null;
141
+ if (!buffer && !tempPath) {
142
+ throw new Error("Unable to resolve file content for virus scanning");
143
+ }
144
+ let infected;
145
+ let viruses;
146
+ try {
147
+ const result = await scanWithRetry(
148
+ scanner,
149
+ buffer ?? Buffer.alloc(0),
150
+ // scanWithRetry will use tempPath if available
151
+ tempPath,
152
+ opts
153
+ );
154
+ infected = result.infected;
155
+ viruses = result.viruses;
156
+ } catch (err) {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ return { valid: false, message: `Virus scan failed: ${message}` };
159
+ }
160
+ const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
161
+ const record = {
162
+ recordId: context.recordId,
163
+ infected,
164
+ viruses,
165
+ scannedAt,
166
+ scannerName: scanner.name};
167
+ await recordScanResult(database, record);
168
+ api.emitMetadata({ infected, viruses, scannedAt });
169
+ if (!infected) return;
170
+ const failureMessage = `Virus detected in "${fileKey}": ${viruses.join(", ")}`;
171
+ switch (opts.onFailure ?? "abort") {
172
+ case "continue":
173
+ return;
174
+ case "custom":
175
+ if (opts.onFailureCallback) {
176
+ const customResult = await opts.onFailureCallback(fileKey, viruses);
177
+ if (customResult && customResult.valid === false) return customResult;
178
+ }
179
+ return;
180
+ case "abort":
181
+ default:
182
+ return { valid: false, message: failureMessage };
183
+ }
184
+ }
185
+ var VirusTotalScanner = class {
186
+ name = "virustotal";
187
+ options;
188
+ constructor(options) {
189
+ if (!options.apiKey) {
190
+ throw new Error("VirusTotalScanner requires an apiKey");
191
+ }
192
+ this.options = {
193
+ pollingTimeoutMs: 12e4,
194
+ pollingIntervalMs: 5e3,
195
+ ...options
196
+ };
197
+ }
198
+ async init() {
199
+ }
200
+ async scanBuffer(buffer) {
201
+ const blob = new Blob([new Uint8Array(buffer)]);
202
+ return this.uploadAndAnalyze(blob, "upload.bin");
203
+ }
204
+ async scanFile(filePath) {
205
+ const buffer = await fs__default.default.readFile(filePath);
206
+ const blob = new Blob([new Uint8Array(buffer)]);
207
+ const filename = filePath.split("/").pop() || "upload.bin";
208
+ return this.uploadAndAnalyze(blob, filename);
209
+ }
210
+ async uploadAndAnalyze(blob, filename) {
211
+ const size = blob.size;
212
+ let uploadUrl = "https://www.virustotal.com/api/v3/files";
213
+ if (size > 32 * 1024 * 1024) {
214
+ const urlRes = await fetch("https://www.virustotal.com/api/v3/files/upload_url", {
215
+ headers: { "x-apikey": this.options.apiKey }
216
+ });
217
+ if (!urlRes.ok) {
218
+ throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);
219
+ }
220
+ const urlData = await urlRes.json();
221
+ uploadUrl = urlData.data;
222
+ }
223
+ const formData = new FormData();
224
+ formData.append("file", blob, filename);
225
+ const uploadRes = await fetch(uploadUrl, {
226
+ method: "POST",
227
+ headers: { "x-apikey": this.options.apiKey },
228
+ body: formData
229
+ });
230
+ if (!uploadRes.ok) {
231
+ const errText = await uploadRes.text().catch(() => "");
232
+ throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);
233
+ }
234
+ const uploadData = await uploadRes.json();
235
+ const analysisId = uploadData.data.id;
236
+ return this.pollAnalysis(analysisId);
237
+ }
238
+ async pollAnalysis(analysisId) {
239
+ const startTime = Date.now();
240
+ const timeout = this.options.pollingTimeoutMs;
241
+ const interval = this.options.pollingIntervalMs;
242
+ while (Date.now() - startTime < timeout) {
243
+ const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {
244
+ headers: { "x-apikey": this.options.apiKey }
245
+ });
246
+ if (!res.ok) {
247
+ throw new Error(`VirusTotal polling failed: ${res.statusText}`);
248
+ }
249
+ const data = await res.json();
250
+ const status = data.data.attributes.status;
251
+ if (status === "completed") {
252
+ const stats = data.data.attributes.stats;
253
+ let infected = false;
254
+ const viruses = [];
255
+ if (stats.malicious > 0 || stats.suspicious > 0) {
256
+ infected = true;
257
+ const results = data.data.attributes.results;
258
+ for (const [engine, info] of Object.entries(
259
+ results
260
+ )) {
261
+ if (info.category === "malicious" || info.category === "suspicious") {
262
+ viruses.push(`${engine}:${info.result}`);
263
+ }
264
+ }
265
+ }
266
+ return {
267
+ infected,
268
+ viruses,
269
+ raw: data.data
270
+ };
271
+ }
272
+ await new Promise((resolve) => setTimeout(resolve, interval));
273
+ }
274
+ throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);
275
+ }
276
+ };
277
+
278
+ // src/index.ts
279
+ function virusScanPlugin(opts = {}) {
280
+ const executionMode = opts.executionMode ?? "background";
281
+ const isBackground = executionMode === "background";
282
+ const scanner = opts.scanner ?? new ClamScanner();
283
+ return {
284
+ name: "virus-scan",
285
+ runtimeManifest: {
286
+ id: "better-media-virus-scan",
287
+ version: "1.0.0",
288
+ trustLevel: "untrusted",
289
+ capabilities: ["file.read", "metadata.write.own", "processing.write.own"],
290
+ namespace: "antivirus"
291
+ },
292
+ executionMode,
293
+ intensive: isBackground,
294
+ apply(runtime) {
295
+ let initPromise = null;
296
+ runtime.hooks["scan:run"].tap(
297
+ "virus-scan",
298
+ async (context, api) => {
299
+ if (!initPromise) {
300
+ initPromise = scanner.init();
301
+ }
302
+ await initPromise;
303
+ return runVirusScan(context, api, scanner, opts);
304
+ },
305
+ { mode: executionMode }
306
+ );
307
+ }
308
+ };
309
+ }
310
+
311
+ exports.ClamScanner = ClamScanner;
312
+ exports.VirusTotalScanner = VirusTotalScanner;
313
+ exports.virusScanPlugin = virusScanPlugin;
314
+ //# sourceMappingURL=index.js.map
315
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,307 @@
1
+ import { Readable } from 'stream';
2
+ import { randomUUID } from 'crypto';
3
+ import fs from 'fs/promises';
4
+
5
+ // src/scanners/clam.scanner.ts
6
+ var ClamScanner = class {
7
+ name = "clamav";
8
+ instance = null;
9
+ options;
10
+ constructor(options = {}) {
11
+ this.options = options;
12
+ }
13
+ async init() {
14
+ if (this.instance) return;
15
+ const NodeClam = (await import('clamscan')).default;
16
+ this.instance = await new NodeClam().init(this.buildClamConfig());
17
+ }
18
+ async scanBuffer(buffer) {
19
+ const scanner = await this.getScanner();
20
+ const stream = Readable.from(buffer);
21
+ const result = await scanner.scanStream(stream);
22
+ return {
23
+ infected: result.isInfected === true,
24
+ viruses: result.viruses ?? []
25
+ };
26
+ }
27
+ async scanFile(filePath) {
28
+ const scanner = await this.getScanner();
29
+ const result = await scanner.isInfected(filePath);
30
+ return {
31
+ infected: result.isInfected === true,
32
+ viruses: result.viruses ?? []
33
+ };
34
+ }
35
+ async getScanner() {
36
+ if (!this.instance) await this.init();
37
+ return this.instance;
38
+ }
39
+ buildClamConfig() {
40
+ return {
41
+ removeInfected: this.options.removeInfected ?? false,
42
+ quarantineInfected: this.options.quarantinePath ?? false,
43
+ debugMode: this.options.debugMode ?? false,
44
+ clamscan: {
45
+ path: this.options.clamscanPath ?? "/usr/bin/clamscan"
46
+ },
47
+ clamdscan: {
48
+ path: this.options.clamdscanPath ?? "/usr/bin/clamdscan",
49
+ active: this.options.clamdscan?.active ?? true,
50
+ host: this.options.clamdscan?.host ?? "127.0.0.1",
51
+ port: this.options.clamdscan?.port ?? 3310,
52
+ socket: this.options.clamdscan?.socket ?? null,
53
+ timeout: this.options.clamdscan?.timeout ?? 5e3
54
+ }
55
+ };
56
+ }
57
+ };
58
+ async function sleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ function withTimeout(promise, ms, label) {
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
64
+ promise.then(
65
+ (val) => {
66
+ clearTimeout(timer);
67
+ resolve(val);
68
+ },
69
+ (err) => {
70
+ clearTimeout(timer);
71
+ reject(err);
72
+ }
73
+ );
74
+ });
75
+ }
76
+ async function recordScanResult(database, record) {
77
+ const model = "media_virus_scan_results";
78
+ const status = record.infected ? "infected" : "clean";
79
+ const data = {
80
+ mediaId: record.recordId,
81
+ status,
82
+ threats: record.viruses,
83
+ scanner: record.scannerName,
84
+ createdAt: record.scannedAt
85
+ };
86
+ const existing = await database.findOne({
87
+ model,
88
+ where: [{ field: "mediaId", value: record.recordId }]
89
+ });
90
+ if (existing) {
91
+ await database.update({
92
+ model,
93
+ where: [{ field: "id", value: existing.id }],
94
+ update: data
95
+ });
96
+ } else {
97
+ await database.create({
98
+ model,
99
+ data: { id: randomUUID(), ...data }
100
+ });
101
+ }
102
+ }
103
+ async function scanWithRetry(scanner, buffer, tempPath, opts) {
104
+ const maxAttempts = opts.retryOptions?.maxAttempts ?? 3;
105
+ const delayMs = opts.retryOptions?.delayMs ?? 1e3;
106
+ const backoff = opts.retryOptions?.backoff ?? "exponential";
107
+ const timeoutMs = opts.scanTimeoutMs ?? 3e4;
108
+ let lastError;
109
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
110
+ try {
111
+ const scanPromise = tempPath ? scanner.scanFile(tempPath) : scanner.scanBuffer(buffer);
112
+ const result = await withTimeout(scanPromise, timeoutMs, "Virus scan");
113
+ return { infected: result.infected, viruses: result.viruses };
114
+ } catch (err) {
115
+ lastError = err instanceof Error ? err : new Error(String(err));
116
+ if (attempt < maxAttempts) {
117
+ const wait = backoff === "exponential" ? delayMs * Math.pow(2, attempt - 1) : delayMs * attempt;
118
+ await sleep(wait);
119
+ }
120
+ }
121
+ }
122
+ throw lastError ?? new Error("Virus scan failed after retries");
123
+ }
124
+ async function runVirusScan(context, api, scanner, opts) {
125
+ const { file, database } = context;
126
+ const fileKey = file.key;
127
+ const fileContent = context.utilities?.fileContent;
128
+ if (!fileContent || !fileContent.buffer && !fileContent.tempPath) {
129
+ throw new Error(
130
+ "Virus scan plugin requires fileContent (buffer or tempPath) to be available in context.utilities"
131
+ );
132
+ }
133
+ const tempPath = fileContent.tempPath;
134
+ const buffer = tempPath ? void 0 : fileContent.buffer ?? null;
135
+ if (!buffer && !tempPath) {
136
+ throw new Error("Unable to resolve file content for virus scanning");
137
+ }
138
+ let infected;
139
+ let viruses;
140
+ try {
141
+ const result = await scanWithRetry(
142
+ scanner,
143
+ buffer ?? Buffer.alloc(0),
144
+ // scanWithRetry will use tempPath if available
145
+ tempPath,
146
+ opts
147
+ );
148
+ infected = result.infected;
149
+ viruses = result.viruses;
150
+ } catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ return { valid: false, message: `Virus scan failed: ${message}` };
153
+ }
154
+ const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
155
+ const record = {
156
+ recordId: context.recordId,
157
+ infected,
158
+ viruses,
159
+ scannedAt,
160
+ scannerName: scanner.name};
161
+ await recordScanResult(database, record);
162
+ api.emitMetadata({ infected, viruses, scannedAt });
163
+ if (!infected) return;
164
+ const failureMessage = `Virus detected in "${fileKey}": ${viruses.join(", ")}`;
165
+ switch (opts.onFailure ?? "abort") {
166
+ case "continue":
167
+ return;
168
+ case "custom":
169
+ if (opts.onFailureCallback) {
170
+ const customResult = await opts.onFailureCallback(fileKey, viruses);
171
+ if (customResult && customResult.valid === false) return customResult;
172
+ }
173
+ return;
174
+ case "abort":
175
+ default:
176
+ return { valid: false, message: failureMessage };
177
+ }
178
+ }
179
+ var VirusTotalScanner = class {
180
+ name = "virustotal";
181
+ options;
182
+ constructor(options) {
183
+ if (!options.apiKey) {
184
+ throw new Error("VirusTotalScanner requires an apiKey");
185
+ }
186
+ this.options = {
187
+ pollingTimeoutMs: 12e4,
188
+ pollingIntervalMs: 5e3,
189
+ ...options
190
+ };
191
+ }
192
+ async init() {
193
+ }
194
+ async scanBuffer(buffer) {
195
+ const blob = new Blob([new Uint8Array(buffer)]);
196
+ return this.uploadAndAnalyze(blob, "upload.bin");
197
+ }
198
+ async scanFile(filePath) {
199
+ const buffer = await fs.readFile(filePath);
200
+ const blob = new Blob([new Uint8Array(buffer)]);
201
+ const filename = filePath.split("/").pop() || "upload.bin";
202
+ return this.uploadAndAnalyze(blob, filename);
203
+ }
204
+ async uploadAndAnalyze(blob, filename) {
205
+ const size = blob.size;
206
+ let uploadUrl = "https://www.virustotal.com/api/v3/files";
207
+ if (size > 32 * 1024 * 1024) {
208
+ const urlRes = await fetch("https://www.virustotal.com/api/v3/files/upload_url", {
209
+ headers: { "x-apikey": this.options.apiKey }
210
+ });
211
+ if (!urlRes.ok) {
212
+ throw new Error(`VirusTotal failed to get upload URL: ${urlRes.statusText}`);
213
+ }
214
+ const urlData = await urlRes.json();
215
+ uploadUrl = urlData.data;
216
+ }
217
+ const formData = new FormData();
218
+ formData.append("file", blob, filename);
219
+ const uploadRes = await fetch(uploadUrl, {
220
+ method: "POST",
221
+ headers: { "x-apikey": this.options.apiKey },
222
+ body: formData
223
+ });
224
+ if (!uploadRes.ok) {
225
+ const errText = await uploadRes.text().catch(() => "");
226
+ throw new Error(`VirusTotal upload failed: ${uploadRes.status} ${errText}`);
227
+ }
228
+ const uploadData = await uploadRes.json();
229
+ const analysisId = uploadData.data.id;
230
+ return this.pollAnalysis(analysisId);
231
+ }
232
+ async pollAnalysis(analysisId) {
233
+ const startTime = Date.now();
234
+ const timeout = this.options.pollingTimeoutMs;
235
+ const interval = this.options.pollingIntervalMs;
236
+ while (Date.now() - startTime < timeout) {
237
+ const res = await fetch(`https://www.virustotal.com/api/v3/analyses/${analysisId}`, {
238
+ headers: { "x-apikey": this.options.apiKey }
239
+ });
240
+ if (!res.ok) {
241
+ throw new Error(`VirusTotal polling failed: ${res.statusText}`);
242
+ }
243
+ const data = await res.json();
244
+ const status = data.data.attributes.status;
245
+ if (status === "completed") {
246
+ const stats = data.data.attributes.stats;
247
+ let infected = false;
248
+ const viruses = [];
249
+ if (stats.malicious > 0 || stats.suspicious > 0) {
250
+ infected = true;
251
+ const results = data.data.attributes.results;
252
+ for (const [engine, info] of Object.entries(
253
+ results
254
+ )) {
255
+ if (info.category === "malicious" || info.category === "suspicious") {
256
+ viruses.push(`${engine}:${info.result}`);
257
+ }
258
+ }
259
+ }
260
+ return {
261
+ infected,
262
+ viruses,
263
+ raw: data.data
264
+ };
265
+ }
266
+ await new Promise((resolve) => setTimeout(resolve, interval));
267
+ }
268
+ throw new Error(`VirusTotal scan timed out waiting for analysis ${analysisId} to complete`);
269
+ }
270
+ };
271
+
272
+ // src/index.ts
273
+ function virusScanPlugin(opts = {}) {
274
+ const executionMode = opts.executionMode ?? "background";
275
+ const isBackground = executionMode === "background";
276
+ const scanner = opts.scanner ?? new ClamScanner();
277
+ return {
278
+ name: "virus-scan",
279
+ runtimeManifest: {
280
+ id: "better-media-virus-scan",
281
+ version: "1.0.0",
282
+ trustLevel: "untrusted",
283
+ capabilities: ["file.read", "metadata.write.own", "processing.write.own"],
284
+ namespace: "antivirus"
285
+ },
286
+ executionMode,
287
+ intensive: isBackground,
288
+ apply(runtime) {
289
+ let initPromise = null;
290
+ runtime.hooks["scan:run"].tap(
291
+ "virus-scan",
292
+ async (context, api) => {
293
+ if (!initPromise) {
294
+ initPromise = scanner.init();
295
+ }
296
+ await initPromise;
297
+ return runVirusScan(context, api, scanner, opts);
298
+ },
299
+ { mode: executionMode }
300
+ );
301
+ }
302
+ };
303
+ }
304
+
305
+ export { ClamScanner, VirusTotalScanner, virusScanPlugin };
306
+ //# sourceMappingURL=index.mjs.map
307
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@better-media/plugin-virus-scan",
3
+ "version": "0.1.0",
4
+ "description": "Virus scanning plugin for Better Media pipeline",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@better-media/core": "0.1.0"
21
+ },
22
+ "peerDependencies": {
23
+ "clamscan": "^2.4.0"
24
+ },
25
+ "keywords": [],
26
+ "author": "Abenezer Atnafu",
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "@types/clamscan": "^2.4.1",
30
+ "clamscan": "^2.4.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/AbenezerAtnafu/better-media.git"
35
+ },
36
+ "homepage": "https://better-media.dev",
37
+ "bugs": {
38
+ "url": "https://github.com/AbenezerAtnafu/better-media/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit",
47
+ "lint": "eslint .",
48
+ "test": "jest"
49
+ }
50
+ }