@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.
- package/.turbo/turbo-build.log +22 -0
- package/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +62 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +62 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -0
- package/src/clamscan-plugin.ts +237 -0
- package/src/clamscan.d.ts +39 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +11 -0
|
@@ -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
|
+
[34mℹ[39m tsdown [2mv0.16.5[22m powered by rolldown [2mv1.0.0-beta.50[22m
|
|
7
|
+
[34mℹ[39m Using tsdown config: [4m/Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/security/clamscan/tsdown.config.ts[24m
|
|
8
|
+
[34mℹ[39m entry: [34msrc/index.ts[39m
|
|
9
|
+
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
10
|
+
[34mℹ[39m Build start
|
|
11
|
+
[34mℹ[39m Cleaning 7 files
|
|
12
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[1mindex.cjs[22m [2m2.84 kB[22m [2m│ gzip: 1.17 kB[22m
|
|
13
|
+
[34mℹ[39m [33m[CJS][39m 1 files, total: 2.84 kB
|
|
14
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[1mindex.mjs[22m [2m2.25 kB[22m [2m│ gzip: 0.92 kB[22m
|
|
15
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.mjs.map [2m9.32 kB[22m [2m│ gzip: 2.88 kB[22m
|
|
16
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.d.mts.map [2m0.25 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
17
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m1.65 kB[22m [2m│ gzip: 0.73 kB[22m
|
|
18
|
+
[34mℹ[39m [34m[ESM][39m 4 files, total: 13.46 kB
|
|
19
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22mindex.d.cts.map [2m0.25 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
20
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[32m[1mindex.d.cts[22m[39m [2m1.65 kB[22m [2m│ gzip: 0.73 kB[22m
|
|
21
|
+
[34mℹ[39m [33m[CJS][39m 2 files, total: 1.90 kB
|
|
22
|
+
[32m✔[39m Build complete in [32m2707ms[39m
|
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=_;
|
package/dist/index.d.cts
ADDED
|
@@ -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"}
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|