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.
- 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 +306 -48
- 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
|
|
@@ -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
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
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
|
-
|
|
308
|
+
// Run detection on every walked file and aggregate.
|
|
309
|
+
const findings = [];
|
|
310
|
+
let maxScore = 0;
|
|
311
|
+
let entryCode = '';
|
|
168
312
|
|
|
169
|
-
for (const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
399
|
+
* Build a local-filesystem readFile callback rooted at the package dir.
|
|
197
400
|
*/
|
|
198
|
-
function
|
|
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
|
|
424
|
+
const reader = makeTarballReader(files);
|
|
203
425
|
const scriptResults = [];
|
|
204
426
|
|
|
205
427
|
for (const { lifecycle, command } of scripts) {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
|
250
|
-
*
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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) {
|