np-audit 1.3.0 → 1.5.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/LICENSE +21 -0
- package/README.md +131 -52
- package/package.json +1 -1
- package/src/cli.js +55 -181
- package/src/commands/alias.js +111 -0
- package/src/commands/ci.js +90 -0
- package/src/commands/config.js +71 -0
- package/src/commands/index.js +37 -0
- package/src/commands/install.js +109 -0
- package/src/commands/scan.js +82 -0
- package/src/core/detector.js +444 -0
- package/src/core/requireWalker.js +192 -0
- package/src/core/scanner.js +700 -0
- package/src/utils/command.js +256 -0
- package/src/{config.js → utils/config.js} +34 -2
- package/src/{output.js → utils/output.js} +22 -7
- package/src/{aware.js → utils/review.js} +56 -16
- package/src/{tarball.js → utils/tarball.js} +7 -1
- package/src/utils/updateChecker.js +72 -0
- package/src/detector.js +0 -300
- package/src/scanner.js +0 -407
- /package/src/{fetcher.js → utils/fetcher.js} +0 -0
- /package/src/{lockfile.js → utils/lockfile.js} +0 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
|
|
6
|
+
const CHUNK_STRIDE = 250000; // 50% overlap between adjacent chunks
|
|
7
|
+
|
|
8
|
+
// ─── Individual detection checks ─────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect eval / dynamic code execution, including common indirect-eval tricks.
|
|
12
|
+
* @param {string} code
|
|
13
|
+
* @returns {Finding|null}
|
|
14
|
+
*/
|
|
15
|
+
function checkEval(code) {
|
|
16
|
+
const patterns = [
|
|
17
|
+
/\beval\s*\(/,
|
|
18
|
+
/new\s+Function\s*\(/,
|
|
19
|
+
/vm\.runInThisContext\s*\(/,
|
|
20
|
+
/vm\.runInNewContext\s*\(/,
|
|
21
|
+
/vm\.Script\s*\(/,
|
|
22
|
+
// Indirect eval — (0, eval)(...) is the canonical sloppy-mode trick
|
|
23
|
+
/\(\s*0\s*,\s*eval\s*\)\s*\(/,
|
|
24
|
+
// Bracket access on a global object: global['eval'], globalThis["eval"], window['eval'],
|
|
25
|
+
// and the same with the string built from concatenation: globalThis['ev'+'al']
|
|
26
|
+
/(?:global|globalThis|window|self|this)\s*\[\s*['"`](?:eval|Function)['"`]\s*\]\s*\(/,
|
|
27
|
+
/(?:global|globalThis|window|self|this)\s*\[\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`]){1,}\s*\]\s*\(/,
|
|
28
|
+
// Function constructor accessed via prototype: ({}).constructor.constructor("...")()
|
|
29
|
+
/\.constructor\s*\.\s*constructor\s*\(/,
|
|
30
|
+
// setTimeout/setInterval with a string argument is a legacy eval vector
|
|
31
|
+
/\b(?:setTimeout|setInterval)\s*\(\s*['"`]/,
|
|
32
|
+
// require('vm') hint when combined with run* — covered above by vm.*, but catch the import too
|
|
33
|
+
/require\s*\(\s*['"]vm['"]\s*\)/,
|
|
34
|
+
];
|
|
35
|
+
const matched = patterns.filter(p => p.test(code));
|
|
36
|
+
if (matched.length === 0) return null;
|
|
37
|
+
return { name: 'eval/dynamic-exec', score: 8, detail: `eval-like call found (${matched.length} pattern(s))` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect obfuscator.io signature: _0x variable naming.
|
|
42
|
+
* Score scales with density of obfuscation.
|
|
43
|
+
* @param {string} code
|
|
44
|
+
* @returns {Finding|null}
|
|
45
|
+
*/
|
|
46
|
+
function checkObfuscatorIo(code) {
|
|
47
|
+
const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
|
|
48
|
+
if (matches.length < 3) return null;
|
|
49
|
+
// Scale score: 3-10 = 9, 11-50 = 15, 51-200 = 30, 201-1000 = 50, 1000+ = 80
|
|
50
|
+
let score = 9;
|
|
51
|
+
if (matches.length > 1000) score = 80;
|
|
52
|
+
else if (matches.length > 200) score = 50;
|
|
53
|
+
else if (matches.length > 50) score = 30;
|
|
54
|
+
else if (matches.length > 10) score = 15;
|
|
55
|
+
return { name: 'obfuscator.io', score, detail: `${matches.length} _0x identifiers found` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Detect high-entropy strings (likely encoded/encrypted payloads).
|
|
60
|
+
* Uses indexOf-based extraction to avoid regex stack overflow on large files.
|
|
61
|
+
* Also detects concatenation chains used to defeat per-literal entropy checks.
|
|
62
|
+
* @param {string} code
|
|
63
|
+
* @returns {Finding|null}
|
|
64
|
+
*/
|
|
65
|
+
function checkHighEntropy(code) {
|
|
66
|
+
let maxEntropy = 0;
|
|
67
|
+
let worst = '';
|
|
68
|
+
const minLen = 50;
|
|
69
|
+
|
|
70
|
+
// Simple string extraction without complex regex
|
|
71
|
+
for (const quote of ['"', "'", '`']) {
|
|
72
|
+
let pos = 0;
|
|
73
|
+
while (pos < code.length) {
|
|
74
|
+
const start = code.indexOf(quote, pos);
|
|
75
|
+
if (start === -1) break;
|
|
76
|
+
|
|
77
|
+
// Find end quote (skip escaped quotes)
|
|
78
|
+
let end = start + 1;
|
|
79
|
+
while (end < code.length) {
|
|
80
|
+
if (code[end] === '\\') { end += 2; continue; }
|
|
81
|
+
if (code[end] === quote) break;
|
|
82
|
+
end++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (end < code.length && end - start - 1 >= minLen) {
|
|
86
|
+
const s = code.slice(start + 1, end);
|
|
87
|
+
const e = shannonEntropy(s);
|
|
88
|
+
if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
|
|
89
|
+
}
|
|
90
|
+
pos = end + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (maxEntropy >= 4.5) {
|
|
95
|
+
return {
|
|
96
|
+
name: 'high-entropy-string',
|
|
97
|
+
score: 6,
|
|
98
|
+
detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Concatenation chain: many small string literals joined with `+`.
|
|
103
|
+
// Captures payloads split into <50-char chunks to dodge the per-literal entropy check.
|
|
104
|
+
// We measure the entropy of the *aggregated* literals.
|
|
105
|
+
const concatChainRe = /(?:['"`][^'"`\n]{0,40}['"`]\s*\+\s*){5,}['"`][^'"`\n]{0,40}['"`]/g;
|
|
106
|
+
let m;
|
|
107
|
+
let bestChain = '';
|
|
108
|
+
while ((m = concatChainRe.exec(code)) !== null) {
|
|
109
|
+
const literals = m[0].match(/['"`]([^'"`\n]{0,40})['"`]/g) || [];
|
|
110
|
+
const joined = literals.map(l => l.slice(1, -1)).join('');
|
|
111
|
+
if (joined.length >= 40) {
|
|
112
|
+
const e = shannonEntropy(joined);
|
|
113
|
+
if (e > maxEntropy) { maxEntropy = e; bestChain = joined.slice(0, 40); }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (maxEntropy >= 4.5) {
|
|
118
|
+
return {
|
|
119
|
+
name: 'high-entropy-string',
|
|
120
|
+
score: 6,
|
|
121
|
+
detail: `Entropy ${maxEntropy.toFixed(2)} in concatenated literals "${bestChain}…"`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Detect dense \xNN and \uXXXX escape sequences.
|
|
130
|
+
* Score scales with volume; \u and \x are summed.
|
|
131
|
+
* @param {string} code
|
|
132
|
+
* @returns {Finding|null}
|
|
133
|
+
*/
|
|
134
|
+
function checkHexEscapes(code) {
|
|
135
|
+
const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
|
|
136
|
+
const unicodeMatches = (code.match(/\\u[0-9a-fA-F]{4}/g) || []).length
|
|
137
|
+
+ (code.match(/\\u\{[0-9a-fA-F]+\}/g) || []).length;
|
|
138
|
+
const total = hexMatches + unicodeMatches;
|
|
139
|
+
if (total < 10) return null;
|
|
140
|
+
// Scale: 10-50 = 5, 51-200 = 15, 201-1000 = 30, 1000+ = 50
|
|
141
|
+
let score = 5;
|
|
142
|
+
if (total > 1000) score = 50;
|
|
143
|
+
else if (total > 200) score = 30;
|
|
144
|
+
else if (total > 50) score = 15;
|
|
145
|
+
const detail = unicodeMatches > 0
|
|
146
|
+
? `${hexMatches} \\xNN + ${unicodeMatches} \\uXXXX escapes found`
|
|
147
|
+
: `${hexMatches} \\xNN hex escapes found`;
|
|
148
|
+
return { name: 'hex-escape-density', score, detail };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Detect String.fromCharCode (and its aliases) with many numeric arguments,
|
|
153
|
+
* and large arrays of character codes that are typically reassembled into strings.
|
|
154
|
+
* @param {string} code
|
|
155
|
+
* @returns {Finding|null}
|
|
156
|
+
*/
|
|
157
|
+
function checkFromCharCode(code) {
|
|
158
|
+
// Direct (or property-access) call: String.fromCharCode(...) or anyObj.fromCharCode(...)
|
|
159
|
+
let maxArgs = 0;
|
|
160
|
+
const direct = /(?:String|[\w$]+)\.fromCharCode\s*\(([^)]+)\)/g;
|
|
161
|
+
let match;
|
|
162
|
+
while ((match = direct.exec(code)) !== null) {
|
|
163
|
+
const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
|
|
164
|
+
if (args.length > maxArgs) maxArgs = args.length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Decimal char-code arrays of length >= 8 that look like ASCII text
|
|
168
|
+
// e.g. [101,118,97,108] -> "eval"
|
|
169
|
+
const arrRe = /\[\s*((?:\d{1,3}\s*,\s*){7,}\d{1,3})\s*\]/g;
|
|
170
|
+
let arrMatch;
|
|
171
|
+
let maxArr = 0;
|
|
172
|
+
while ((arrMatch = arrRe.exec(code)) !== null) {
|
|
173
|
+
const nums = arrMatch[1].split(',')
|
|
174
|
+
.map(s => parseInt(s.trim(), 10))
|
|
175
|
+
.filter(n => !Number.isNaN(n));
|
|
176
|
+
// ASCII printable range — typical for char-code payloads
|
|
177
|
+
const printable = nums.filter(n => n >= 32 && n <= 126).length;
|
|
178
|
+
if (printable / nums.length >= 0.9 && nums.length > maxArr) maxArr = nums.length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (maxArgs >= 5) {
|
|
182
|
+
return { name: 'fromCharCode', score: 7, detail: `fromCharCode with ${maxArgs} numeric args` };
|
|
183
|
+
}
|
|
184
|
+
if (maxArr >= 16) {
|
|
185
|
+
// Treat large printable-ascii decimal arrays as equivalent to fromCharCode obfuscation
|
|
186
|
+
return { name: 'fromCharCode', score: 7, detail: `decimal char-code array of length ${maxArr}` };
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Detect base64 / hex decoding combined with eval-like execution.
|
|
193
|
+
* @param {string} code
|
|
194
|
+
* @returns {Finding|null}
|
|
195
|
+
*/
|
|
196
|
+
function checkBase64Exec(code) {
|
|
197
|
+
const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
|
|
198
|
+
const hasHexDecode = /Buffer\.from\s*\([^)]*,\s*['"]hex['"]\)/.test(code);
|
|
199
|
+
const hasExec = /\beval\s*\(|new\s+Function\s*\(|\.exec\s*\(|\(\s*0\s*,\s*eval\s*\)\s*\(/.test(code);
|
|
200
|
+
if (!hasBase64 && !hasHexDecode) return null;
|
|
201
|
+
if (!hasExec) {
|
|
202
|
+
const kind = hasBase64 ? 'Base64' : 'Hex';
|
|
203
|
+
return { name: 'encoded-decode', score: 3, detail: `${kind} decode found — verify usage` };
|
|
204
|
+
}
|
|
205
|
+
const kind = hasBase64 ? 'Base64' : 'Hex';
|
|
206
|
+
return { name: 'encoded-decode+exec', score: 8, detail: `${kind} decode with code execution found` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Detect child_process / shell execution patterns, including string-concatenated
|
|
211
|
+
* `require('child' + '_process')` and access via require.call etc.
|
|
212
|
+
* @param {string} code
|
|
213
|
+
* @returns {Finding|null}
|
|
214
|
+
*/
|
|
215
|
+
function checkChildProcess(code) {
|
|
216
|
+
const patterns = [
|
|
217
|
+
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
|
218
|
+
// node:-prefixed import
|
|
219
|
+
/require\s*\(\s*['"]node:child_process['"]\s*\)/,
|
|
220
|
+
// String-concatenation bypass: require('child'+'_process'), require(\`child${''}_process\`)
|
|
221
|
+
/require\s*\(\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`])+\s*\)/,
|
|
222
|
+
// Dynamic require with computed key — flag for review
|
|
223
|
+
/require\s*\(\s*[a-zA-Z_$][\w$]*\s*\[/,
|
|
224
|
+
/\bexec\s*\(/,
|
|
225
|
+
/\bspawn\s*\(/,
|
|
226
|
+
/\bexecSync\s*\(/,
|
|
227
|
+
/\bspawnSync\s*\(/,
|
|
228
|
+
/\bexecFile\s*\(/,
|
|
229
|
+
/\bexecFileSync\s*\(/,
|
|
230
|
+
/\bfork\s*\(/,
|
|
231
|
+
// Worker threads can host eval-equivalent execution
|
|
232
|
+
/require\s*\(\s*['"]worker_threads['"]\s*\)/,
|
|
233
|
+
/new\s+Worker\s*\(/,
|
|
234
|
+
];
|
|
235
|
+
const matched = patterns.filter(p => p.test(code));
|
|
236
|
+
if (matched.length === 0) return null;
|
|
237
|
+
return { name: 'child-process', score: 5, detail: `Shell/process execution found (${matched.length} pattern(s))` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Detect large hex literal arrays (common in minified obfuscated code).
|
|
242
|
+
* Score scales with volume.
|
|
243
|
+
* @param {string} code
|
|
244
|
+
* @returns {Finding|null}
|
|
245
|
+
*/
|
|
246
|
+
function checkHexArray(code) {
|
|
247
|
+
// Count 0x1234-style literals
|
|
248
|
+
const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
|
|
249
|
+
if (hexLiterals < 20) return null;
|
|
250
|
+
// Scale: 20-100 = 7, 101-500 = 20, 501-2000 = 40, 2000+ = 60
|
|
251
|
+
let score = 7;
|
|
252
|
+
if (hexLiterals > 2000) score = 60;
|
|
253
|
+
else if (hexLiterals > 500) score = 40;
|
|
254
|
+
else if (hexLiterals > 100) score = 20;
|
|
255
|
+
return { name: 'hex-array', score, detail: `${hexLiterals} hex literal values found` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Detect process.env access (potential credential exfiltration signal).
|
|
260
|
+
* @param {string} code
|
|
261
|
+
* @returns {Finding|null}
|
|
262
|
+
*/
|
|
263
|
+
function checkProcessEnv(code) {
|
|
264
|
+
const matches = (code.match(/process\.env\b/g) || []).length;
|
|
265
|
+
if (matches === 0) return null;
|
|
266
|
+
return { name: 'process-env', score: 3, detail: `${matches} process.env access(es)` };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Detect suspicious network calls (data exfiltration).
|
|
271
|
+
* @param {string} code
|
|
272
|
+
* @returns {Finding|null}
|
|
273
|
+
*/
|
|
274
|
+
function checkNetworkCalls(code) {
|
|
275
|
+
const patterns = [
|
|
276
|
+
/require\s*\(\s*['"]https?['"]\s*\)/,
|
|
277
|
+
/require\s*\(\s*['"]net['"]\s*\)/,
|
|
278
|
+
/require\s*\(\s*['"]dns['"]\s*\)/,
|
|
279
|
+
/require\s*\(\s*['"]tls['"]\s*\)/,
|
|
280
|
+
/require\s*\(\s*['"]dgram['"]\s*\)/,
|
|
281
|
+
/require\s*\(\s*['"]http2['"]\s*\)/,
|
|
282
|
+
/\bfetch\s*\(/,
|
|
283
|
+
/XMLHttpRequest/,
|
|
284
|
+
/\.request\s*\(/,
|
|
285
|
+
// node:-prefixed imports (Node 16+)
|
|
286
|
+
/require\s*\(\s*['"]node:(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
|
|
287
|
+
// Dynamic import of these modules
|
|
288
|
+
/import\s*\(\s*['"](?:node:)?(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
|
|
289
|
+
];
|
|
290
|
+
const matched = patterns.filter(p => p.test(code));
|
|
291
|
+
if (matched.length === 0) return null;
|
|
292
|
+
return { name: 'network-call', score: 4, detail: `Network call found (${matched.length} pattern(s))` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Detect filesystem manipulation (potential backdoor installation).
|
|
297
|
+
* @param {string} code
|
|
298
|
+
* @returns {Finding|null}
|
|
299
|
+
*/
|
|
300
|
+
function checkFilesystemManipulation(code) {
|
|
301
|
+
const writePatterns = [
|
|
302
|
+
/fs\.write(?:File)?(?:Sync)?\s*\(/,
|
|
303
|
+
/fs\.append(?:File)?(?:Sync)?\s*\(/,
|
|
304
|
+
/fs\.create(?:WriteStream)?\s*\(/,
|
|
305
|
+
/\.pipe\s*\(/,
|
|
306
|
+
];
|
|
307
|
+
const permissionPatterns = [
|
|
308
|
+
/fs\.chmod(?:Sync)?\s*\(/,
|
|
309
|
+
/fs\.chown(?:Sync)?\s*\(/,
|
|
310
|
+
/fs\.access(?:Sync)?\s*\(/,
|
|
311
|
+
];
|
|
312
|
+
const linkPatterns = [
|
|
313
|
+
/fs\.symlink(?:Sync)?\s*\(/,
|
|
314
|
+
/fs\.link(?:Sync)?\s*\(/,
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const writeMatches = writePatterns.filter(p => p.test(code)).length;
|
|
318
|
+
const permMatches = permissionPatterns.filter(p => p.test(code)).length;
|
|
319
|
+
const linkMatches = linkPatterns.filter(p => p.test(code)).length;
|
|
320
|
+
|
|
321
|
+
if (writeMatches === 0 && permMatches === 0 && linkMatches === 0) return null;
|
|
322
|
+
|
|
323
|
+
const details = [];
|
|
324
|
+
if (writeMatches > 0) details.push(`${writeMatches} write operation(s)`);
|
|
325
|
+
if (permMatches > 0) details.push(`${permMatches} permission change(s)`);
|
|
326
|
+
if (linkMatches > 0) details.push(`${linkMatches} symlink operation(s)`);
|
|
327
|
+
|
|
328
|
+
// Score 3-4 based on variety of operations
|
|
329
|
+
let score = 3;
|
|
330
|
+
if ((writeMatches > 0 ? 1 : 0) + (permMatches > 0 ? 1 : 0) + (linkMatches > 0 ? 1 : 0) >= 2) {
|
|
331
|
+
score = 4;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
name: 'filesystem-manipulation',
|
|
336
|
+
score,
|
|
337
|
+
detail: details.join(', ')
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Entropy helper ──────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function shannonEntropy(str) {
|
|
344
|
+
if (!str || str.length === 0) return 0;
|
|
345
|
+
const freq = {};
|
|
346
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
347
|
+
let entropy = 0;
|
|
348
|
+
for (const count of Object.values(freq)) {
|
|
349
|
+
const p = count / str.length;
|
|
350
|
+
entropy -= p * Math.log2(p);
|
|
351
|
+
}
|
|
352
|
+
return entropy;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Main detection function ─────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
const CHECKS = [
|
|
358
|
+
checkEval,
|
|
359
|
+
checkObfuscatorIo,
|
|
360
|
+
checkHighEntropy,
|
|
361
|
+
checkHexEscapes,
|
|
362
|
+
checkFromCharCode,
|
|
363
|
+
checkBase64Exec,
|
|
364
|
+
checkChildProcess,
|
|
365
|
+
checkHexArray,
|
|
366
|
+
checkProcessEnv,
|
|
367
|
+
checkNetworkCalls,
|
|
368
|
+
checkFilesystemManipulation,
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Run all checks against a code string.
|
|
373
|
+
* For large files, uses a sliding window (50% overlap) so payloads cannot
|
|
374
|
+
* hide in the gaps between fixed start/middle/end chunks.
|
|
375
|
+
* @param {string} code
|
|
376
|
+
* @param {object} config { blockScore, warnScore }
|
|
377
|
+
* @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
|
|
378
|
+
*/
|
|
379
|
+
function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
|
|
380
|
+
if (!code || typeof code !== 'string') {
|
|
381
|
+
return { score: 0, findings: [], verdict: 'OK' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// For large files, slide a window across the entire content. With a 500KB
|
|
385
|
+
// window and 250KB stride, every byte appears in at least one window — so a
|
|
386
|
+
// payload at any offset is guaranteed to be analyzed in a single contiguous
|
|
387
|
+
// chunk.
|
|
388
|
+
const chunks = [];
|
|
389
|
+
if (code.length > MAX_CODE_SIZE) {
|
|
390
|
+
let start = 0;
|
|
391
|
+
while (start < code.length) {
|
|
392
|
+
chunks.push(code.slice(start, start + MAX_CODE_SIZE));
|
|
393
|
+
if (start + MAX_CODE_SIZE >= code.length) break;
|
|
394
|
+
start += CHUNK_STRIDE;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
chunks.push(code);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const allFindings = new Map(); // Dedupe by name, keep highest score
|
|
401
|
+
|
|
402
|
+
for (const chunk of chunks) {
|
|
403
|
+
for (const check of CHECKS) {
|
|
404
|
+
const result = check(chunk);
|
|
405
|
+
if (result) {
|
|
406
|
+
const existing = allFindings.get(result.name);
|
|
407
|
+
if (!existing || result.score > existing.score) {
|
|
408
|
+
allFindings.set(result.name, result);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const findings = Array.from(allFindings.values());
|
|
415
|
+
|
|
416
|
+
// Score = highest individual finding score
|
|
417
|
+
const score = findings.length > 0
|
|
418
|
+
? Math.max(...findings.map(f => f.score))
|
|
419
|
+
: 0;
|
|
420
|
+
|
|
421
|
+
let verdict;
|
|
422
|
+
if (score >= config.blockScore) verdict = 'BLOCK';
|
|
423
|
+
else if (score >= config.warnScore) verdict = 'WARN';
|
|
424
|
+
else verdict = 'OK';
|
|
425
|
+
|
|
426
|
+
return { score, findings, verdict };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
module.exports = {
|
|
430
|
+
detectObfuscation,
|
|
431
|
+
shannonEntropy,
|
|
432
|
+
// Export individual checks for testing
|
|
433
|
+
checkEval,
|
|
434
|
+
checkObfuscatorIo,
|
|
435
|
+
checkHighEntropy,
|
|
436
|
+
checkHexEscapes,
|
|
437
|
+
checkFromCharCode,
|
|
438
|
+
checkBase64Exec,
|
|
439
|
+
checkChildProcess,
|
|
440
|
+
checkHexArray,
|
|
441
|
+
checkProcessEnv,
|
|
442
|
+
checkNetworkCalls,
|
|
443
|
+
checkFilesystemManipulation,
|
|
444
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// Hard caps to prevent pathological inputs from exploding analysis time.
|
|
6
|
+
const MAX_FILES_PER_PACKAGE = 50;
|
|
7
|
+
const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5 MB total
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walk all internal `require('./...')` / `require('../...')` / `import` chains
|
|
11
|
+
* starting from an entry file, returning the full set of files that would be
|
|
12
|
+
* loaded when the entry script runs.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally regex-based — the package advertises zero runtime
|
|
15
|
+
* dependencies, so we don't pull in a JS parser. The trade-off: we accept
|
|
16
|
+
* occasional false positives (a string literal that *looks* like a require
|
|
17
|
+
* argument) and false negatives (dynamic requires built from variables).
|
|
18
|
+
* Dynamic requires are explicitly recorded as a separate finding so the user
|
|
19
|
+
* sees that *something* unresolvable was loaded.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} entryPath normalized path of the start file
|
|
22
|
+
* @param {(p: string) => Buffer|null} readFile callback that returns the file
|
|
23
|
+
* contents at a given normalized
|
|
24
|
+
* path, or null if not found
|
|
25
|
+
* @returns {{
|
|
26
|
+
* files: Map<string, string>, // path → source code
|
|
27
|
+
* dynamicRequires: Array<{file: string, hint: string}>,
|
|
28
|
+
* unresolved: Array<{file: string, target: string}>,
|
|
29
|
+
* truncated: boolean
|
|
30
|
+
* }}
|
|
31
|
+
*/
|
|
32
|
+
function walkRequires(entryPath, readFile) {
|
|
33
|
+
const files = new Map();
|
|
34
|
+
const dynamicRequires = [];
|
|
35
|
+
const unresolved = [];
|
|
36
|
+
const queue = [entryPath];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
let totalBytes = 0;
|
|
39
|
+
let truncated = false;
|
|
40
|
+
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const current = queue.shift();
|
|
43
|
+
if (seen.has(current)) continue;
|
|
44
|
+
seen.add(current);
|
|
45
|
+
|
|
46
|
+
if (files.size >= MAX_FILES_PER_PACKAGE) { truncated = true; break; }
|
|
47
|
+
|
|
48
|
+
const buf = readFile(current);
|
|
49
|
+
if (!buf) continue;
|
|
50
|
+
|
|
51
|
+
totalBytes += buf.length;
|
|
52
|
+
if (totalBytes > MAX_TOTAL_BYTES) { truncated = true; break; }
|
|
53
|
+
|
|
54
|
+
const code = buf.toString('utf8');
|
|
55
|
+
files.set(current, code);
|
|
56
|
+
|
|
57
|
+
const { staticTargets, dynamicHints } = extractRequires(code);
|
|
58
|
+
|
|
59
|
+
for (const hint of dynamicHints) {
|
|
60
|
+
dynamicRequires.push({ file: current, hint });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const target of staticTargets) {
|
|
64
|
+
// Only follow *internal* paths — explicit relative or absolute-within-package.
|
|
65
|
+
// Package-name requires (e.g. require('lodash')) are external; the scanner
|
|
66
|
+
// would have to resolve them as separate dependencies, which is out of
|
|
67
|
+
// scope here — npm's own resolution will fetch and ship them, and they
|
|
68
|
+
// appear independently in the lockfile so np-audit scans them anyway.
|
|
69
|
+
if (!isInternalRequire(target)) continue;
|
|
70
|
+
|
|
71
|
+
const resolved = resolveRelative(current, target, readFile);
|
|
72
|
+
if (resolved) {
|
|
73
|
+
if (!seen.has(resolved)) queue.push(resolved);
|
|
74
|
+
} else {
|
|
75
|
+
unresolved.push({ file: current, target });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { files, dynamicRequires, unresolved, truncated };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract every require/import target literal from a chunk of source code.
|
|
85
|
+
* Splits the result into:
|
|
86
|
+
* - staticTargets: string literals we can resolve at scan time
|
|
87
|
+
* - dynamicHints: non-literal arguments (variables, template substitutions,
|
|
88
|
+
* string concatenations) that signal a dynamic load
|
|
89
|
+
*/
|
|
90
|
+
function extractRequires(code) {
|
|
91
|
+
const staticTargets = new Set();
|
|
92
|
+
const dynamicHints = [];
|
|
93
|
+
|
|
94
|
+
// 1. require('literal') — including template strings without substitution
|
|
95
|
+
const staticRe = /\brequire\s*\(\s*(['"`])([^'"`\n\r$]+)\1\s*\)/g;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = staticRe.exec(code)) !== null) {
|
|
98
|
+
staticTargets.add(m[2]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. import 'literal' and import x from 'literal' and import x, {y} from 'literal'
|
|
102
|
+
const importRe = /\bimport\s+(?:[^'"`;]+\s+from\s+)?(['"`])([^'"`\n\r$]+)\1/g;
|
|
103
|
+
while ((m = importRe.exec(code)) !== null) {
|
|
104
|
+
staticTargets.add(m[2]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. await import('literal') / import('literal') dynamic import with a literal arg
|
|
108
|
+
const dynImportRe = /\bimport\s*\(\s*(['"`])([^'"`\n\r$]+)\1\s*\)/g;
|
|
109
|
+
while ((m = dynImportRe.exec(code)) !== null) {
|
|
110
|
+
staticTargets.add(m[2]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 4. Dynamic require: require(variable), require(expr+expr), require(`tpl${x}`)
|
|
114
|
+
// We capture only enough to record that *something* dynamic was loaded —
|
|
115
|
+
// the actual target is unknowable without execution.
|
|
116
|
+
const dynamicRe = /\brequire\s*\(\s*([^)]*?)\s*\)/g;
|
|
117
|
+
while ((m = dynamicRe.exec(code)) !== null) {
|
|
118
|
+
const arg = m[1].trim();
|
|
119
|
+
if (arg === '') continue;
|
|
120
|
+
// Pure literal? Already captured above. Skip.
|
|
121
|
+
if (/^(['"`])[^'"`\n\r$]+\1$/.test(arg)) continue;
|
|
122
|
+
// Looks like a literal with embedded template expression, concatenation,
|
|
123
|
+
// variable, member access, or function call. Record it.
|
|
124
|
+
dynamicHints.push(arg.slice(0, 120));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 5. Dynamic import: import(variable)
|
|
128
|
+
const dynImportDynamicRe = /\bimport\s*\(\s*([^)]*?)\s*\)/g;
|
|
129
|
+
while ((m = dynImportDynamicRe.exec(code)) !== null) {
|
|
130
|
+
const arg = m[1].trim();
|
|
131
|
+
if (arg === '') continue;
|
|
132
|
+
if (/^(['"`])[^'"`\n\r$]+\1$/.test(arg)) continue;
|
|
133
|
+
dynamicHints.push(`import(${arg.slice(0, 100)})`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
staticTargets: Array.from(staticTargets),
|
|
138
|
+
dynamicHints,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Is this require target a relative or absolute-within-package path
|
|
144
|
+
* (as opposed to a package-name import like 'lodash')?
|
|
145
|
+
*/
|
|
146
|
+
function isInternalRequire(target) {
|
|
147
|
+
return target.startsWith('./') || target.startsWith('../') || target.startsWith('/');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a relative require target against the directory of the requiring
|
|
152
|
+
* file, applying Node's resolution rules: try the path as-is, then with
|
|
153
|
+
* common extensions, then as a directory's index file.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} fromFile normalized path of the requiring file
|
|
156
|
+
* @param {string} target the require argument string
|
|
157
|
+
* @param {(p: string) => Buffer|null} readFile
|
|
158
|
+
* @returns {string|null} normalized path of the resolved file
|
|
159
|
+
*/
|
|
160
|
+
function resolveRelative(fromFile, target, readFile) {
|
|
161
|
+
const fromDir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
|
|
162
|
+
// Strip a leading absolute slash if present — we treat all paths as
|
|
163
|
+
// package-relative.
|
|
164
|
+
const rel = target.startsWith('/') ? target.slice(1) : target;
|
|
165
|
+
const joined = path.posix.normalize(path.posix.join(fromDir, rel));
|
|
166
|
+
|
|
167
|
+
const candidates = [
|
|
168
|
+
joined,
|
|
169
|
+
joined + '.js',
|
|
170
|
+
joined + '.mjs',
|
|
171
|
+
joined + '.cjs',
|
|
172
|
+
joined + '.json',
|
|
173
|
+
joined + '/index.js',
|
|
174
|
+
joined + '/index.mjs',
|
|
175
|
+
joined + '/index.cjs',
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
for (const c of candidates) {
|
|
179
|
+
if (readFile(c)) return c;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
walkRequires,
|
|
186
|
+
extractRequires,
|
|
187
|
+
resolveRelative,
|
|
188
|
+
isInternalRequire,
|
|
189
|
+
// Exported for tests
|
|
190
|
+
MAX_FILES_PER_PACKAGE,
|
|
191
|
+
MAX_TOTAL_BYTES,
|
|
192
|
+
};
|