@uploadista/flow-security-clamscan 0.0.16-beta.1

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.
@@ -0,0 +1,22 @@
1
+
2
+ 
3
+ > @uploadista/flow-security-clamscan@0.0.15 build /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan
4
+ > tsdown
5
+
6
+ ℹ tsdown v0.16.5 powered by rolldown v1.0.0-beta.50
7
+ ℹ Using tsdown config: /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan/tsdown.config.ts
8
+ ℹ entry: src/index.ts
9
+ ℹ tsconfig: tsconfig.json
10
+ ℹ Build start
11
+ ℹ Cleaning 7 files
12
+ ℹ [CJS] dist/index.cjs 2.84 kB │ gzip: 1.17 kB
13
+ ℹ [CJS] 1 files, total: 2.84 kB
14
+ ℹ [ESM] dist/index.mjs 2.25 kB │ gzip: 0.92 kB
15
+ ℹ [ESM] dist/index.mjs.map 9.32 kB │ gzip: 2.88 kB
16
+ ℹ [ESM] dist/index.d.mts.map 0.25 kB │ gzip: 0.17 kB
17
+ ℹ [ESM] dist/index.d.mts 1.65 kB │ gzip: 0.73 kB
18
+ ℹ [ESM] 4 files, total: 13.46 kB
19
+ ℹ [CJS] dist/index.d.cts.map 0.25 kB │ gzip: 0.17 kB
20
+ ℹ [CJS] dist/index.d.cts 1.65 kB │ gzip: 0.73 kB
21
+ ℹ [CJS] 2 files, total: 1.90 kB
22
+ ✔ Build complete in 2707ms
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 uploadista
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, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # @uploadista/flow-security-clamscan
2
+
3
+ ClamAV virus scanning plugin for Uploadista Flow. Provides virus and malware detection using the industry-standard ClamAV antivirus engine.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @uploadista/flow-security-clamscan
9
+ # or
10
+ pnpm add @uploadista/flow-security-clamscan
11
+ # or
12
+ yarn add @uploadista/flow-security-clamscan
13
+ ```
14
+
15
+ ## System Requirements
16
+
17
+ This plugin requires ClamAV to be installed on your system. ClamAV can run in two modes:
18
+
19
+ 1. **clamd daemon** (recommended): Faster, persistent scanning service
20
+ 2. **clamscan binary**: Slower but works without daemon
21
+
22
+ ### Installing ClamAV
23
+
24
+ #### macOS
25
+
26
+ ```bash
27
+ brew install clamav
28
+ # Start the daemon
29
+ brew services start clamav
30
+ ```
31
+
32
+ #### Ubuntu/Debian
33
+
34
+ ```bash
35
+ sudo apt-get update
36
+ sudo apt-get install clamav clamav-daemon
37
+ # Update virus definitions
38
+ sudo freshclam
39
+ # Start daemon
40
+ sudo systemctl start clamav-daemon
41
+ ```
42
+
43
+ #### Fedora/RHEL
44
+
45
+ ```bash
46
+ sudo yum install clamav clamav-update
47
+ sudo freshclam
48
+ sudo systemctl start clamd
49
+ ```
50
+
51
+ #### Docker
52
+
53
+ Add to your Dockerfile:
54
+
55
+ ```dockerfile
56
+ RUN apt-get update && apt-get install -y clamav clamav-daemon
57
+ RUN freshclam
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### Basic Usage
63
+
64
+ ```typescript
65
+ import { createScanVirusNode } from "@uploadista/flow-security-nodes";
66
+ import { ClamScanPluginLayer } from "@uploadista/flow-security-clamscan";
67
+ import { Effect } from "effect";
68
+
69
+ const program = Effect.gen(function* () {
70
+ // Create virus scan node
71
+ const scanNode = yield* createScanVirusNode("scan-1", {
72
+ action: "fail",
73
+ });
74
+
75
+ // Use the node in your flow...
76
+ }).pipe(
77
+ // Provide ClamAV plugin
78
+ Effect.provide(ClamScanPluginLayer()),
79
+ );
80
+ ```
81
+
82
+ ### Custom Configuration
83
+
84
+ ```typescript
85
+ import { ClamScanPluginLayer } from "@uploadista/flow-security-clamscan";
86
+
87
+ // Configure ClamAV plugin
88
+ const clamavLayer = ClamScanPluginLayer({
89
+ preference: "clamdscan", // Use daemon (faster)
90
+ clamdscan_socket: "/var/run/clamd.scan/clamd.sock", // Custom socket path
91
+ debug_mode: false,
92
+ });
93
+
94
+ // Or use TCP connection
95
+ const tcpLayer = ClamScanPluginLayer({
96
+ preference: "clamdscan",
97
+ clamdscan_host: "localhost",
98
+ clamdscan_port: 3310,
99
+ });
100
+ ```
101
+
102
+ ### Configuration Options
103
+
104
+ ```typescript
105
+ interface ClamScanConfig {
106
+ // Scanning method preference
107
+ preference?: "clamdscan" | "clamscan"; // Default: "clamdscan"
108
+
109
+ // Daemon socket path (Unix)
110
+ clamdscan_socket?: string; // Default: system default
111
+
112
+ // TCP connection (alternative to socket)
113
+ clamdscan_host?: string;
114
+ clamdscan_port?: number; // Default: 3310
115
+
116
+ // Whether to remove infected files (not recommended in flows)
117
+ remove_infected?: boolean; // Default: false
118
+
119
+ // Debug mode
120
+ debug_mode?: boolean; // Default: false
121
+ }
122
+ ```
123
+
124
+ ## How It Works
125
+
126
+ 1. **Initialization**: Plugin initializes ClamAV connection (daemon or binary) on first use
127
+ 2. **Temp File**: Input bytes are written to a temporary file
128
+ 3. **Scanning**: ClamAV scans the temporary file for viruses
129
+ 4. **Cleanup**: Temporary file is deleted after scanning (success or failure)
130
+ 5. **Result**: Returns scan results with detected virus names (if any)
131
+
132
+ ### Performance Characteristics
133
+
134
+ - **Daemon mode (clamdscan)**: ~100-500ms for small files (<1MB)
135
+ - **Binary mode (clamscan)**: ~1-3s per scan (slower, requires process startup)
136
+ - **Large files**: Time increases linearly with file size
137
+ - **Memory**: Efficient stream-based scanning, low memory footprint
138
+
139
+ ## Virus Definitions
140
+
141
+ ClamAV uses virus definition databases that must be kept up-to-date.
142
+
143
+ ### Update Virus Definitions
144
+
145
+ ```bash
146
+ # Update manually
147
+ sudo freshclam
148
+
149
+ # Set up automatic updates (cron)
150
+ # Add to crontab:
151
+ 0 */6 * * * /usr/bin/freshclam --quiet
152
+ ```
153
+
154
+ ### Docker
155
+
156
+ In your Dockerfile, update definitions at build time:
157
+
158
+ ```dockerfile
159
+ RUN freshclam
160
+ ```
161
+
162
+ For production, set up a cron job or scheduled task to run `freshclam` regularly.
163
+
164
+ ## Testing
165
+
166
+ You can test virus detection using the EICAR test file - a harmless file that all antivirus software detects as malware:
167
+
168
+ ```typescript
169
+ // EICAR test string (safe, not actual malware)
170
+ const eicarTest = new TextEncoder().encode(
171
+ 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
172
+ );
173
+
174
+ const result = yield* virusScanPlugin.scan(eicarTest);
175
+ // result.isClean === false
176
+ // result.detectedViruses === ["Eicar-Test-Signature"]
177
+ ```
178
+
179
+ ## Troubleshooting
180
+
181
+ ### "ClamAV is not installed or not available"
182
+
183
+ - Verify ClamAV is installed: `clamscan --version`
184
+ - Check daemon is running: `systemctl status clamav-daemon` (Linux)
185
+ - Try using binary mode: `preference: "clamscan"`
186
+
187
+ ### "Connection refused" / Daemon not responding
188
+
189
+ - Ensure daemon is running: `sudo systemctl start clamav-daemon`
190
+ - Check socket path matches your system's configuration
191
+ - Try TCP connection instead of socket
192
+
193
+ ### Scan timeout
194
+
195
+ - Increase timeout in scan virus node parameters
196
+ - Consider using daemon mode (faster than binary)
197
+ - Check system resources (CPU, memory)
198
+
199
+ ### Outdated virus definitions
200
+
201
+ ```bash
202
+ # Check definitions age
203
+ freshclam --version
204
+
205
+ # Update definitions
206
+ sudo freshclam
207
+ ```
208
+
209
+ ## Examples
210
+
211
+ ### Basic File Scan
212
+
213
+ ```typescript
214
+ import { Effect } from "effect";
215
+ import { ClamScanPluginLayer } from "@uploadista/flow-security-clamscan";
216
+ import { VirusScanPlugin } from "@uploadista/core/flow";
217
+
218
+ const scanFile = (fileBytes: Uint8Array) =>
219
+ Effect.gen(function* () {
220
+ const scanner = yield* VirusScanPlugin;
221
+
222
+ const result = yield* scanner.scan(fileBytes);
223
+
224
+ if (!result.isClean) {
225
+ console.log("⚠️ Viruses detected:", result.detectedViruses);
226
+ } else {
227
+ console.log("✅ File is clean");
228
+ }
229
+
230
+ return result;
231
+ }).pipe(Effect.provide(ClamScanPluginLayer()));
232
+ ```
233
+
234
+ ### Secure Upload Flow
235
+
236
+ ```typescript
237
+ import { createFlow } from "@uploadista/core/flow";
238
+ import { createScanVirusNode } from "@uploadista/flow-security-nodes";
239
+ import { ClamScanPluginLayer } from "@uploadista/flow-security-clamscan";
240
+
241
+ const secureFlow = createFlow({
242
+ nodes: [
243
+ createInputNode("input"),
244
+ createScanVirusNode("virus-scan", { action: "fail" }),
245
+ createStorageNode("storage", { storageId: "uploads" }),
246
+ ],
247
+ edges: [
248
+ { source: "input", target: "virus-scan" },
249
+ { source: "virus-scan", target: "storage" },
250
+ ],
251
+ }).pipe(Effect.provide(ClamScanPluginLayer()));
252
+ ```
253
+
254
+ ## License
255
+
256
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require(`node:crypto`),l=require(`node:fs/promises`);l=s(l);let u=require(`node:os`);u=s(u);let d=require(`node:path`);d=s(d);let f=require(`@uploadista/core/errors`),p=require(`@uploadista/core/flow`),m=require(`clamscan`);m=s(m);let h=require(`effect`);var g=class{clamscan=null;constructor(e={}){this.config=e}async initScanner(){if(this.clamscan)return this.clamscan;try{let e=await new m.default().init({preference:this.config.preference??`clamdscan`,remove_infected:this.config.remove_infected??!1,debug_mode:this.config.debug_mode??!1,clamdscan:{socket:this.config.clamdscan_socket,host:this.config.clamdscan_host,port:this.config.clamdscan_port??3310,timeout:6e4,local_fallback:!0},clamscan:{path:`/usr/bin/clamscan`,scan_archives:!0,active:!0}});return this.clamscan=e,e}catch(e){throw Error(`ClamAV initialization failed: ${e instanceof Error?e.message:String(e)}`)}}scan(e){return h.Effect.gen(function*(){let t=yield*h.Effect.tryPromise({try:()=>this.initScanner(),catch:e=>f.UploadistaError.fromCode(`CLAMAV_NOT_INSTALLED`,{body:e instanceof Error?e.message:`ClamAV is not installed or not available`,details:{error:e}})}),n=u.tmpdir(),r=`uploadista-scan-${(0,c.randomUUID)()}`,i=d.join(n,r);return yield*h.Effect.tryPromise({try:()=>l.writeFile(i,e),catch:e=>f.UploadistaError.fromCode(`VIRUS_SCAN_FAILED`,{body:`Failed to create temporary file for scanning`,details:{error:e}})}),yield*h.Effect.tryPromise({try:()=>t.isInfected(i),catch:e=>f.UploadistaError.fromCode(`VIRUS_SCAN_FAILED`,{body:`Virus scan failed: ${e instanceof Error?e.message:String(e)}`,details:{error:e}})}).pipe(h.Effect.map(e=>({isClean:!e.isInfected,detectedViruses:e.viruses||[]})),h.Effect.ensuring(h.Effect.tryPromise({try:()=>l.unlink(i),catch:()=>void 0}).pipe(h.Effect.ignore)))}.bind(this))}getVersion(){return h.Effect.gen(function*(){let e=yield*h.Effect.tryPromise({try:()=>this.initScanner(),catch:e=>f.UploadistaError.fromCode(`CLAMAV_NOT_INSTALLED`,{body:e instanceof Error?e.message:`ClamAV is not installed or not available`,details:{error:e}})});return(yield*h.Effect.tryPromise({try:()=>e.getVersion(),catch:e=>f.UploadistaError.fromCode(`VIRUS_SCAN_FAILED`,{body:`Failed to get ClamAV version`,details:{error:e}})})).version||`Unknown`}.bind(this))}};function _(e={}){return h.Layer.succeed(p.VirusScanPlugin,new g(e))}exports.ClamScanPluginLayer=_;
@@ -0,0 +1,62 @@
1
+ import { ScanMetadata, ScanResult, VirusScanPlugin } from "@uploadista/core/flow";
2
+ import { Layer } from "effect";
3
+
4
+ //#region src/clamscan-plugin.d.ts
5
+
6
+ /**
7
+ * Configuration options for the ClamAV plugin
8
+ */
9
+ interface ClamScanConfig {
10
+ /**
11
+ * Preference for scanning method
12
+ * - "clamdscan": Use clamd daemon (faster, recommended)
13
+ * - "clamscan": Use command-line binary
14
+ */
15
+ preference?: "clamdscan" | "clamscan";
16
+ /**
17
+ * Path to clamd socket (for daemon mode)
18
+ * Default: /var/run/clamd.scan/clamd.sock
19
+ */
20
+ clamdscan_socket?: string;
21
+ /**
22
+ * TCP host for clamd (alternative to socket)
23
+ */
24
+ clamdscan_host?: string;
25
+ /**
26
+ * TCP port for clamd
27
+ * Default: 3310
28
+ */
29
+ clamdscan_port?: number;
30
+ /**
31
+ * Whether to remove infected files automatically
32
+ * Default: false (not recommended in flow context)
33
+ */
34
+ remove_infected?: boolean;
35
+ /**
36
+ * Debug mode for clamscan library
37
+ * Default: false
38
+ */
39
+ debug_mode?: boolean;
40
+ }
41
+ /**
42
+ * Creates a VirusScanPlugin layer using ClamAV
43
+ *
44
+ * @param config - Optional ClamAV configuration
45
+ * @returns Layer providing VirusScanPlugin
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * // Use with default configuration
50
+ * const layer = ClamScanPluginLayer();
51
+ *
52
+ * // Use with custom configuration
53
+ * const customLayer = ClamScanPluginLayer({
54
+ * preference: "clamdscan",
55
+ * clamdscan_socket: "/var/run/clamav/clamd.ctl"
56
+ * });
57
+ * ```
58
+ */
59
+ declare function ClamScanPluginLayer(config?: ClamScanConfig): Layer.Layer<VirusScanPlugin, never, never>;
60
+ //#endregion
61
+ export { type ClamScanConfig, ClamScanPluginLayer, type ScanMetadata, type ScanResult };
62
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/clamscan-plugin.ts"],"sourcesContent":[],"mappings":";;;;;;;AAaA;AA2NgB,UA3NC,cAAA,CA2NkB;EACzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBADM,mBAAA,UACN,iBACP,KAAA,CAAM,MAAM"}
@@ -0,0 +1,62 @@
1
+ import { ScanMetadata, ScanResult, VirusScanPlugin } from "@uploadista/core/flow";
2
+ import { Layer } from "effect";
3
+
4
+ //#region src/clamscan-plugin.d.ts
5
+
6
+ /**
7
+ * Configuration options for the ClamAV plugin
8
+ */
9
+ interface ClamScanConfig {
10
+ /**
11
+ * Preference for scanning method
12
+ * - "clamdscan": Use clamd daemon (faster, recommended)
13
+ * - "clamscan": Use command-line binary
14
+ */
15
+ preference?: "clamdscan" | "clamscan";
16
+ /**
17
+ * Path to clamd socket (for daemon mode)
18
+ * Default: /var/run/clamd.scan/clamd.sock
19
+ */
20
+ clamdscan_socket?: string;
21
+ /**
22
+ * TCP host for clamd (alternative to socket)
23
+ */
24
+ clamdscan_host?: string;
25
+ /**
26
+ * TCP port for clamd
27
+ * Default: 3310
28
+ */
29
+ clamdscan_port?: number;
30
+ /**
31
+ * Whether to remove infected files automatically
32
+ * Default: false (not recommended in flow context)
33
+ */
34
+ remove_infected?: boolean;
35
+ /**
36
+ * Debug mode for clamscan library
37
+ * Default: false
38
+ */
39
+ debug_mode?: boolean;
40
+ }
41
+ /**
42
+ * Creates a VirusScanPlugin layer using ClamAV
43
+ *
44
+ * @param config - Optional ClamAV configuration
45
+ * @returns Layer providing VirusScanPlugin
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * // Use with default configuration
50
+ * const layer = ClamScanPluginLayer();
51
+ *
52
+ * // Use with custom configuration
53
+ * const customLayer = ClamScanPluginLayer({
54
+ * preference: "clamdscan",
55
+ * clamdscan_socket: "/var/run/clamav/clamd.ctl"
56
+ * });
57
+ * ```
58
+ */
59
+ declare function ClamScanPluginLayer(config?: ClamScanConfig): Layer.Layer<VirusScanPlugin, never, never>;
60
+ //#endregion
61
+ export { type ClamScanConfig, ClamScanPluginLayer, type ScanMetadata, type ScanResult };
62
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/clamscan-plugin.ts"],"sourcesContent":[],"mappings":";;;;;;;AAaA;AA2NgB,UA3NC,cAAA,CA2NkB;EACzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBADM,mBAAA,UACN,iBACP,KAAA,CAAM,MAAM"}
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{randomUUID as e}from"node:crypto";import*as t from"node:fs/promises";import*as n from"node:os";import*as r from"node:path";import{UploadistaError as i}from"@uploadista/core/errors";import{VirusScanPlugin as a}from"@uploadista/core/flow";import o from"clamscan";import{Effect as s,Layer as c}from"effect";var l=class{clamscan=null;constructor(e={}){this.config=e}async initScanner(){if(this.clamscan)return this.clamscan;try{let e=await new o().init({preference:this.config.preference??`clamdscan`,remove_infected:this.config.remove_infected??!1,debug_mode:this.config.debug_mode??!1,clamdscan:{socket:this.config.clamdscan_socket,host:this.config.clamdscan_host,port:this.config.clamdscan_port??3310,timeout:6e4,local_fallback:!0},clamscan:{path:`/usr/bin/clamscan`,scan_archives:!0,active:!0}});return this.clamscan=e,e}catch(e){throw Error(`ClamAV initialization failed: ${e instanceof Error?e.message:String(e)}`)}}scan(a){return s.gen(function*(){let o=yield*s.tryPromise({try:()=>this.initScanner(),catch:e=>i.fromCode(`CLAMAV_NOT_INSTALLED`,{body:e instanceof Error?e.message:`ClamAV is not installed or not available`,details:{error:e}})}),c=n.tmpdir(),l=`uploadista-scan-${e()}`,u=r.join(c,l);return yield*s.tryPromise({try:()=>t.writeFile(u,a),catch:e=>i.fromCode(`VIRUS_SCAN_FAILED`,{body:`Failed to create temporary file for scanning`,details:{error:e}})}),yield*s.tryPromise({try:()=>o.isInfected(u),catch:e=>i.fromCode(`VIRUS_SCAN_FAILED`,{body:`Virus scan failed: ${e instanceof Error?e.message:String(e)}`,details:{error:e}})}).pipe(s.map(e=>({isClean:!e.isInfected,detectedViruses:e.viruses||[]})),s.ensuring(s.tryPromise({try:()=>t.unlink(u),catch:()=>void 0}).pipe(s.ignore)))}.bind(this))}getVersion(){return s.gen(function*(){let e=yield*s.tryPromise({try:()=>this.initScanner(),catch:e=>i.fromCode(`CLAMAV_NOT_INSTALLED`,{body:e instanceof Error?e.message:`ClamAV is not installed or not available`,details:{error:e}})});return(yield*s.tryPromise({try:()=>e.getVersion(),catch:e=>i.fromCode(`VIRUS_SCAN_FAILED`,{body:`Failed to get ClamAV version`,details:{error:e}})})).version||`Unknown`}.bind(this))}};function u(e={}){return c.succeed(a,new l(e))}export{u as ClamScanPluginLayer};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["config: ClamScanConfig"],"sources":["../src/clamscan-plugin.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { UploadistaError } from \"@uploadista/core/errors\";\nimport type { ScanResult, VirusScanPluginShape } from \"@uploadista/core/flow\";\nimport { VirusScanPlugin } from \"@uploadista/core/flow\";\nimport NodeClam from \"clamscan\";\nimport { Effect, Layer } from \"effect\";\n\n/**\n * Configuration options for the ClamAV plugin\n */\nexport interface ClamScanConfig {\n /**\n * Preference for scanning method\n * - \"clamdscan\": Use clamd daemon (faster, recommended)\n * - \"clamscan\": Use command-line binary\n */\n preference?: \"clamdscan\" | \"clamscan\";\n\n /**\n * Path to clamd socket (for daemon mode)\n * Default: /var/run/clamd.scan/clamd.sock\n */\n clamdscan_socket?: string;\n\n /**\n * TCP host for clamd (alternative to socket)\n */\n clamdscan_host?: string;\n\n /**\n * TCP port for clamd\n * Default: 3310\n */\n clamdscan_port?: number;\n\n /**\n * Whether to remove infected files automatically\n * Default: false (not recommended in flow context)\n */\n remove_infected?: boolean;\n\n /**\n * Debug mode for clamscan library\n * Default: false\n */\n debug_mode?: boolean;\n}\n\n/**\n * ClamAV implementation of the VirusScanPlugin\n *\n * This plugin uses the `clamscan` npm package to scan files for viruses\n * using ClamAV antivirus engine. It supports both clamd daemon mode (fast)\n * and binary mode (slower but more portable).\n *\n * @example\n * ```typescript\n * import { ClamScanPluginLayer } from \"@uploadista/flow-security-clamscan\";\n *\n * const program = Effect.gen(function* () {\n * const virusScan = yield* VirusScanPlugin;\n * const result = yield* virusScan.scan(fileBytes);\n * console.log(result.isClean ? \"Clean\" : \"Infected\");\n * }).pipe(Effect.provide(ClamScanPluginLayer));\n * ```\n */\nclass ClamScanPluginImpl implements VirusScanPluginShape {\n private clamscan: NodeClam | null = null;\n\n constructor(private config: ClamScanConfig = {}) {}\n\n /**\n * Initialize the ClamAV scanner\n * This is called lazily on first use\n */\n private async initScanner(): Promise<NodeClam> {\n if (this.clamscan) {\n return this.clamscan;\n }\n\n try {\n // Initialize clamscan with configuration\n const scanner = await new NodeClam().init({\n preference: this.config.preference ?? \"clamdscan\",\n remove_infected: this.config.remove_infected ?? false,\n debug_mode: this.config.debug_mode ?? false,\n clamdscan: {\n socket: this.config.clamdscan_socket,\n host: this.config.clamdscan_host,\n port: this.config.clamdscan_port ?? 3310,\n timeout: 60000,\n local_fallback: true, // Fall back to binary if daemon unavailable\n },\n clamscan: {\n path: \"/usr/bin/clamscan\", // Standard path\n scan_archives: true,\n active: true,\n },\n });\n\n this.clamscan = scanner;\n return scanner;\n } catch (error) {\n // ClamAV not installed or not available\n throw new Error(\n `ClamAV initialization failed: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Scans a file for viruses using ClamAV\n *\n * @param input - File contents as Uint8Array\n * @returns Effect with scan results\n */\n scan(input: Uint8Array): Effect.Effect<ScanResult, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* Effect.tryPromise({\n try: () => this.initScanner(),\n catch: (error) =>\n UploadistaError.fromCode(\"CLAMAV_NOT_INSTALLED\", {\n body:\n error instanceof Error\n ? error.message\n : \"ClamAV is not installed or not available\",\n details: { error },\n }),\n });\n\n // Create temporary file path for scanning\n const tmpDir = os.tmpdir();\n const fileName = `uploadista-scan-${randomUUID()}`;\n const tempFilePath = path.join(tmpDir, fileName);\n\n // Write file data to temp file\n yield* Effect.tryPromise({\n try: () => fs.writeFile(tempFilePath, input),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to create temporary file for scanning\",\n details: { error },\n }),\n });\n\n // Scan the file and ensure cleanup\n const result = yield* Effect.tryPromise({\n try: () => scanner.isInfected(tempFilePath),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: `Virus scan failed: ${error instanceof Error ? error.message : String(error)}`,\n details: { error },\n }),\n }).pipe(\n Effect.map((scanResult) => ({\n isClean: !scanResult.isInfected,\n detectedViruses: scanResult.viruses || [],\n })),\n Effect.ensuring(\n // Clean up temporary file (ignore errors)\n Effect.tryPromise({\n try: () => fs.unlink(tempFilePath),\n catch: () => undefined,\n }).pipe(Effect.ignore),\n ),\n );\n\n return result;\n }.bind(this),\n );\n }\n\n /**\n * Gets the ClamAV engine version\n *\n * @returns Effect with version string\n */\n getVersion(): Effect.Effect<string, UploadistaError> {\n return Effect.gen(\n function* (this: ClamScanPluginImpl) {\n // Initialize scanner (lazy initialization)\n const scanner = yield* Effect.tryPromise({\n try: () => this.initScanner(),\n catch: (error) =>\n UploadistaError.fromCode(\"CLAMAV_NOT_INSTALLED\", {\n body:\n error instanceof Error\n ? error.message\n : \"ClamAV is not installed or not available\",\n details: { error },\n }),\n });\n\n // Get version from ClamAV\n const versionResult = yield* Effect.tryPromise({\n try: () => scanner.getVersion(),\n catch: (error) =>\n UploadistaError.fromCode(\"VIRUS_SCAN_FAILED\", {\n body: \"Failed to get ClamAV version\",\n details: { error },\n }),\n });\n\n return versionResult.version || \"Unknown\";\n }.bind(this),\n );\n }\n}\n\n/**\n * Creates a VirusScanPlugin layer using ClamAV\n *\n * @param config - Optional ClamAV configuration\n * @returns Layer providing VirusScanPlugin\n *\n * @example\n * ```typescript\n * // Use with default configuration\n * const layer = ClamScanPluginLayer();\n *\n * // Use with custom configuration\n * const customLayer = ClamScanPluginLayer({\n * preference: \"clamdscan\",\n * clamdscan_socket: \"/var/run/clamav/clamd.ctl\"\n * });\n * ```\n */\nexport function ClamScanPluginLayer(\n config: ClamScanConfig = {},\n): Layer.Layer<VirusScanPlugin, never, never> {\n return Layer.succeed(VirusScanPlugin, new ClamScanPluginImpl(config));\n}\n"],"mappings":"uTAqEA,IAAM,EAAN,KAAyD,CACvD,SAAoC,KAEpC,YAAY,EAAiC,EAAE,CAAE,CAA7B,KAAA,OAAA,EAMpB,MAAc,aAAiC,CAC7C,GAAI,KAAK,SACP,OAAO,KAAK,SAGd,GAAI,CAEF,IAAM,EAAU,MAAM,IAAI,GAAU,CAAC,KAAK,CACxC,WAAY,KAAK,OAAO,YAAc,YACtC,gBAAiB,KAAK,OAAO,iBAAmB,GAChD,WAAY,KAAK,OAAO,YAAc,GACtC,UAAW,CACT,OAAQ,KAAK,OAAO,iBACpB,KAAM,KAAK,OAAO,eAClB,KAAM,KAAK,OAAO,gBAAkB,KACpC,QAAS,IACT,eAAgB,GACjB,CACD,SAAU,CACR,KAAM,oBACN,cAAe,GACf,OAAQ,GACT,CACF,CAAC,CAGF,MADA,MAAK,SAAW,EACT,QACA,EAAO,CAEd,MAAU,MACR,iCAAiC,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GACxF,EAUL,KAAK,EAA+D,CAClE,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,EAAO,WAAW,CACvC,QAAW,KAAK,aAAa,CAC7B,MAAQ,GACN,EAAgB,SAAS,uBAAwB,CAC/C,KACE,aAAiB,MACb,EAAM,QACN,2CACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGI,EAAS,EAAG,QAAQ,CACpB,EAAW,mBAAmB,GAAY,GAC1C,EAAe,EAAK,KAAK,EAAQ,EAAS,CAkChD,OA/BA,MAAO,EAAO,WAAW,CACvB,QAAW,EAAG,UAAU,EAAc,EAAM,CAC5C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+CACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAGa,MAAO,EAAO,WAAW,CACtC,QAAW,EAAQ,WAAW,EAAa,CAC3C,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,sBAAsB,aAAiB,MAAQ,EAAM,QAAU,OAAO,EAAM,GAClF,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAAC,KACD,EAAO,IAAK,IAAgB,CAC1B,QAAS,CAAC,EAAW,WACrB,gBAAiB,EAAW,SAAW,EAAE,CAC1C,EAAE,CACH,EAAO,SAEL,EAAO,WAAW,CAChB,QAAW,EAAG,OAAO,EAAa,CAClC,UAAa,IAAA,GACd,CAAC,CAAC,KAAK,EAAO,OAAO,CACvB,CACF,EAGD,KAAK,KAAK,CACb,CAQH,YAAqD,CACnD,OAAO,EAAO,IACZ,WAAqC,CAEnC,IAAM,EAAU,MAAO,EAAO,WAAW,CACvC,QAAW,KAAK,aAAa,CAC7B,MAAQ,GACN,EAAgB,SAAS,uBAAwB,CAC/C,KACE,aAAiB,MACb,EAAM,QACN,2CACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,CAYF,OATsB,MAAO,EAAO,WAAW,CAC7C,QAAW,EAAQ,YAAY,CAC/B,MAAQ,GACN,EAAgB,SAAS,oBAAqB,CAC5C,KAAM,+BACN,QAAS,CAAE,QAAO,CACnB,CAAC,CACL,CAAC,EAEmB,SAAW,WAChC,KAAK,KAAK,CACb,GAsBL,SAAgB,EACd,EAAyB,EAAE,CACiB,CAC5C,OAAO,EAAM,QAAQ,EAAiB,IAAI,EAAmB,EAAO,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@uploadista/flow-security-clamscan",
3
+ "type": "module",
4
+ "version": "0.0.16-beta.1",
5
+ "description": "ClamAV virus scanning plugin for Uploadista Flow",
6
+ "license": "MIT",
7
+ "author": "Uploadista",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.mts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.cjs",
13
+ "default": "./dist/index.mjs"
14
+ }
15
+ },
16
+ "dependencies": {
17
+ "clamscan": "^2.3.3",
18
+ "effect": "3.19.4",
19
+ "zod": "4.1.12",
20
+ "@uploadista/core": "0.0.16-beta.1"
21
+ },
22
+ "devDependencies": {
23
+ "@effect/vitest": "0.27.0",
24
+ "@types/node": "24.10.1",
25
+ "tsdown": "0.16.5",
26
+ "vitest": "4.0.8",
27
+ "@uploadista/typescript-config": "0.0.16-beta.1"
28
+ },
29
+ "scripts": {
30
+ "build": "tsdown",
31
+ "format": "biome format --write ./src",
32
+ "lint": "biome lint --write ./src",
33
+ "check": "biome check --write ./src",
34
+ "test": "vitest",
35
+ "test:run": "vitest run",
36
+ "test:watch": "vitest watch"
37
+ }
38
+ }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { UploadistaError } from "@uploadista/core/errors";
6
+ import type { ScanResult, VirusScanPluginShape } from "@uploadista/core/flow";
7
+ import { VirusScanPlugin } from "@uploadista/core/flow";
8
+ import NodeClam from "clamscan";
9
+ import { Effect, Layer } from "effect";
10
+
11
+ /**
12
+ * Configuration options for the ClamAV plugin
13
+ */
14
+ export interface ClamScanConfig {
15
+ /**
16
+ * Preference for scanning method
17
+ * - "clamdscan": Use clamd daemon (faster, recommended)
18
+ * - "clamscan": Use command-line binary
19
+ */
20
+ preference?: "clamdscan" | "clamscan";
21
+
22
+ /**
23
+ * Path to clamd socket (for daemon mode)
24
+ * Default: /var/run/clamd.scan/clamd.sock
25
+ */
26
+ clamdscan_socket?: string;
27
+
28
+ /**
29
+ * TCP host for clamd (alternative to socket)
30
+ */
31
+ clamdscan_host?: string;
32
+
33
+ /**
34
+ * TCP port for clamd
35
+ * Default: 3310
36
+ */
37
+ clamdscan_port?: number;
38
+
39
+ /**
40
+ * Whether to remove infected files automatically
41
+ * Default: false (not recommended in flow context)
42
+ */
43
+ remove_infected?: boolean;
44
+
45
+ /**
46
+ * Debug mode for clamscan library
47
+ * Default: false
48
+ */
49
+ debug_mode?: boolean;
50
+ }
51
+
52
+ /**
53
+ * ClamAV implementation of the VirusScanPlugin
54
+ *
55
+ * This plugin uses the `clamscan` npm package to scan files for viruses
56
+ * using ClamAV antivirus engine. It supports both clamd daemon mode (fast)
57
+ * and binary mode (slower but more portable).
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * import { ClamScanPluginLayer } from "@uploadista/flow-security-clamscan";
62
+ *
63
+ * const program = Effect.gen(function* () {
64
+ * const virusScan = yield* VirusScanPlugin;
65
+ * const result = yield* virusScan.scan(fileBytes);
66
+ * console.log(result.isClean ? "Clean" : "Infected");
67
+ * }).pipe(Effect.provide(ClamScanPluginLayer));
68
+ * ```
69
+ */
70
+ class ClamScanPluginImpl implements VirusScanPluginShape {
71
+ private clamscan: NodeClam | null = null;
72
+
73
+ constructor(private config: ClamScanConfig = {}) {}
74
+
75
+ /**
76
+ * Initialize the ClamAV scanner
77
+ * This is called lazily on first use
78
+ */
79
+ private async initScanner(): Promise<NodeClam> {
80
+ if (this.clamscan) {
81
+ return this.clamscan;
82
+ }
83
+
84
+ try {
85
+ // Initialize clamscan with configuration
86
+ const scanner = await new NodeClam().init({
87
+ preference: this.config.preference ?? "clamdscan",
88
+ remove_infected: this.config.remove_infected ?? false,
89
+ debug_mode: this.config.debug_mode ?? false,
90
+ clamdscan: {
91
+ socket: this.config.clamdscan_socket,
92
+ host: this.config.clamdscan_host,
93
+ port: this.config.clamdscan_port ?? 3310,
94
+ timeout: 60000,
95
+ local_fallback: true, // Fall back to binary if daemon unavailable
96
+ },
97
+ clamscan: {
98
+ path: "/usr/bin/clamscan", // Standard path
99
+ scan_archives: true,
100
+ active: true,
101
+ },
102
+ });
103
+
104
+ this.clamscan = scanner;
105
+ return scanner;
106
+ } catch (error) {
107
+ // ClamAV not installed or not available
108
+ throw new Error(
109
+ `ClamAV initialization failed: ${error instanceof Error ? error.message : String(error)}`,
110
+ );
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Scans a file for viruses using ClamAV
116
+ *
117
+ * @param input - File contents as Uint8Array
118
+ * @returns Effect with scan results
119
+ */
120
+ scan(input: Uint8Array): Effect.Effect<ScanResult, UploadistaError> {
121
+ return Effect.gen(
122
+ function* (this: ClamScanPluginImpl) {
123
+ // Initialize scanner (lazy initialization)
124
+ const scanner = yield* Effect.tryPromise({
125
+ try: () => this.initScanner(),
126
+ catch: (error) =>
127
+ UploadistaError.fromCode("CLAMAV_NOT_INSTALLED", {
128
+ body:
129
+ error instanceof Error
130
+ ? error.message
131
+ : "ClamAV is not installed or not available",
132
+ details: { error },
133
+ }),
134
+ });
135
+
136
+ // Create temporary file path for scanning
137
+ const tmpDir = os.tmpdir();
138
+ const fileName = `uploadista-scan-${randomUUID()}`;
139
+ const tempFilePath = path.join(tmpDir, fileName);
140
+
141
+ // Write file data to temp file
142
+ yield* Effect.tryPromise({
143
+ try: () => fs.writeFile(tempFilePath, input),
144
+ catch: (error) =>
145
+ UploadistaError.fromCode("VIRUS_SCAN_FAILED", {
146
+ body: "Failed to create temporary file for scanning",
147
+ details: { error },
148
+ }),
149
+ });
150
+
151
+ // Scan the file and ensure cleanup
152
+ const result = yield* Effect.tryPromise({
153
+ try: () => scanner.isInfected(tempFilePath),
154
+ catch: (error) =>
155
+ UploadistaError.fromCode("VIRUS_SCAN_FAILED", {
156
+ body: `Virus scan failed: ${error instanceof Error ? error.message : String(error)}`,
157
+ details: { error },
158
+ }),
159
+ }).pipe(
160
+ Effect.map((scanResult) => ({
161
+ isClean: !scanResult.isInfected,
162
+ detectedViruses: scanResult.viruses || [],
163
+ })),
164
+ Effect.ensuring(
165
+ // Clean up temporary file (ignore errors)
166
+ Effect.tryPromise({
167
+ try: () => fs.unlink(tempFilePath),
168
+ catch: () => undefined,
169
+ }).pipe(Effect.ignore),
170
+ ),
171
+ );
172
+
173
+ return result;
174
+ }.bind(this),
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Gets the ClamAV engine version
180
+ *
181
+ * @returns Effect with version string
182
+ */
183
+ getVersion(): Effect.Effect<string, UploadistaError> {
184
+ return Effect.gen(
185
+ function* (this: ClamScanPluginImpl) {
186
+ // Initialize scanner (lazy initialization)
187
+ const scanner = yield* Effect.tryPromise({
188
+ try: () => this.initScanner(),
189
+ catch: (error) =>
190
+ UploadistaError.fromCode("CLAMAV_NOT_INSTALLED", {
191
+ body:
192
+ error instanceof Error
193
+ ? error.message
194
+ : "ClamAV is not installed or not available",
195
+ details: { error },
196
+ }),
197
+ });
198
+
199
+ // Get version from ClamAV
200
+ const versionResult = yield* Effect.tryPromise({
201
+ try: () => scanner.getVersion(),
202
+ catch: (error) =>
203
+ UploadistaError.fromCode("VIRUS_SCAN_FAILED", {
204
+ body: "Failed to get ClamAV version",
205
+ details: { error },
206
+ }),
207
+ });
208
+
209
+ return versionResult.version || "Unknown";
210
+ }.bind(this),
211
+ );
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Creates a VirusScanPlugin layer using ClamAV
217
+ *
218
+ * @param config - Optional ClamAV configuration
219
+ * @returns Layer providing VirusScanPlugin
220
+ *
221
+ * @example
222
+ * ```typescript
223
+ * // Use with default configuration
224
+ * const layer = ClamScanPluginLayer();
225
+ *
226
+ * // Use with custom configuration
227
+ * const customLayer = ClamScanPluginLayer({
228
+ * preference: "clamdscan",
229
+ * clamdscan_socket: "/var/run/clamav/clamd.ctl"
230
+ * });
231
+ * ```
232
+ */
233
+ export function ClamScanPluginLayer(
234
+ config: ClamScanConfig = {},
235
+ ): Layer.Layer<VirusScanPlugin, never, never> {
236
+ return Layer.succeed(VirusScanPlugin, new ClamScanPluginImpl(config));
237
+ }
@@ -0,0 +1,39 @@
1
+ // Type declarations for clamscan npm package
2
+ declare module "clamscan" {
3
+ interface ClamScanOptions {
4
+ preference?: "clamdscan" | "clamscan";
5
+ remove_infected?: boolean;
6
+ debug_mode?: boolean;
7
+ clamdscan?: {
8
+ socket?: string;
9
+ host?: string;
10
+ port?: number;
11
+ timeout?: number;
12
+ local_fallback?: boolean;
13
+ };
14
+ clamscan?: {
15
+ path?: string;
16
+ scan_archives?: boolean;
17
+ active?: boolean;
18
+ };
19
+ }
20
+
21
+ interface ScanResult {
22
+ isInfected: boolean;
23
+ file: string;
24
+ viruses: string[];
25
+ }
26
+
27
+ interface VersionResult {
28
+ version: string;
29
+ }
30
+
31
+ class NodeClam {
32
+ init(options?: ClamScanOptions): Promise<NodeClam>;
33
+ isInfected(filePath: string): Promise<ScanResult>;
34
+ getVersion(): Promise<VersionResult>;
35
+ scanStream(stream: NodeJS.ReadableStream): Promise<ScanResult>;
36
+ }
37
+
38
+ export = NodeClam;
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // ClamAV virus scanning plugin for Uploadista Flow
2
+
3
+ // Import from core packages to ensure proper type resolution
4
+ import type {} from "@uploadista/core/types";
5
+ import type {} from "@uploadista/core/upload";
6
+
7
+ // Re-export types from core for convenience
8
+ export type { ScanMetadata, ScanResult } from "@uploadista/core/flow";
9
+
10
+ // Export plugin implementation
11
+ export { type ClamScanConfig, ClamScanPluginLayer } from "./clamscan-plugin";
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "@uploadista/typescript-config/server.json",
3
+ "compilerOptions": {
4
+ "baseUrl": "./",
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ },
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
11
+ "types": ["node"]
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ index: "src/index.ts",
6
+ },
7
+ minify: true,
8
+ format: ["esm", "cjs"],
9
+ dts: true,
10
+ outDir: "dist",
11
+ });