pompelmi 0.15.0-dev.34 → 0.15.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 +13 -1
- package/dist/pompelmi.esm.js +132 -131
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
<p align="center">
|
|
3
|
+
|
|
4
|
+
<br/>
|
|
3
5
|
<a href="https://www.producthunt.com/products/pompelmi?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-pompelmi" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1010722&theme=light&t=1756653468504" alt="pompelmi - free, open-source file scanner | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
|
4
6
|
<br/>
|
|
5
7
|
<a href="https://github.com/pompelmi/pompelmi" target="_blank" rel="noopener noreferrer">
|
|
@@ -13,7 +15,17 @@
|
|
|
13
15
|
|
|
14
16
|
<h1 align="center">pompelmi</h1>
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<img src="assets/video.gif" alt="pompelmi demo" width="920" />
|
|
22
|
+
<br/>
|
|
23
|
+
<br/>
|
|
24
|
+
<strong>Fast file‑upload malware scanning for Node.js</strong> — optional <strong>YARA</strong> integration, ZIP deep‑inspection, and drop‑in adapters for <em>Express</em>, <em>Koa</em>, and <em>Next.js</em>. Private by design. Typed. Tiny.
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
assets/video.mp4
|
|
28
|
+
|
|
17
29
|
|
|
18
30
|
<p align="center">
|
|
19
31
|
<a href="https://www.npmjs.com/package/pompelmi"><img alt="npm version" src="https://img.shields.io/npm/v/pompelmi?label=pompelmi&color=0a7ea4"></a>
|
package/dist/pompelmi.esm.js
CHANGED
|
@@ -1,137 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
maxEntries: 1000,
|
|
4
|
-
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
5
|
-
maxEntryNameLength: 255,
|
|
6
|
-
maxCompressionRatio: 1000,
|
|
7
|
-
eocdSearchWindow: 70000,
|
|
8
|
-
};
|
|
9
|
-
function r16(buf, off) {
|
|
10
|
-
return buf.readUInt16LE(off);
|
|
11
|
-
}
|
|
12
|
-
function r32(buf, off) {
|
|
13
|
-
return buf.readUInt32LE(off);
|
|
14
|
-
}
|
|
15
|
-
function isZipLike$1(buf) {
|
|
16
|
-
// local file header at start is common
|
|
17
|
-
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
18
|
-
}
|
|
19
|
-
function lastIndexOfEOCD(buf, window) {
|
|
20
|
-
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
21
|
-
const start = Math.max(0, buf.length - window);
|
|
22
|
-
const idx = buf.lastIndexOf(sig, Math.min(buf.length - sig.length, buf.length - 1));
|
|
23
|
-
return idx >= start ? idx : -1;
|
|
1
|
+
function toScanFn(s) {
|
|
2
|
+
return (typeof s === "function" ? s : s.scan);
|
|
24
3
|
}
|
|
25
|
-
function
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const matches = [];
|
|
34
|
-
if (!isZipLike$1(buf))
|
|
35
|
-
return matches;
|
|
36
|
-
// Find EOCD near the end
|
|
37
|
-
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
38
|
-
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
39
|
-
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
40
|
-
matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
|
|
41
|
-
return matches;
|
|
42
|
-
}
|
|
43
|
-
const totalEntries = r16(buf, eocdPos + 10);
|
|
44
|
-
const cdSize = r32(buf, eocdPos + 12);
|
|
45
|
-
const cdOffset = r32(buf, eocdPos + 16);
|
|
46
|
-
// Bounds check
|
|
47
|
-
if (cdOffset + cdSize > buf.length) {
|
|
48
|
-
matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
|
|
49
|
-
return matches;
|
|
50
|
-
}
|
|
51
|
-
// Iterate central directory entries
|
|
52
|
-
let ptr = cdOffset;
|
|
53
|
-
let seen = 0;
|
|
54
|
-
let sumComp = 0;
|
|
55
|
-
let sumUnc = 0;
|
|
56
|
-
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
57
|
-
const sig = r32(buf, ptr);
|
|
58
|
-
if (sig !== SIG_CEN)
|
|
59
|
-
break; // stop if structure breaks
|
|
60
|
-
const compSize = r32(buf, ptr + 20);
|
|
61
|
-
const uncSize = r32(buf, ptr + 24);
|
|
62
|
-
const fnLen = r16(buf, ptr + 28);
|
|
63
|
-
const exLen = r16(buf, ptr + 30);
|
|
64
|
-
const cmLen = r16(buf, ptr + 32);
|
|
65
|
-
const nameStart = ptr + 46;
|
|
66
|
-
const nameEnd = nameStart + fnLen;
|
|
67
|
-
if (nameEnd > buf.length)
|
|
68
|
-
break;
|
|
69
|
-
const name = buf.toString('utf8', nameStart, nameEnd);
|
|
70
|
-
sumComp += compSize;
|
|
71
|
-
sumUnc += uncSize;
|
|
72
|
-
seen++;
|
|
73
|
-
if (name.length > cfg.maxEntryNameLength) {
|
|
74
|
-
matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
|
|
75
|
-
}
|
|
76
|
-
if (hasTraversal(name)) {
|
|
77
|
-
matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
|
|
78
|
-
}
|
|
79
|
-
// move to next entry
|
|
80
|
-
ptr = nameEnd + exLen + cmLen;
|
|
81
|
-
}
|
|
82
|
-
if (seen !== totalEntries) {
|
|
83
|
-
// central dir truncated/odd, still report what we found
|
|
84
|
-
matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
|
|
85
|
-
}
|
|
86
|
-
// Heuristics thresholds
|
|
87
|
-
if (seen > cfg.maxEntries) {
|
|
88
|
-
matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
|
|
89
|
-
}
|
|
90
|
-
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
91
|
-
matches.push({
|
|
92
|
-
rule: 'zip_total_uncompressed_too_large',
|
|
93
|
-
severity: 'medium',
|
|
94
|
-
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
95
|
-
});
|
|
4
|
+
function composeScanners(...scanners) {
|
|
5
|
+
return async (input, ctx) => {
|
|
6
|
+
const all = [];
|
|
7
|
+
for (const s of scanners) {
|
|
8
|
+
try {
|
|
9
|
+
const out = await toScanFn(s)(input, ctx);
|
|
10
|
+
if (Array.isArray(out))
|
|
11
|
+
all.push(...out);
|
|
96
12
|
}
|
|
97
|
-
|
|
98
|
-
|
|
13
|
+
catch {
|
|
14
|
+
// ignore individual scanner failures
|
|
99
15
|
}
|
|
100
|
-
else if (sumComp > 0) {
|
|
101
|
-
const ratio = sumUnc / Math.max(1, sumComp);
|
|
102
|
-
if (ratio >= cfg.maxCompressionRatio) {
|
|
103
|
-
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return matches;
|
|
107
16
|
}
|
|
17
|
+
return all;
|
|
108
18
|
};
|
|
109
19
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
/** Composizione sequenziale: concatena tutti i match degli scanner */
|
|
116
|
-
function composeScanners(scanners) {
|
|
117
|
-
return async (input, ctx) => {
|
|
118
|
-
const out = [];
|
|
119
|
-
for (const s of scanners) {
|
|
120
|
-
const res = await Promise.resolve(asScanFn(s)(input, ctx));
|
|
121
|
-
if (Array.isArray(res) && res.length)
|
|
122
|
-
out.push(...res);
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
20
|
+
function createPresetScanner(_preset, _opts = {}) {
|
|
21
|
+
// TODO: wire to real preset registry
|
|
22
|
+
return async (_input, _ctx) => {
|
|
23
|
+
return [];
|
|
125
24
|
};
|
|
126
25
|
}
|
|
127
|
-
/** Ritorna uno ScanFn pronto all'uso, oggi con zip-bomb guard */
|
|
128
|
-
function createPresetScanner(_name = 'zip-basic', _opts = {}) {
|
|
129
|
-
// Al momento un solo preset "zip-basic"
|
|
130
|
-
const scanners = [
|
|
131
|
-
createZipBombGuard(), // usa i default interni
|
|
132
|
-
];
|
|
133
|
-
return composeScanners(scanners);
|
|
134
|
-
}
|
|
135
26
|
|
|
136
27
|
/** Mappa veloce estensione -> mime (basic) */
|
|
137
28
|
function guessMimeByExt(name) {
|
|
@@ -168,14 +59,14 @@ function toYaraMatches(ms) {
|
|
|
168
59
|
/** Scan di bytes (browser/node) usando preset (default: zip-basic) */
|
|
169
60
|
async function scanBytes(input, opts = {}) {
|
|
170
61
|
const t0 = Date.now();
|
|
171
|
-
|
|
62
|
+
opts.preset ?? 'zip-basic';
|
|
172
63
|
const ctx = {
|
|
173
64
|
...opts.ctx,
|
|
174
65
|
mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
|
|
175
66
|
size: opts.ctx?.size ?? input.byteLength,
|
|
176
67
|
};
|
|
177
|
-
const scanFn = createPresetScanner(
|
|
178
|
-
const matchesH = await scanFn(input, ctx);
|
|
68
|
+
const scanFn = createPresetScanner();
|
|
69
|
+
const matchesH = await (typeof scanFn === "function" ? scanFn : scanFn.scan)(input, ctx);
|
|
179
70
|
const matches = toYaraMatches(matchesH);
|
|
180
71
|
const verdict = computeVerdict(matches);
|
|
181
72
|
const durationMs = Date.now() - t0;
|
|
@@ -3188,7 +3079,7 @@ function isOleCfb(buf) {
|
|
|
3188
3079
|
const sig = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
|
3189
3080
|
return startsWith(buf, sig);
|
|
3190
3081
|
}
|
|
3191
|
-
function isZipLike(buf) {
|
|
3082
|
+
function isZipLike$1(buf) {
|
|
3192
3083
|
// PK\x03\x04
|
|
3193
3084
|
return startsWith(buf, [0x50, 0x4b, 0x03, 0x04]);
|
|
3194
3085
|
}
|
|
@@ -3198,7 +3089,7 @@ function isPeExecutable(buf) {
|
|
|
3198
3089
|
}
|
|
3199
3090
|
/** OOXML macro hint via filename token in ZIP container */
|
|
3200
3091
|
function hasOoxmlMacros(buf) {
|
|
3201
|
-
if (!isZipLike(buf))
|
|
3092
|
+
if (!isZipLike$1(buf))
|
|
3202
3093
|
return false;
|
|
3203
3094
|
return hasAsciiToken(buf, 'vbaProject.bin');
|
|
3204
3095
|
}
|
|
@@ -3237,6 +3128,116 @@ const CommonHeuristicsScanner = {
|
|
|
3237
3128
|
}
|
|
3238
3129
|
};
|
|
3239
3130
|
|
|
3131
|
+
const SIG_CEN = 0x02014b50;
|
|
3132
|
+
const DEFAULTS = {
|
|
3133
|
+
maxEntries: 1000,
|
|
3134
|
+
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
3135
|
+
maxEntryNameLength: 255,
|
|
3136
|
+
maxCompressionRatio: 1000,
|
|
3137
|
+
eocdSearchWindow: 70000,
|
|
3138
|
+
};
|
|
3139
|
+
function r16(buf, off) {
|
|
3140
|
+
return buf.readUInt16LE(off);
|
|
3141
|
+
}
|
|
3142
|
+
function r32(buf, off) {
|
|
3143
|
+
return buf.readUInt32LE(off);
|
|
3144
|
+
}
|
|
3145
|
+
function isZipLike(buf) {
|
|
3146
|
+
// local file header at start is common
|
|
3147
|
+
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
3148
|
+
}
|
|
3149
|
+
function lastIndexOfEOCD(buf, window) {
|
|
3150
|
+
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
3151
|
+
const start = Math.max(0, buf.length - window);
|
|
3152
|
+
const idx = buf.lastIndexOf(sig, Math.min(buf.length - sig.length, buf.length - 1));
|
|
3153
|
+
return idx >= start ? idx : -1;
|
|
3154
|
+
}
|
|
3155
|
+
function hasTraversal(name) {
|
|
3156
|
+
return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
|
|
3157
|
+
}
|
|
3158
|
+
function createZipBombGuard(opts = {}) {
|
|
3159
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
3160
|
+
return {
|
|
3161
|
+
async scan(input) {
|
|
3162
|
+
const buf = Buffer.from(input);
|
|
3163
|
+
const matches = [];
|
|
3164
|
+
if (!isZipLike(buf))
|
|
3165
|
+
return matches;
|
|
3166
|
+
// Find EOCD near the end
|
|
3167
|
+
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
3168
|
+
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
3169
|
+
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
3170
|
+
matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
|
|
3171
|
+
return matches;
|
|
3172
|
+
}
|
|
3173
|
+
const totalEntries = r16(buf, eocdPos + 10);
|
|
3174
|
+
const cdSize = r32(buf, eocdPos + 12);
|
|
3175
|
+
const cdOffset = r32(buf, eocdPos + 16);
|
|
3176
|
+
// Bounds check
|
|
3177
|
+
if (cdOffset + cdSize > buf.length) {
|
|
3178
|
+
matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
|
|
3179
|
+
return matches;
|
|
3180
|
+
}
|
|
3181
|
+
// Iterate central directory entries
|
|
3182
|
+
let ptr = cdOffset;
|
|
3183
|
+
let seen = 0;
|
|
3184
|
+
let sumComp = 0;
|
|
3185
|
+
let sumUnc = 0;
|
|
3186
|
+
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
3187
|
+
const sig = r32(buf, ptr);
|
|
3188
|
+
if (sig !== SIG_CEN)
|
|
3189
|
+
break; // stop if structure breaks
|
|
3190
|
+
const compSize = r32(buf, ptr + 20);
|
|
3191
|
+
const uncSize = r32(buf, ptr + 24);
|
|
3192
|
+
const fnLen = r16(buf, ptr + 28);
|
|
3193
|
+
const exLen = r16(buf, ptr + 30);
|
|
3194
|
+
const cmLen = r16(buf, ptr + 32);
|
|
3195
|
+
const nameStart = ptr + 46;
|
|
3196
|
+
const nameEnd = nameStart + fnLen;
|
|
3197
|
+
if (nameEnd > buf.length)
|
|
3198
|
+
break;
|
|
3199
|
+
const name = buf.toString('utf8', nameStart, nameEnd);
|
|
3200
|
+
sumComp += compSize;
|
|
3201
|
+
sumUnc += uncSize;
|
|
3202
|
+
seen++;
|
|
3203
|
+
if (name.length > cfg.maxEntryNameLength) {
|
|
3204
|
+
matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
|
|
3205
|
+
}
|
|
3206
|
+
if (hasTraversal(name)) {
|
|
3207
|
+
matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
|
|
3208
|
+
}
|
|
3209
|
+
// move to next entry
|
|
3210
|
+
ptr = nameEnd + exLen + cmLen;
|
|
3211
|
+
}
|
|
3212
|
+
if (seen !== totalEntries) {
|
|
3213
|
+
// central dir truncated/odd, still report what we found
|
|
3214
|
+
matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
|
|
3215
|
+
}
|
|
3216
|
+
// Heuristics thresholds
|
|
3217
|
+
if (seen > cfg.maxEntries) {
|
|
3218
|
+
matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
|
|
3219
|
+
}
|
|
3220
|
+
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
3221
|
+
matches.push({
|
|
3222
|
+
rule: 'zip_total_uncompressed_too_large',
|
|
3223
|
+
severity: 'medium',
|
|
3224
|
+
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
if (sumComp === 0 && sumUnc > 0) {
|
|
3228
|
+
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
|
|
3229
|
+
}
|
|
3230
|
+
else if (sumComp > 0) {
|
|
3231
|
+
const ratio = sumUnc / Math.max(1, sumComp);
|
|
3232
|
+
if (ratio >= cfg.maxCompressionRatio) {
|
|
3233
|
+
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
return matches;
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3240
3241
|
const MB = 1024 * 1024;
|
|
3241
3242
|
const DEFAULT_POLICY = {
|
|
3242
3243
|
includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf'],
|