pompelmi 1.17.0 → 1.19.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/src/Watcher.js CHANGED
@@ -7,44 +7,79 @@ const { Verdict } = require('./verdicts.js');
7
7
 
8
8
  const DEBOUNCE_MS = 300;
9
9
 
10
+ function quarantineFile(filePath, quarantineDir, virus) {
11
+ try {
12
+ fs.mkdirSync(quarantineDir, { recursive: true });
13
+ const basename = path.basename(filePath);
14
+ const destBase = path.join(quarantineDir, basename + '.quarantined');
15
+ const destJson = destBase + '.json';
16
+ const sha256 = (() => { try { return require('crypto').createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } catch { return ''; } })();
17
+
18
+ fs.renameSync(filePath, destBase);
19
+
20
+ const sidecar = {
21
+ originalPath: filePath,
22
+ virus: virus || '',
23
+ timestamp: new Date().toISOString(),
24
+ sha256,
25
+ };
26
+ fs.writeFileSync(destJson, JSON.stringify(sidecar, null, 2));
27
+
28
+ return destBase;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
10
34
  /**
11
35
  * Watch a directory for new/modified files and scan each one automatically.
12
36
  * Uses fs.watch (no dependencies) with a 300 ms debounce.
13
37
  *
14
38
  * @param {string} dirPath
15
- * @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay)
39
+ * @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay).
40
+ * Also accepts `quarantine` (string path) to enable auto-quarantine of infected files.
16
41
  * @param {{ onClean?: Function, onMalicious?: Function, onError?: Function }} [callbacks]
17
42
  * @returns {import('fs').FSWatcher}
18
43
  */
19
44
  function watch(dirPath, options = {}, { onClean, onMalicious, onError } = {}) {
20
- const timers = new Map();
45
+ const { quarantine: quarantineDir, ...scanOptions } = options;
46
+ const timers = new Map();
47
+
48
+ if (quarantineDir) {
49
+ fs.mkdirSync(quarantineDir, { recursive: true });
50
+ }
21
51
 
22
- return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
23
- if (!filename) return;
52
+ return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
53
+ if (!filename) return;
24
54
 
25
- const fullPath = path.join(dirPath, filename);
55
+ const fullPath = path.join(dirPath, filename);
26
56
 
27
- if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
57
+ if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
28
58
 
29
- timers.set(fullPath, setTimeout(async () => {
30
- timers.delete(fullPath);
59
+ timers.set(fullPath, setTimeout(async () => {
60
+ timers.delete(fullPath);
31
61
 
32
- if (!fs.existsSync(fullPath)) return;
62
+ if (!fs.existsSync(fullPath)) return;
33
63
 
34
- let stat;
35
- try { stat = fs.statSync(fullPath); } catch { return; }
36
- if (!stat.isFile()) return;
64
+ let stat;
65
+ try { stat = fs.statSync(fullPath); } catch { return; }
66
+ if (!stat.isFile()) return;
37
67
 
38
- try {
39
- const verdict = await scan(fullPath, options);
40
- if (verdict === Verdict.Clean) onClean && onClean(fullPath);
41
- else if (verdict === Verdict.Malicious) onMalicious && onMalicious(fullPath);
42
- else onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
43
- } catch (err) {
44
- onError && onError(err, fullPath);
45
- }
46
- }, DEBOUNCE_MS));
47
- });
68
+ try {
69
+ const verdict = await scan(fullPath, scanOptions);
70
+ if (verdict === Verdict.Clean) {
71
+ onClean && onClean(fullPath);
72
+ } else if (verdict === Verdict.Malicious) {
73
+ if (quarantineDir) quarantineFile(fullPath, quarantineDir, '');
74
+ onMalicious && onMalicious(fullPath);
75
+ } else {
76
+ onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
77
+ }
78
+ } catch (err) {
79
+ onError && onError(err, fullPath);
80
+ }
81
+ }, DEBOUNCE_MS));
82
+ });
48
83
  }
49
84
 
50
- module.exports = { watch };
85
+ module.exports = { watch, quarantineFile };
package/src/index.js CHANGED
@@ -8,5 +8,9 @@ const { notify } = require('./WebhookNotifi
8
8
  const { createScanner } = require('./ScanEmitter.js');
9
9
  const { generateDashboard } = require('./Dashboard.js');
10
10
  const { generateShareCard } = require('./ShareCard.js');
11
+ const { generateScorecard } = require('./Scorecard.js');
12
+ const { createCache } = require('./ScanCache.js');
13
+ const { createPolicy } = require('./Policy.js');
14
+ const { createMultiEngine } = require('./MultiEngine.js');
11
15
 
12
- module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard };
16
+ module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard, generateScorecard, createCache, createPolicy, createMultiEngine };
package/src/index.mjs CHANGED
@@ -13,7 +13,11 @@ export const {
13
13
  createScanner,
14
14
  generateDashboard,
15
15
  generateShareCard,
16
+ generateScorecard,
16
17
  notify,
17
18
  Verdict,
19
+ createCache,
20
+ createPolicy,
21
+ createMultiEngine,
18
22
  } = pompelmi;
19
23
  export default pompelmi;
package/types/index.d.ts CHANGED
@@ -73,11 +73,14 @@ export declare function scanStream(stream: Readable, options?: ScanOptions): Pro
73
73
  /**
74
74
  * Recursively scan every file under dirPath.
75
75
  * Per-file errors are caught and collected without aborting the full scan.
76
+ * Use scanDirectory.stream() for async-iterable progress events.
76
77
  */
77
78
  export declare function scanDirectory(
78
79
  dirPath: string,
79
80
  options?: ScanOptions
80
- ): Promise<DirectoryScanResult>;
81
+ ): Promise<DirectoryScanResult> & {
82
+ stream(dirPath: string, options?: ScanOptions): AsyncIterable<DirectoryScanEvent>;
83
+ };
81
84
 
82
85
  /**
83
86
  * Express / Fastify middleware that scans multer-uploaded files
@@ -142,14 +145,22 @@ export interface WatchCallbacks {
142
145
  onError?: (err: Error, filePath?: string) => void;
143
146
  }
144
147
 
148
+ /** Options for watch() — extends ScanOptions with quarantine support */
149
+ export interface WatchOptions extends ScanOptions {
150
+ /** Path to a quarantine directory. Infected files are moved here automatically. */
151
+ quarantine?: string;
152
+ }
153
+
145
154
  /**
146
155
  * Watch a directory for new/modified files and scan each automatically.
147
156
  * Uses fs.watch with a 300 ms debounce. No dependencies.
157
+ * When `options.quarantine` is set, infected files are moved to that directory
158
+ * with a `.quarantined` extension and a sidecar JSON file.
148
159
  * Returns an FSWatcher; call .close() to stop watching.
149
160
  */
150
161
  export declare function watch(
151
162
  dirPath: string,
152
- options?: ScanOptions,
163
+ options?: WatchOptions,
153
164
  callbacks?: WatchCallbacks
154
165
  ): FSWatcher;
155
166
 
@@ -266,3 +277,244 @@ export declare function generateShareCard(
266
277
  scanResults: ScanRow[] | DirectoryScanResult,
267
278
  options?: ShareCardOptions
268
279
  ): string;
280
+
281
+ /** Input configuration for generateScorecard */
282
+ export interface ScorecardConfig {
283
+ /** Whether virus scanning is enabled */
284
+ scanEnabled?: boolean;
285
+ /** Array of allowed MIME types (non-empty = pass) */
286
+ mimeTypeAllowlist?: string[];
287
+ /** Maximum file size in bytes (positive = pass) */
288
+ fileSizeLimit?: number;
289
+ /** Whether files are written to disk before scanning (false = pass) */
290
+ diskWriteBeforeScan?: boolean;
291
+ /** What to do on scan error: 'reject' = pass, anything else = fail */
292
+ scanErrorBehavior?: 'reject' | string;
293
+ /** What to do when clamd is unavailable: 'reject' = pass, anything else = fail */
294
+ clamdUnavailableBehavior?: 'reject' | string;
295
+ /** Whether TLS is enabled on the upload endpoint */
296
+ tlsEnabled?: boolean;
297
+ }
298
+
299
+ /** A single check result in a scorecard */
300
+ export interface ScorecardFinding {
301
+ check: string;
302
+ status: 'pass' | 'fail';
303
+ weight: number;
304
+ }
305
+
306
+ /** Result returned by generateScorecard */
307
+ export interface ScorecardResult {
308
+ /** Letter grade A–F */
309
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
310
+ /** Numeric score 0–100 */
311
+ score: number;
312
+ /** Per-check results */
313
+ findings: ScorecardFinding[];
314
+ /** Actionable recommendations for failed checks */
315
+ recommendations: string[];
316
+ }
317
+
318
+ /**
319
+ * Analyse a project's upload security configuration and return a grade A–F.
320
+ *
321
+ * @example
322
+ * const scorecard = await generateScorecard({
323
+ * scanEnabled: true,
324
+ * mimeTypeAllowlist: ['image/jpeg', 'image/png'],
325
+ * fileSizeLimit: 10 * 1024 * 1024,
326
+ * diskWriteBeforeScan: false,
327
+ * scanErrorBehavior: 'reject',
328
+ * clamdUnavailableBehavior: 'reject',
329
+ * tlsEnabled: true,
330
+ * });
331
+ * console.log(scorecard.grade); // 'A'
332
+ */
333
+ export declare function generateScorecard(config?: ScorecardConfig): Promise<ScorecardResult>;
334
+
335
+ // ─── ScanCache ────────────────────────────────────────────────────────────────
336
+
337
+ /** Options for createCache() */
338
+ export interface CacheOptions {
339
+ /** Entry TTL in milliseconds (default: 3600000 = 1 hour) */
340
+ ttl?: number;
341
+ /** Maximum number of entries before LRU eviction (default: 1000) */
342
+ maxSize?: number;
343
+ /** Storage backend: 'memory' or 'file' (default: 'memory') */
344
+ storage?: 'memory' | 'file';
345
+ /** Path for file-backed storage (default: './.pompelmi-cache.json') */
346
+ filePath?: string;
347
+ }
348
+
349
+ /** Cache hit/miss statistics */
350
+ export interface CacheStats {
351
+ hits: number;
352
+ misses: number;
353
+ size: number;
354
+ hitRate: number;
355
+ }
356
+
357
+ /** SHA256-based scan result cache */
358
+ export interface ScanCache {
359
+ /** Scan a file by path, returning a cached result if available */
360
+ scan(filePath: string, options?: ScanOptions): Promise<VerdictValue>;
361
+ /** Scan an in-memory Buffer, returning a cached result if available */
362
+ scanBuffer(buffer: Buffer, options?: ScanOptions): Promise<VerdictValue>;
363
+ /** Return cache statistics */
364
+ stats(): CacheStats;
365
+ /** Clear all cache entries */
366
+ clear(): void;
367
+ /** Remove a specific entry by its SHA256 hash */
368
+ delete(sha256: string): void;
369
+ }
370
+
371
+ /**
372
+ * Create a SHA256-based scan result cache.
373
+ * Skips rescanning files with known-clean or known-malicious hashes.
374
+ * Supports LRU eviction and optional file-backed persistence.
375
+ */
376
+ export declare function createCache(options?: CacheOptions): ScanCache;
377
+
378
+ // ─── Policy ───────────────────────────────────────────────────────────────────
379
+
380
+ /** Options for createPolicy() */
381
+ export interface PolicyRules {
382
+ /** ClamAV connection options for virus scanning */
383
+ scan?: ScanOptions;
384
+ /** Maximum allowed file size in bytes */
385
+ maxSize?: number;
386
+ /** Allowlist of MIME types (e.g. ['image/jpeg', 'application/pdf']) */
387
+ allowedMimeTypes?: string[];
388
+ /** Allowlist of file extensions (e.g. ['.jpg', '.pdf']) */
389
+ allowedExtensions?: string[];
390
+ /** Reject encrypted archives (default: false) */
391
+ rejectEncrypted?: boolean;
392
+ /** Behaviour when clamd is unavailable: 'reject' | 'allow' | 'throw' (default: 'reject') */
393
+ onScannerUnavailable?: 'reject' | 'allow' | 'throw';
394
+ }
395
+
396
+ /** Metadata passed to policy.check() */
397
+ export interface FileMeta {
398
+ filename?: string | null;
399
+ mimeType?: string | null;
400
+ size?: number;
401
+ }
402
+
403
+ /** Details about the file that was checked */
404
+ export interface PolicyDetails {
405
+ size: number;
406
+ mimeType: string | null;
407
+ extension: string | null;
408
+ virusName: string | null;
409
+ }
410
+
411
+ /** Result returned by policy.check() */
412
+ export interface PolicyResult {
413
+ allowed: boolean;
414
+ reason:
415
+ | null
416
+ | 'file_too_large'
417
+ | 'mime_not_allowed'
418
+ | 'extension_not_allowed'
419
+ | 'encrypted_archive'
420
+ | 'virus_detected'
421
+ | 'scanner_unavailable';
422
+ verdict: VerdictValue;
423
+ details: PolicyDetails;
424
+ }
425
+
426
+ /** A compiled scan policy */
427
+ export interface ScanPolicy {
428
+ /** Apply all policy rules to a buffer */
429
+ check(buffer: Buffer, meta?: FileMeta): Promise<PolicyResult>;
430
+ /** Express/Fastify middleware — rejects with HTTP 403 on policy violation */
431
+ middleware(): RequestHandler;
432
+ /** NestJS CanActivate guard */
433
+ nestGuard(): { canActivate(context: unknown): Promise<boolean> };
434
+ }
435
+
436
+ /**
437
+ * Create a unified scan policy combining size, MIME, extension, and virus checks.
438
+ */
439
+ export declare function createPolicy(rules?: PolicyRules): ScanPolicy;
440
+
441
+ // ─── MultiEngine ─────────────────────────────────────────────────────────────
442
+
443
+ /** ClamAV engine configuration */
444
+ export interface ClamAVEngineConfig {
445
+ type: 'clamav';
446
+ host?: string;
447
+ port?: number;
448
+ socket?: string;
449
+ timeout?: number;
450
+ }
451
+
452
+ /** VirusTotal engine configuration */
453
+ export interface VirusTotalEngineConfig {
454
+ type: 'virustotal';
455
+ apiKey: string;
456
+ /** Minimum number of engine detections to flag as malicious (default: 1) */
457
+ threshold?: number;
458
+ }
459
+
460
+ export type EngineConfig = ClamAVEngineConfig | VirusTotalEngineConfig;
461
+
462
+ /** Per-engine result */
463
+ export interface EngineResult {
464
+ name: string;
465
+ verdict: VerdictValue;
466
+ detections?: number;
467
+ virus?: string;
468
+ error?: string;
469
+ }
470
+
471
+ /** Result returned by multi-engine scan */
472
+ export interface MultiEngineResult {
473
+ verdict: VerdictValue;
474
+ consensus: 'any' | 'all' | 'majority';
475
+ engines: EngineResult[];
476
+ }
477
+
478
+ /** Options for createMultiEngine() */
479
+ export interface MultiEngineOptions {
480
+ engines: EngineConfig[];
481
+ /** How to combine engine verdicts (default: 'any') */
482
+ consensus?: 'any' | 'all' | 'majority';
483
+ }
484
+
485
+ /** Multi-engine scanner */
486
+ export interface MultiEngine {
487
+ scan(filePath: string): Promise<MultiEngineResult>;
488
+ scanBuffer(buffer: Buffer): Promise<MultiEngineResult>;
489
+ }
490
+
491
+ /**
492
+ * Create a multi-engine scanner combining ClamAV and/or VirusTotal.
493
+ * The consensus mode controls how engine verdicts are combined.
494
+ */
495
+ export declare function createMultiEngine(options?: MultiEngineOptions): MultiEngine;
496
+
497
+ // ─── scanDirectory.stream ─────────────────────────────────────────────────────
498
+
499
+ /** Progress event emitted by scanDirectory.stream() */
500
+ export interface ScanProgressEvent {
501
+ type: 'progress';
502
+ scanned: number;
503
+ total: number;
504
+ file: string;
505
+ }
506
+
507
+ /** Per-file result event emitted by scanDirectory.stream() */
508
+ export interface ScanResultEvent {
509
+ type: 'result';
510
+ file: string;
511
+ verdict: VerdictValue | null;
512
+ }
513
+
514
+ /** Final summary event emitted by scanDirectory.stream() */
515
+ export interface ScanCompleteEvent {
516
+ type: 'complete';
517
+ summary: DirectoryScanResult;
518
+ }
519
+
520
+ export type DirectoryScanEvent = ScanProgressEvent | ScanResultEvent | ScanCompleteEvent;