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,700 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { parseLockfile } = require('../utils/lockfile');
|
|
6
|
+
const { fetchTarball, buildTarballUrl, verifyIntegrity } = require('../utils/fetcher');
|
|
7
|
+
const { parseTarGz, extractFile, getPackageJson } = require('../utils/tarball');
|
|
8
|
+
const { detectObfuscation } = require('./detector');
|
|
9
|
+
const { walkRequires, MAX_FILES_PER_PACKAGE, MAX_TOTAL_BYTES } = require('./requireWalker');
|
|
10
|
+
const { parseCommand } = require('../utils/command');
|
|
11
|
+
const output = require('../utils/output');
|
|
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
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Main scan orchestrator.
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {string} opts.cwd
|
|
33
|
+
* @param {object} opts.config
|
|
34
|
+
* @param {boolean} opts.noDev
|
|
35
|
+
* @param {boolean} opts.verbose
|
|
36
|
+
* @param {string|null} opts.singlePackage name for single-package mode (deprecated, use packages)
|
|
37
|
+
* @param {string[]|null} opts.packages package names to scan
|
|
38
|
+
* @returns {Promise<ScanResult[]>}
|
|
39
|
+
*/
|
|
40
|
+
async function scan(opts) {
|
|
41
|
+
const { cwd, config, noDev, verbose, singlePackage, packages: packageList } = opts;
|
|
42
|
+
|
|
43
|
+
let packages;
|
|
44
|
+
let lockfileVersion = 1;
|
|
45
|
+
let explicitPackageNames = new Set();
|
|
46
|
+
|
|
47
|
+
// Support both single package (legacy) and multiple packages
|
|
48
|
+
const targetPackages = packageList || (singlePackage ? [singlePackage] : null);
|
|
49
|
+
|
|
50
|
+
if (targetPackages && targetPackages.length > 0) {
|
|
51
|
+
// Scan specific packages from registry
|
|
52
|
+
const allPackages = [];
|
|
53
|
+
for (const pkg of targetPackages) {
|
|
54
|
+
const resolved = await resolveSinglePackage(pkg, config);
|
|
55
|
+
// Mark the first package (the explicitly requested one) as explicit
|
|
56
|
+
if (resolved.length > 0) {
|
|
57
|
+
const pkgName = pkg.includes('@') && !pkg.startsWith('@') ? pkg.split('@')[0] : pkg;
|
|
58
|
+
explicitPackageNames.add(pkgName);
|
|
59
|
+
}
|
|
60
|
+
allPackages.push(...resolved);
|
|
61
|
+
}
|
|
62
|
+
packages = allPackages;
|
|
63
|
+
} else {
|
|
64
|
+
const lockPath = path.join(cwd, 'package-lock.json');
|
|
65
|
+
if (fs.existsSync(lockPath)) {
|
|
66
|
+
const parsed = parseLockfile(cwd);
|
|
67
|
+
packages = parsed.packages;
|
|
68
|
+
lockfileVersion = parsed.lockfileVersion;
|
|
69
|
+
} else {
|
|
70
|
+
// No lockfile — resolve from package.json
|
|
71
|
+
packages = await resolveFromPackageJson(cwd, config, noDev);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Track packages without install scripts (for skipped count)
|
|
76
|
+
let skippedCount = 0;
|
|
77
|
+
|
|
78
|
+
// Apply skip filters
|
|
79
|
+
packages = packages.filter(pkg => {
|
|
80
|
+
if (noDev && pkg.dev) return false;
|
|
81
|
+
if (pkg.inBundle || pkg.link) return false;
|
|
82
|
+
if (config.skipPackages && config.skipPackages.includes(pkg.name)) return false;
|
|
83
|
+
if (config.skipScopes) {
|
|
84
|
+
for (const scope of config.skipScopes) {
|
|
85
|
+
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// For explicit packages, always include them but track if they have no scripts
|
|
89
|
+
if (explicitPackageNames.has(pkg.name)) {
|
|
90
|
+
if (!pkg.hasInstallScript) {
|
|
91
|
+
skippedCount++;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
// v2/v3 lockfiles reliably report hasInstallScript — skip definitive negatives
|
|
97
|
+
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (verbose) output.info(`Scanning ${packages.length} packages...`);
|
|
102
|
+
|
|
103
|
+
// Parallel fetch + scan with concurrency limit
|
|
104
|
+
const results = await mapWithConcurrency(packages, config.parallelFetches, async (pkg) => {
|
|
105
|
+
return scanPackage(pkg, cwd, config, verbose);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const scanned = results.filter(Boolean);
|
|
109
|
+
// Add packages that returned null from scanPackage (no scripts found during scan)
|
|
110
|
+
skippedCount += results.filter(r => r === null).length;
|
|
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
|
+
|
|
123
|
+
// Attach metadata to results array
|
|
124
|
+
scanned.skippedCount = skippedCount;
|
|
125
|
+
return scanned;
|
|
126
|
+
}
|
|
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
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Scan a single package for obfuscated install scripts.
|
|
209
|
+
* @returns {ScanResult|null} null if no install scripts found
|
|
210
|
+
*/
|
|
211
|
+
async function scanPackage(pkg, cwd, config, verbose) {
|
|
212
|
+
let pkgJson = null;
|
|
213
|
+
let source = 'registry';
|
|
214
|
+
|
|
215
|
+
// Try local node_modules first
|
|
216
|
+
const localPkgJson = tryReadLocalPackageJson(cwd, pkg);
|
|
217
|
+
if (localPkgJson) {
|
|
218
|
+
pkgJson = localPkgJson;
|
|
219
|
+
source = 'local';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// If v2/v3 lockfile says no install script, skip unless we couldn't confirm locally
|
|
223
|
+
if (source === 'local' && !hasInstallScripts(pkgJson)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!pkgJson) {
|
|
228
|
+
// v1 lockfile or package not installed — need to fetch
|
|
229
|
+
if (!pkg.resolved && !pkg.version) return null;
|
|
230
|
+
|
|
231
|
+
const tarballUrl = pkg.resolved || buildTarballUrl(pkg.name, pkg.version, config.registry);
|
|
232
|
+
|
|
233
|
+
let tarBuffer;
|
|
234
|
+
try {
|
|
235
|
+
if (verbose) output.info(`Fetching ${pkg.name}@${pkg.version}...`);
|
|
236
|
+
tarBuffer = await fetchTarball(tarballUrl, { timeout: config.timeout });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
output.warn(`Could not fetch ${pkg.name}@${pkg.version}: ${err.message}`);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!verifyIntegrity(tarBuffer, pkg.integrity)) {
|
|
243
|
+
output.warn(`Integrity check failed for ${pkg.name}@${pkg.version} — skipping`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let files;
|
|
248
|
+
try {
|
|
249
|
+
files = parseTarGz(tarBuffer, config.maxTarballSize);
|
|
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
|
+
}
|
|
261
|
+
output.warn(`Could not parse tarball for ${pkg.name}@${pkg.version}: ${err.message}`);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
pkgJson = getPackageJson(files);
|
|
266
|
+
if (!pkgJson) return null;
|
|
267
|
+
|
|
268
|
+
if (!hasInstallScripts(pkgJson)) return null;
|
|
269
|
+
|
|
270
|
+
// Analyze script files from tarball
|
|
271
|
+
return analyzeScripts(pkg, pkgJson, files, config);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Analyze from local node_modules
|
|
275
|
+
return analyzeScriptsLocal(pkg, pkgJson, cwd, config);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
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
|
|
289
|
+
*/
|
|
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
|
+
}
|
|
307
|
+
|
|
308
|
+
// Run detection on every walked file and aggregate.
|
|
309
|
+
const findings = [];
|
|
310
|
+
let maxScore = 0;
|
|
311
|
+
let entryCode = '';
|
|
312
|
+
|
|
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
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
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
|
+
}
|
|
338
|
+
|
|
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;
|
|
348
|
+
}
|
|
349
|
+
|
|
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
|
+
}
|
|
360
|
+
|
|
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
|
+
}
|
|
371
|
+
|
|
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
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Build a local-filesystem readFile callback rooted at the package dir.
|
|
400
|
+
*/
|
|
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) {
|
|
421
|
+
const scripts = getInstallScripts(pkgJson);
|
|
422
|
+
if (scripts.length === 0) return null;
|
|
423
|
+
|
|
424
|
+
const reader = makeTarballReader(files);
|
|
425
|
+
const scriptResults = [];
|
|
426
|
+
|
|
427
|
+
for (const { lifecycle, command } of scripts) {
|
|
428
|
+
const refs = parseCommand(command);
|
|
429
|
+
if (refs.length === 0) {
|
|
430
|
+
const result = detectObfuscation(command, config);
|
|
431
|
+
scriptResults.push({ lifecycle, file: '(inline)', code: command, ...result });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
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
|
+
}
|
|
441
|
+
|
|
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
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (scriptResults.length === 0) return null;
|
|
471
|
+
return summarizeResults(pkg, scriptResults, config);
|
|
472
|
+
}
|
|
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) {
|
|
484
|
+
const maxScore = Math.max(...scriptResults.map(r => r.score));
|
|
485
|
+
const allFindings = scriptResults.flatMap(r => r.findings);
|
|
486
|
+
const verdict = verdictFromScore(maxScore, config);
|
|
487
|
+
return { pkg, scripts: scriptResults, score: maxScore, findings: allFindings, verdict };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
function hasInstallScripts(pkgJson) {
|
|
493
|
+
if (!pkgJson || !pkgJson.scripts) return false;
|
|
494
|
+
return LIFECYCLE_SCRIPTS.some(lc => pkgJson.scripts[lc]);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function getInstallScripts(pkgJson) {
|
|
498
|
+
const result = [];
|
|
499
|
+
const s = pkgJson && pkgJson.scripts || {};
|
|
500
|
+
for (const lc of LIFECYCLE_SCRIPTS) {
|
|
501
|
+
if (s[lc]) result.push({ lifecycle: lc, command: s[lc] });
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
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.
|
|
514
|
+
*/
|
|
515
|
+
function extractScriptFileFromCommand(command) {
|
|
516
|
+
const refs = parseCommand(command);
|
|
517
|
+
const fileRef = refs.find(r => r.kind === 'file' && r.interpreter === 'node');
|
|
518
|
+
return fileRef ? fileRef.path : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function tryReadLocalPackageJson(cwd, pkg) {
|
|
522
|
+
const dir = findLocalPackageDir(cwd, pkg.name);
|
|
523
|
+
if (!dir) return null;
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function findLocalPackageDir(cwd, name) {
|
|
532
|
+
const candidate = path.join(cwd, 'node_modules', name);
|
|
533
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function verdictFromScore(score, config) {
|
|
538
|
+
if (score >= config.blockScore) return 'BLOCK';
|
|
539
|
+
if (score >= config.warnScore) return 'WARN';
|
|
540
|
+
return 'OK';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Extract the first clean X.Y.Z (or X.Y or X) semver from a range string.
|
|
545
|
+
* Returns null if no clean version can be found.
|
|
546
|
+
* Examples: "^5.1.0" → "5.1.0", "4.22.1 || ^5" → "4.22.1", "2" → "2", "*" → null
|
|
547
|
+
*/
|
|
548
|
+
function extractSemver(range) {
|
|
549
|
+
const match = range.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?|\d+\.\d+|\d+)(?!\S*-)/);
|
|
550
|
+
if (match) return match[1];
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Resolve dependencies from package.json when no lockfile exists.
|
|
556
|
+
* @param {string} cwd
|
|
557
|
+
* @param {object} config
|
|
558
|
+
* @param {boolean} noDev
|
|
559
|
+
* @returns {Promise<PackageDescriptor[]>}
|
|
560
|
+
*/
|
|
561
|
+
async function resolveFromPackageJson(cwd, config, noDev) {
|
|
562
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
563
|
+
if (!fs.existsSync(pkgPath)) {
|
|
564
|
+
// No package.json — nothing to scan (e.g. empty directory)
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let pkgJson;
|
|
569
|
+
try {
|
|
570
|
+
pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
571
|
+
} catch (err) {
|
|
572
|
+
throw new Error(`Failed to parse package.json: ${err.message}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const deps = { ...pkgJson.dependencies };
|
|
576
|
+
if (!noDev && pkgJson.devDependencies) {
|
|
577
|
+
Object.assign(deps, pkgJson.devDependencies);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const packages = [];
|
|
581
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
582
|
+
|
|
583
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
584
|
+
const version = extractSemver(range);
|
|
585
|
+
if (!version) continue;
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
589
|
+
const versionData = meta.versions && meta.versions[version];
|
|
590
|
+
if (!versionData) continue;
|
|
591
|
+
|
|
592
|
+
packages.push({
|
|
593
|
+
name,
|
|
594
|
+
version,
|
|
595
|
+
resolved: versionData.dist && versionData.dist.tarball,
|
|
596
|
+
integrity: versionData.dist && versionData.dist.integrity || '',
|
|
597
|
+
hasInstallScript: !!(versionData.scripts &&
|
|
598
|
+
(versionData.scripts.preinstall || versionData.scripts.postinstall || versionData.scripts.install)),
|
|
599
|
+
dev: !!(pkgJson.devDependencies && pkgJson.devDependencies[name]),
|
|
600
|
+
optional: false,
|
|
601
|
+
inBundle: false,
|
|
602
|
+
link: false,
|
|
603
|
+
});
|
|
604
|
+
} catch {
|
|
605
|
+
// Skip packages we can't fetch metadata for
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return packages;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Resolve a single package's dependency tree via the npm registry.
|
|
614
|
+
* @param {string} packageSpec e.g. "express" or "express@4.18.0"
|
|
615
|
+
* @param {object} config
|
|
616
|
+
* @returns {Promise<PackageDescriptor[]>}
|
|
617
|
+
*/
|
|
618
|
+
async function resolveSinglePackage(packageSpec, config) {
|
|
619
|
+
const [name, version] = packageSpec.includes('@') && !packageSpec.startsWith('@')
|
|
620
|
+
? packageSpec.split('@')
|
|
621
|
+
: [packageSpec, 'latest'];
|
|
622
|
+
|
|
623
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
624
|
+
let meta;
|
|
625
|
+
try {
|
|
626
|
+
meta = await fetchJSON(`${config.registry}/${encodeURIComponent(name)}`, { timeout: config.timeout });
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new Error(`Could not fetch registry metadata for "${name}": ${err.message}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const resolvedVersion = version === 'latest'
|
|
632
|
+
? (meta['dist-tags'] && meta['dist-tags'].latest)
|
|
633
|
+
: version;
|
|
634
|
+
|
|
635
|
+
const versionData = meta.versions && meta.versions[resolvedVersion];
|
|
636
|
+
if (!versionData) throw new Error(`Version "${resolvedVersion}" not found for "${name}"`);
|
|
637
|
+
|
|
638
|
+
const packages = [];
|
|
639
|
+
const seen = new Set();
|
|
640
|
+
|
|
641
|
+
function collectDeps(deps) {
|
|
642
|
+
for (const [depName, range] of Object.entries(deps || {})) {
|
|
643
|
+
if (seen.has(depName)) continue;
|
|
644
|
+
seen.add(depName);
|
|
645
|
+
// Extract the first clean semver from the range (e.g. "4.22.1 || ^5" → "4.22.1", "^5.1.0" → "5.1.0")
|
|
646
|
+
const exactVersion = extractSemver(range);
|
|
647
|
+
if (!exactVersion) continue; // skip unresolvable ranges — lockfile scan will cover them
|
|
648
|
+
packages.push({
|
|
649
|
+
name: depName,
|
|
650
|
+
version: exactVersion,
|
|
651
|
+
resolved: buildTarballUrl(depName, exactVersion, config.registry),
|
|
652
|
+
integrity: '',
|
|
653
|
+
hasInstallScript: false,
|
|
654
|
+
dev: false,
|
|
655
|
+
optional: false,
|
|
656
|
+
inBundle: false,
|
|
657
|
+
link: false,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Include the package itself
|
|
663
|
+
packages.unshift({
|
|
664
|
+
name,
|
|
665
|
+
version: resolvedVersion,
|
|
666
|
+
resolved: versionData.dist && versionData.dist.tarball,
|
|
667
|
+
integrity: versionData.dist && versionData.dist.integrity || '',
|
|
668
|
+
hasInstallScript: !!(versionData.scripts &&
|
|
669
|
+
(versionData.scripts.preinstall || versionData.scripts.postinstall || versionData.scripts.install)),
|
|
670
|
+
dev: false,
|
|
671
|
+
optional: false,
|
|
672
|
+
inBundle: false,
|
|
673
|
+
link: false,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
collectDeps(versionData.dependencies);
|
|
677
|
+
|
|
678
|
+
return packages;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Async map with concurrency limit.
|
|
683
|
+
*/
|
|
684
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
685
|
+
const results = new Array(items.length);
|
|
686
|
+
let index = 0;
|
|
687
|
+
|
|
688
|
+
async function worker() {
|
|
689
|
+
while (index < items.length) {
|
|
690
|
+
const i = index++;
|
|
691
|
+
results[i] = await fn(items[i]);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
|
|
696
|
+
await Promise.all(workers);
|
|
697
|
+
return results;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
module.exports = { scan, hasInstallScripts, extractScriptFileFromCommand, verdictFromScore };
|