pompelmi 0.31.0 → 0.32.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/README.md CHANGED
@@ -287,11 +287,17 @@ Get secure file scanning running in under 5 minutes with pompelmi's zero-config
287
287
 
288
288
  ### Step 1: Create Security Policy
289
289
 
290
- Create a reusable security policy and scanner configuration:
290
+ Create a reusable security policy and scanner configuration.
291
+
292
+ > **`composeScanners` API** — two supported forms:
293
+ > - **Named-scanner array** *(recommended)*: `composeScanners([["name", scanner], ...], opts?)` — supports `parallel`, `stopOn`, `timeoutMsPerScanner`, and `tagSourceName` options.
294
+ > - **Variadic** *(backward-compatible)*: `composeScanners(scannerA, scannerB, ...)` — runs scanners sequentially, no options.
291
295
 
292
296
  ```ts
293
297
  // lib/security.ts
294
298
  import { CommonHeuristicsScanner, createZipBombGuard, composeScanners } from 'pompelmi';
299
+ // Optional: import types for explicit annotation
300
+ // import type { NamedScanner, ComposeScannerOptions } from 'pompelmi';
295
301
 
296
302
  export const policy = {
297
303
  includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf', 'txt'],
package/dist/pompelmi.cjs CHANGED
@@ -28,7 +28,96 @@ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
28
28
  function toScanFn(s) {
29
29
  return (typeof s === "function" ? s : s.scan);
30
30
  }
31
- function composeScanners(...scanners) {
31
+ /** Map a Match's severity field to a Verdict for stopOn comparison. */
32
+ function matchToVerdict(m) {
33
+ const s = m.severity;
34
+ if (s === "critical" || s === "high" || s === "malicious")
35
+ return "malicious";
36
+ if (s === "medium" || s === "low" || s === "suspicious" || s === "info")
37
+ return "suspicious";
38
+ return "clean";
39
+ }
40
+ /** Highest verdict across all matches in the list. */
41
+ function highestSeverity(matches) {
42
+ if (matches.length === 0)
43
+ return null;
44
+ if (matches.some((m) => matchToVerdict(m) === "malicious"))
45
+ return "malicious";
46
+ if (matches.some((m) => matchToVerdict(m) === "suspicious"))
47
+ return "suspicious";
48
+ return "clean";
49
+ }
50
+ const SEVERITY_RANK = { malicious: 2, suspicious: 1, clean: 0 };
51
+ function shouldStop(matches, stopOn) {
52
+ if (!stopOn)
53
+ return false;
54
+ const highest = highestSeverity(matches);
55
+ if (!highest)
56
+ return false;
57
+ return SEVERITY_RANK[highest] >= SEVERITY_RANK[stopOn];
58
+ }
59
+ async function runWithTimeout(fn, timeoutMs) {
60
+ if (!timeoutMs)
61
+ return fn();
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => reject(new Error("scanner timeout")), timeoutMs);
64
+ fn().then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
65
+ });
66
+ }
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ function composeScanners(...args) {
69
+ const first = args[0];
70
+ const rest = args.slice(1);
71
+ // ── Named-scanner array form ──────────────────────────────────────────────
72
+ if (Array.isArray(first) &&
73
+ (first.length === 0 || (Array.isArray(first[0]) && typeof first[0][0] === "string"))) {
74
+ const entries = first;
75
+ const opts = rest.length > 0 && !Array.isArray(rest[0]) && typeof rest[0] !== "function" &&
76
+ !(typeof rest[0] === "object" && rest[0] !== null && "scan" in rest[0])
77
+ ? rest[0]
78
+ : {};
79
+ return async (input, ctx) => {
80
+ const all = [];
81
+ if (opts.parallel) {
82
+ // Parallel execution — collect all results then return
83
+ const results = await Promise.allSettled(entries.map(([name, scanner]) => runWithTimeout(() => toScanFn(scanner)(input, ctx), opts.timeoutMsPerScanner)));
84
+ for (let i = 0; i < results.length; i++) {
85
+ const result = results[i];
86
+ if (result.status === "fulfilled" && Array.isArray(result.value)) {
87
+ const matches = opts.tagSourceName
88
+ ? result.value.map((m) => ({
89
+ ...m,
90
+ meta: { ...m.meta, _sourceName: entries[i][0] },
91
+ }))
92
+ : result.value;
93
+ all.push(...matches);
94
+ }
95
+ }
96
+ }
97
+ else {
98
+ // Sequential execution with optional stopOn short-circuit
99
+ for (const [name, scanner] of entries) {
100
+ try {
101
+ const out = await runWithTimeout(() => toScanFn(scanner)(input, ctx), opts.timeoutMsPerScanner);
102
+ if (Array.isArray(out)) {
103
+ const matches = opts.tagSourceName
104
+ ? out.map((m) => ({ ...m, meta: { ...m.meta, _sourceName: name } }))
105
+ : out;
106
+ all.push(...matches);
107
+ if (shouldStop(all, opts.stopOn))
108
+ break;
109
+ }
110
+ }
111
+ catch {
112
+ // individual scanner failure is non-fatal
113
+ }
114
+ }
115
+ }
116
+ return all;
117
+ };
118
+ }
119
+ // ── Variadic form (backward-compatible) ───────────────────────────────────
120
+ const scanners = [first, ...rest].filter(Boolean);
32
121
  return async (input, ctx) => {
33
122
  const all = [];
34
123
  for (const s of scanners) {