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