np-audit 1.4.0 → 1.5.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.
@@ -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
+ };
@@ -6,8 +6,26 @@ const { parseLockfile } = require('../utils/lockfile');
6
6
  const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('../utils/fetcher');
7
7
  const { parseTarGz, extractFile, getPackageJson } = require('../utils/tarball');
8
8
  const { detectObfuscation } = require('./detector');
9
+ const { walkRequires, MAX_FILES_PER_PACKAGE, MAX_TOTAL_BYTES } = require('./requireWalker');
10
+ const { parseCommand } = require('../utils/command');
9
11
  const output = require('../utils/output');
10
12
 
13
+ // Lifecycle scripts that npm executes during install. The original tool only
14
+ // looked at preinstall/install/postinstall, but `prepare` is also automatically
15
+ // run for git dependencies and during `npm install` of local paths; and
16
+ // `preprepare`/`postprepare` wrap `prepare`. We also include `prepublish` (run
17
+ // during `npm install` historically — deprecated but still respected by older
18
+ // npm versions in the dependency graph).
19
+ const LIFECYCLE_SCRIPTS = [
20
+ 'preinstall',
21
+ 'install',
22
+ 'postinstall',
23
+ 'preprepare',
24
+ 'prepare',
25
+ 'postprepare',
26
+ 'prepublish',
27
+ ];
28
+
11
29
  /**
12
30
  * Main scan orchestrator.
13
31
  * @param {object} opts
@@ -36,7 +54,8 @@ async function scan(opts) {
36
54
  const resolved = await resolveSinglePackage(pkg, config);
37
55
  // Mark the first package (the explicitly requested one) as explicit
38
56
  if (resolved.length > 0) {
39
- const pkgName = pkg.includes('@') && !pkg.startsWith('@') ? pkg.split('@')[0] : pkg;
57
+ const lastAt = pkg.lastIndexOf('@');
58
+ const pkgName = lastAt > 0 ? pkg.slice(0, lastAt) : pkg;
40
59
  explicitPackageNames.add(pkgName);
41
60
  }
42
61
  allPackages.push(...resolved);
@@ -91,11 +110,101 @@ async function scan(opts) {
91
110
  // Add packages that returned null from scanPackage (no scripts found during scan)
92
111
  skippedCount += results.filter(r => r === null).length;
93
112
 
113
+ // Optionally scan the *current project's own* lifecycle scripts. This is
114
+ // off by default to avoid surprising users — `npa` is a drop-in replacement
115
+ // for `npm install` and most projects' own postinstall scripts are
116
+ // intentionally local. Set `scanSelf: true` in .npmauditor.json (or pass
117
+ // --scan-self) to opt in. Useful for CI on third-party PRs.
118
+ if (config.scanSelf) {
119
+ const selfResult = scanCwdProject(cwd, config);
120
+ if (selfResult) scanned.unshift(selfResult);
121
+ else skippedCount++;
122
+ }
123
+
94
124
  // Attach metadata to results array
95
125
  scanned.skippedCount = skippedCount;
96
126
  return scanned;
97
127
  }
98
128
 
129
+ /**
130
+ * Scan the lifecycle scripts of the CWD's own package.json.
131
+ * Returns null when there is no package.json or no relevant scripts.
132
+ */
133
+ function scanCwdProject(cwd, config) {
134
+ const pkgJsonPath = path.join(cwd, 'package.json');
135
+ if (!fs.existsSync(pkgJsonPath)) return null;
136
+
137
+ let pkgJson;
138
+ try {
139
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
140
+ } catch {
141
+ return null;
142
+ }
143
+
144
+ if (!hasInstallScripts(pkgJson)) return null;
145
+
146
+ // Synthesize a package descriptor so the report renders consistently.
147
+ const pkg = {
148
+ name: pkgJson.name || '(current project)',
149
+ version: pkgJson.version || '0.0.0',
150
+ self: true,
151
+ };
152
+
153
+ // The CWD reader resolves paths relative to the project root (where
154
+ // package.json lives), so the local-fs reader is reused.
155
+ return analyzeScriptsLocalFromDir(pkg, pkgJson, cwd, config);
156
+ }
157
+
158
+ /**
159
+ * Analyze a package's lifecycle scripts using a directory root as the
160
+ * filesystem base. Used for both node_modules packages and the CWD itself.
161
+ */
162
+ function analyzeScriptsLocalFromDir(pkg, pkgJson, rootDir, config) {
163
+ const scripts = getInstallScripts(pkgJson);
164
+ if (scripts.length === 0) return null;
165
+
166
+ const reader = makeLocalReader(rootDir);
167
+ const scriptResults = [];
168
+
169
+ for (const { lifecycle, command } of scripts) {
170
+ const refs = parseCommand(command);
171
+ if (refs.length === 0) {
172
+ const result = detectObfuscation(command, config);
173
+ scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
174
+ continue;
175
+ }
176
+ for (const ref of refs) {
177
+ if (ref.kind === 'inline') {
178
+ const result = detectObfuscation(ref.code, config);
179
+ scriptResults.push({ lifecycle, file: `(inline:${ref.interpreter})`, code: ref.code, ...result });
180
+ continue;
181
+ }
182
+ if (ref.interpreter === 'node' || ref.interpreter === 'auto') {
183
+ scriptResults.push(analyzeScriptWithWalker(lifecycle, ref.path, reader, config));
184
+ } else {
185
+ const buf = reader(ref.path);
186
+ if (!buf) {
187
+ scriptResults.push({
188
+ lifecycle, file: ref.path, code: '', score: 0,
189
+ findings: [{
190
+ name: 'missing-script', score: 0,
191
+ detail: `Command references "${ref.path}" but file not found`,
192
+ }],
193
+ verdict: 'OK',
194
+ });
195
+ continue;
196
+ }
197
+ const code = buf.toString('utf8');
198
+ const result = detectObfuscation(code, config);
199
+ scriptResults.push({ lifecycle, file: ref.path, code, ...result });
200
+ }
201
+ }
202
+ }
203
+
204
+ if (scriptResults.length === 0) return null;
205
+ return summarizeResults(pkg, scriptResults, config);
206
+ }
207
+
99
208
  /**
100
209
  * Scan a single package for obfuscated install scripts.
101
210
  * @returns {ScanResult|null} null if no install scripts found
@@ -138,8 +247,18 @@ async function scanPackage(pkg, cwd, config, verbose) {
138
247
 
139
248
  let files;
140
249
  try {
141
- files = parseTarGz(tarBuffer);
250
+ files = parseTarGz(tarBuffer, config.maxTarballSize);
142
251
  } catch (err) {
252
+ if (err.message.includes('exceeds limit')) {
253
+ // Tarball too large - return a special result indicating oversized tarball
254
+ return {
255
+ pkg,
256
+ scripts: [],
257
+ score: 0,
258
+ findings: [{ name: 'oversized-tarball', score: 0, detail: err.message }],
259
+ verdict: 'OK'
260
+ };
261
+ }
143
262
  output.warn(`Could not parse tarball for ${pkg.name}@${pkg.version}: ${err.message}`);
144
263
  return null;
145
264
  }
@@ -158,74 +277,214 @@ async function scanPackage(pkg, cwd, config, verbose) {
158
277
  }
159
278
 
160
279
  /**
161
- * Analyze install scripts from a tarball's file map.
280
+ * Analyze a single entry-script reference, including every internal
281
+ * require/import target reachable from it. Returns one combined result row
282
+ * per top-level script reference (not one per file walked), so the existing
283
+ * report shape stays the same.
284
+ *
285
+ * @param {string} lifecycle e.g. "postinstall"
286
+ * @param {string} entryPath normalized path of the entry file
287
+ * @param {(p: string) => Buffer|null} readFile
288
+ * @param {object} config
289
+ * @returns {object} script result row
162
290
  */
163
- function analyzeScripts(pkg, pkgJson, files, config) {
164
- const scripts = getInstallScripts(pkgJson);
165
- if (scripts.length === 0) return null;
291
+ function analyzeScriptWithWalker(lifecycle, entryPath, readFile, config) {
292
+ const walk = walkRequires(entryPath, readFile);
293
+
294
+ if (walk.files.size === 0) {
295
+ return {
296
+ lifecycle,
297
+ file: entryPath,
298
+ code: '',
299
+ score: 0,
300
+ findings: [{
301
+ name: 'missing-script',
302
+ score: 0,
303
+ detail: `Command references "${entryPath}" but file not found`,
304
+ }],
305
+ verdict: 'OK',
306
+ };
307
+ }
166
308
 
167
- const scriptResults = [];
309
+ // Run detection on every walked file and aggregate.
310
+ const findings = [];
311
+ let maxScore = 0;
312
+ let entryCode = '';
168
313
 
169
- for (const { lifecycle, command } of scripts) {
170
- const scriptFile = extractScriptFileFromCommand(command);
171
- if (!scriptFile) {
172
- // Inline shell command analyze the command string itself
173
- const result = detectObfuscation(command, config);
174
- scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
175
- continue;
314
+ for (const [filePath, code] of walk.files) {
315
+ if (filePath === entryPath) entryCode = code;
316
+ const result = detectObfuscation(code, config);
317
+ if (result.score > maxScore) maxScore = result.score;
318
+ // Tag each finding with the file it came from so the report makes sense
319
+ // when multiple files contribute.
320
+ for (const f of result.findings) {
321
+ findings.push({
322
+ ...f,
323
+ detail: walk.files.size > 1 ? `[${filePath}] ${f.detail}` : f.detail,
324
+ });
176
325
  }
326
+ }
177
327
 
178
- const fileBuf = extractFile(files, scriptFile);
179
- if (!fileBuf) continue;
328
+ // Surface dynamic requires as findings — these are unresolvable load
329
+ // targets and the user should review them. They count as a small score
330
+ // bump so a script that ONLY does require(variable) still warrants a look.
331
+ for (const dr of walk.dynamicRequires) {
332
+ findings.push({
333
+ name: 'dynamic-require',
334
+ score: 4,
335
+ detail: `[${dr.file}] dynamic require/import: ${dr.hint}`,
336
+ });
337
+ if (4 > maxScore) maxScore = 4;
338
+ }
180
339
 
181
- const code = fileBuf.toString('utf8');
182
- const result = detectObfuscation(code, config);
183
- scriptResults.push({ lifecycle, file: scriptFile, code, ...result });
340
+ // Truncation is a defense-in-depth signal — a package that loads >50 files
341
+ // from postinstall is suspicious in itself.
342
+ if (walk.truncated) {
343
+ findings.push({
344
+ name: 'oversized-require-graph',
345
+ score: 4,
346
+ detail: `Require graph exceeded scan limits (>${MAX_FILES_PER_PACKAGE} files or ${Math.round(MAX_TOTAL_BYTES / 1024 / 1024)}MB)`,
347
+ });
348
+ if (4 > maxScore) maxScore = 4;
184
349
  }
185
350
 
186
- if (scriptResults.length === 0) return null;
351
+ // Unresolved internal requires (e.g. require('./does-not-exist')) are
352
+ // recorded but not scored. They might be legitimate (lazy-loaded optional
353
+ // deps) but are also a common camouflage technique.
354
+ for (const u of walk.unresolved) {
355
+ findings.push({
356
+ name: 'unresolved-require',
357
+ score: 0,
358
+ detail: `[${u.file}] could not resolve "${u.target}"`,
359
+ });
360
+ }
187
361
 
188
- const maxScore = Math.max(...scriptResults.map(r => r.score));
189
- const allFindings = scriptResults.flatMap(r => r.findings);
190
- const verdict = verdictFromScore(maxScore, config);
362
+ return {
363
+ lifecycle,
364
+ file: entryPath,
365
+ code: entryCode,
366
+ score: maxScore,
367
+ findings,
368
+ verdict: verdictFromScore(maxScore, config),
369
+ walkedFiles: Array.from(walk.files.keys()),
370
+ };
371
+ }
191
372
 
192
- return { pkg, scripts: scriptResults, score: maxScore, findings: allFindings, verdict };
373
+ /**
374
+ * Build a tarball-aware readFile callback. The tarball file map uses keys
375
+ * like "package/<path>", so we normalize away the leading top-level dir.
376
+ */
377
+ function makeTarballReader(files) {
378
+ // Determine the leading-dir prefix once (typically "package/").
379
+ let prefix = '';
380
+ for (const key of files.keys()) {
381
+ const slash = key.indexOf('/');
382
+ if (slash > 0) { prefix = key.slice(0, slash + 1); break; }
383
+ }
384
+ return (normalizedPath) => {
385
+ // Try with the detected prefix first, then exact, then any leading-dir strip.
386
+ if (prefix) {
387
+ const buf = files.get(prefix + normalizedPath);
388
+ if (buf) return buf;
389
+ }
390
+ if (files.has(normalizedPath)) return files.get(normalizedPath);
391
+ // Last-ditch: try every entry stripped of its leading component.
392
+ for (const [k, v] of files) {
393
+ if (k.replace(/^[^/]+\//, '') === normalizedPath) return v;
394
+ }
395
+ return null;
396
+ };
193
397
  }
194
398
 
195
399
  /**
196
- * Analyze install scripts from local node_modules.
400
+ * Build a local-filesystem readFile callback rooted at the package dir.
197
401
  */
198
- function analyzeScriptsLocal(pkg, pkgJson, cwd, config) {
402
+ function makeLocalReader(pkgDir) {
403
+ return (normalizedPath) => {
404
+ if (!pkgDir) return null;
405
+ const abs = path.join(pkgDir, normalizedPath);
406
+ // Guard against path traversal escaping the package root. Anything that
407
+ // resolves outside pkgDir is treated as not-found.
408
+ const rel = path.relative(pkgDir, abs);
409
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
410
+ try {
411
+ return fs.readFileSync(abs);
412
+ } catch {
413
+ return null;
414
+ }
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Analyze install scripts from a tarball's file map.
420
+ */
421
+ function analyzeScripts(pkg, pkgJson, files, config) {
199
422
  const scripts = getInstallScripts(pkgJson);
200
423
  if (scripts.length === 0) return null;
201
424
 
202
- const pkgDir = findLocalPackageDir(cwd, pkg.name);
425
+ const reader = makeTarballReader(files);
203
426
  const scriptResults = [];
204
427
 
205
428
  for (const { lifecycle, command } of scripts) {
206
- const scriptFile = extractScriptFileFromCommand(command);
207
- if (!scriptFile) {
429
+ const refs = parseCommand(command);
430
+ if (refs.length === 0) {
208
431
  const result = detectObfuscation(command, config);
209
432
  scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
210
433
  continue;
211
434
  }
212
435
 
213
- const absolutePath = pkgDir ? path.join(pkgDir, scriptFile) : null;
214
- if (!absolutePath || !fs.existsSync(absolutePath)) continue;
215
-
216
- let code;
217
- try { code = fs.readFileSync(absolutePath, 'utf8'); } catch { continue; }
436
+ for (const ref of refs) {
437
+ if (ref.kind === 'inline') {
438
+ const result = detectObfuscation(ref.code, config);
439
+ scriptResults.push({ lifecycle, file: `(inline:${ref.interpreter})`, code: ref.code, ...result });
440
+ continue;
441
+ }
218
442
 
219
- const result = detectObfuscation(code, config);
220
- scriptResults.push({ lifecycle, file: scriptFile, code, ...result });
443
+ // ref.kind === 'file'. Only Node-interpreted JS gets the require walk;
444
+ // shell scripts and binary files are read once and analyzed flat.
445
+ if (ref.interpreter === 'node' || ref.interpreter === 'auto') {
446
+ scriptResults.push(analyzeScriptWithWalker(lifecycle, ref.path, reader, config));
447
+ } else {
448
+ const fileBuf = reader(ref.path);
449
+ if (!fileBuf) {
450
+ scriptResults.push({
451
+ lifecycle,
452
+ file: ref.path,
453
+ code: '',
454
+ score: 0,
455
+ findings: [{
456
+ name: 'missing-script',
457
+ score: 0,
458
+ detail: `Command references "${ref.path}" but file not found`,
459
+ }],
460
+ verdict: 'OK',
461
+ });
462
+ continue;
463
+ }
464
+ const code = fileBuf.toString('utf8');
465
+ const result = detectObfuscation(code, config);
466
+ scriptResults.push({ lifecycle, file: ref.path, code, ...result });
467
+ }
468
+ }
221
469
  }
222
470
 
223
471
  if (scriptResults.length === 0) return null;
472
+ return summarizeResults(pkg, scriptResults, config);
473
+ }
224
474
 
475
+ /**
476
+ * Analyze install scripts from local node_modules.
477
+ */
478
+ function analyzeScriptsLocal(pkg, pkgJson, cwd, config) {
479
+ const pkgDir = findLocalPackageDir(cwd, pkg.name);
480
+ if (!pkgDir) return null;
481
+ return analyzeScriptsLocalFromDir(pkg, pkgJson, pkgDir, config);
482
+ }
483
+
484
+ function summarizeResults(pkg, scriptResults, config) {
225
485
  const maxScore = Math.max(...scriptResults.map(r => r.score));
226
486
  const allFindings = scriptResults.flatMap(r => r.findings);
227
487
  const verdict = verdictFromScore(maxScore, config);
228
-
229
488
  return { pkg, scripts: scriptResults, score: maxScore, findings: allFindings, verdict };
230
489
  }
231
490
 
@@ -233,31 +492,31 @@ function analyzeScriptsLocal(pkg, pkgJson, cwd, config) {
233
492
 
234
493
  function hasInstallScripts(pkgJson) {
235
494
  if (!pkgJson || !pkgJson.scripts) return false;
236
- return !!(pkgJson.scripts.preinstall || pkgJson.scripts.postinstall || pkgJson.scripts.install);
495
+ return LIFECYCLE_SCRIPTS.some(lc => pkgJson.scripts[lc]);
237
496
  }
238
497
 
239
498
  function getInstallScripts(pkgJson) {
240
499
  const result = [];
241
500
  const s = pkgJson && pkgJson.scripts || {};
242
- for (const lc of ['preinstall', 'install', 'postinstall']) {
501
+ for (const lc of LIFECYCLE_SCRIPTS) {
243
502
  if (s[lc]) result.push({ lifecycle: lc, command: s[lc] });
244
503
  }
245
504
  return result;
246
505
  }
247
506
 
248
507
  /**
249
- * Extract the JS file path from a script command like "node ./install.js" or "node scripts/setup".
250
- * Returns null if it's a pure shell command.
508
+ * Extract the first JS file path from a script command.
509
+ *
510
+ * @deprecated Superseded by `parseCommand` in src/utils/command.js, which
511
+ * understands chained commands, shell scripts, `node -e`, multi-interpreter
512
+ * pipelines, and returns *all* script references instead of just one. Kept
513
+ * here only so external consumers importing this symbol don't break.
514
+ * Returns null if no node-invoked JS file can be extracted.
251
515
  */
252
516
  function extractScriptFileFromCommand(command) {
253
- const m = command.match(/(?:^|\s)node\s+([^\s]+\.(?:js|mjs|cjs))/);
254
- if (m) return m[1].replace(/^\.\//, '');
255
- const m2 = command.match(/(?:^|\s)node\s+([^\s]+)(?:\s|$)/);
256
- if (m2) {
257
- const f = m2[1].replace(/^\.\//, '');
258
- if (!f.startsWith('-')) return f + (f.includes('.') ? '' : '.js');
259
- }
260
- return null;
517
+ const refs = parseCommand(command);
518
+ const fileRef = refs.find(r => r.kind === 'file' && r.interpreter === 'node');
519
+ return fileRef ? fileRef.path : null;
261
520
  }
262
521
 
263
522
  function tryReadLocalPackageJson(cwd, pkg) {
@@ -327,7 +586,8 @@ async function resolveFromPackageJson(cwd, config, noDev) {
327
586
  if (!version) continue;
328
587
 
329
588
  try {
330
- const meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
589
+ const encodedName = name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
590
+ const meta = await fetchJSON(`${config.registry}/${encodedName}`, { timeout: config.timeout });
331
591
  const versionData = meta.versions && meta.versions[version];
332
592
  if (!versionData) continue;
333
593
 
@@ -358,14 +618,21 @@ async function resolveFromPackageJson(cwd, config, noDev) {
358
618
  * @returns {Promise<PackageDescriptor[]>}
359
619
  */
360
620
  async function resolveSinglePackage(packageSpec, config) {
361
- const [name, version] = packageSpec.includes('@') && !packageSpec.startsWith('@')
362
- ? packageSpec.split('@')
363
- : [packageSpec, 'latest'];
621
+ let name, version;
622
+ const lastAt = packageSpec.lastIndexOf('@');
623
+ if (lastAt > 0) {
624
+ name = packageSpec.slice(0, lastAt);
625
+ version = packageSpec.slice(lastAt + 1);
626
+ } else {
627
+ name = packageSpec;
628
+ version = 'latest';
629
+ }
364
630
 
365
631
  const { fetchJSON } = require('../utils/fetcher');
366
632
  let meta;
367
633
  try {
368
- meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
634
+ const encodedName = name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
635
+ meta = await fetchJSON(`${config.registry}/${encodedName}`, { timeout: config.timeout });
369
636
  } catch (err) {
370
637
  throw new Error(`Could not fetch registry metadata for "${name}": ${err.message}`);
371
638
  }