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.
- package/README.md +82 -54
- package/package.json +1 -1
- package/src/cli.js +15 -0
- package/src/core/detector.js +181 -37
- package/src/core/requireWalker.js +192 -0
- package/src/core/scanner.js +321 -54
- package/src/utils/command.js +256 -0
- package/src/utils/config.js +31 -2
- package/src/utils/tarball.js +7 -1
- package/src/utils/updateChecker.js +72 -0
|
@@ -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
|
+
};
|
package/src/core/scanner.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
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
|
-
|
|
309
|
+
// Run detection on every walked file and aggregate.
|
|
310
|
+
const findings = [];
|
|
311
|
+
let maxScore = 0;
|
|
312
|
+
let entryCode = '';
|
|
168
313
|
|
|
169
|
-
for (const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
400
|
+
* Build a local-filesystem readFile callback rooted at the package dir.
|
|
197
401
|
*/
|
|
198
|
-
function
|
|
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
|
|
425
|
+
const reader = makeTarballReader(files);
|
|
203
426
|
const scriptResults = [];
|
|
204
427
|
|
|
205
428
|
for (const { lifecycle, command } of scripts) {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
|
250
|
-
*
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
}
|