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.
@@ -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
+ };