muaddib-scanner 2.2.28 → 2.3.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/bin/muaddib.js +6 -1
- package/package.json +1 -1
- package/src/index.js +15 -1
- package/src/scanner/ast-detectors.js +13 -20
- package/src/scanner/ast.js +31 -1
- package/src/scanner/module-graph.js +886 -883
- package/src/scanner/reachability.js +329 -0
- package/src/scanner/typosquat.js +535 -511
- package/src/scoring.js +253 -213
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reachability analysis — determines which files are reachable from
|
|
3
|
+
* package entry points (main, bin, exports, browser, module, lifecycle scripts).
|
|
4
|
+
* Files not reachable from any entry point are likely tests, examples, or
|
|
5
|
+
* internal utilities shipped in tarballs but never executed at install time.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists } = require('./module-graph.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively extract file paths from the package.json `exports` field.
|
|
13
|
+
* Handles: string shorthand, condition objects, nested subpath objects, arrays.
|
|
14
|
+
* @param {*} exportsField
|
|
15
|
+
* @returns {string[]}
|
|
16
|
+
*/
|
|
17
|
+
function extractExportsPaths(exportsField) {
|
|
18
|
+
if (!exportsField) return [];
|
|
19
|
+
|
|
20
|
+
// String shorthand: "exports": "./index.js"
|
|
21
|
+
if (typeof exportsField === 'string') {
|
|
22
|
+
return isLocalPath(exportsField) ? [exportsField] : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Array form: ["./a.js", "./b.js"]
|
|
26
|
+
if (Array.isArray(exportsField)) {
|
|
27
|
+
const paths = [];
|
|
28
|
+
for (const item of exportsField) {
|
|
29
|
+
paths.push(...extractExportsPaths(item));
|
|
30
|
+
}
|
|
31
|
+
return paths;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Object form — could be condition keys or subpath keys
|
|
35
|
+
if (typeof exportsField === 'object') {
|
|
36
|
+
const paths = [];
|
|
37
|
+
for (const value of Object.values(exportsField)) {
|
|
38
|
+
paths.push(...extractExportsPaths(value));
|
|
39
|
+
}
|
|
40
|
+
return paths;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract .js/.mjs/.cjs file paths referenced in lifecycle script commands.
|
|
48
|
+
* Matches patterns like: node scripts/install.js, node ./lib/post.mjs
|
|
49
|
+
* @param {string} scriptCmd
|
|
50
|
+
* @returns {string[]}
|
|
51
|
+
*/
|
|
52
|
+
function extractScriptJsFiles(scriptCmd) {
|
|
53
|
+
if (!scriptCmd || typeof scriptCmd !== 'string') return [];
|
|
54
|
+
const matches = [];
|
|
55
|
+
// Match: node <path>.js/mjs/cjs (but not node -e '...')
|
|
56
|
+
const re = /\bnode\s+(?!-[a-z])([\w./_-]+\.(?:js|mjs|cjs))\b/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(scriptCmd)) !== null) {
|
|
59
|
+
matches.push(m[1]);
|
|
60
|
+
}
|
|
61
|
+
return matches;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a path is local (starts with ./ or ../ or has no protocol).
|
|
66
|
+
*/
|
|
67
|
+
function isLocalPath(p) {
|
|
68
|
+
if (typeof p !== 'string') return false;
|
|
69
|
+
if (p.startsWith('./') || p.startsWith('../')) return true;
|
|
70
|
+
// Bare path without protocol (e.g., "index.js", "src/main.js")
|
|
71
|
+
if (!p.includes('://') && !p.startsWith('#')) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve an entry point candidate against the filesystem.
|
|
77
|
+
* Tries: exact, .js, .mjs, .cjs, /index.js
|
|
78
|
+
* @param {string} candidate - Relative path from package root (e.g., "./src/index.js")
|
|
79
|
+
* @param {string} packagePath - Absolute path to package root
|
|
80
|
+
* @returns {string|null} Relative path (forward slashes) or null
|
|
81
|
+
*/
|
|
82
|
+
function resolveEntryPoint(candidate, packagePath) {
|
|
83
|
+
// Strip leading ./ for path.resolve
|
|
84
|
+
const clean = candidate.replace(/^\.\//, '');
|
|
85
|
+
const abs = path.resolve(packagePath, clean);
|
|
86
|
+
|
|
87
|
+
if (isFileExists(abs)) return toRel(abs, packagePath);
|
|
88
|
+
if (isFileExists(abs + '.js')) return toRel(abs + '.js', packagePath);
|
|
89
|
+
if (isFileExists(abs + '.mjs')) return toRel(abs + '.mjs', packagePath);
|
|
90
|
+
if (isFileExists(abs + '.cjs')) return toRel(abs + '.cjs', packagePath);
|
|
91
|
+
if (isFileExists(path.join(abs, 'index.js'))) return toRel(path.join(abs, 'index.js'), packagePath);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract entry points from package.json.
|
|
97
|
+
* Sources: main, bin, exports, browser, module, lifecycle scripts.
|
|
98
|
+
* @param {string} packagePath - Absolute path to package root
|
|
99
|
+
* @returns {string[]} Resolved relative file paths (forward slashes)
|
|
100
|
+
*/
|
|
101
|
+
function getEntryPoints(packagePath) {
|
|
102
|
+
const pkgJsonPath = path.join(packagePath, 'package.json');
|
|
103
|
+
let pkg;
|
|
104
|
+
try {
|
|
105
|
+
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidates = [];
|
|
111
|
+
|
|
112
|
+
// main
|
|
113
|
+
if (typeof pkg.main === 'string') {
|
|
114
|
+
candidates.push(pkg.main);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// bin (string or object)
|
|
118
|
+
if (typeof pkg.bin === 'string') {
|
|
119
|
+
candidates.push(pkg.bin);
|
|
120
|
+
} else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
121
|
+
for (const v of Object.values(pkg.bin)) {
|
|
122
|
+
if (typeof v === 'string') candidates.push(v);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// exports (recursive)
|
|
127
|
+
if (pkg.exports) {
|
|
128
|
+
candidates.push(...extractExportsPaths(pkg.exports));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// browser (string or object)
|
|
132
|
+
if (typeof pkg.browser === 'string') {
|
|
133
|
+
candidates.push(pkg.browser);
|
|
134
|
+
} else if (pkg.browser && typeof pkg.browser === 'object') {
|
|
135
|
+
for (const v of Object.values(pkg.browser)) {
|
|
136
|
+
if (typeof v === 'string') candidates.push(v);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// module
|
|
141
|
+
if (typeof pkg.module === 'string') {
|
|
142
|
+
candidates.push(pkg.module);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Lifecycle scripts: extract .js files from preinstall/install/postinstall/prepare
|
|
146
|
+
const lifecycleKeys = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
147
|
+
if (pkg.scripts) {
|
|
148
|
+
for (const key of lifecycleKeys) {
|
|
149
|
+
if (typeof pkg.scripts[key] === 'string') {
|
|
150
|
+
candidates.push(...extractScriptJsFiles(pkg.scripts[key]));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolve candidates against filesystem
|
|
156
|
+
const resolved = new Set();
|
|
157
|
+
for (const c of candidates) {
|
|
158
|
+
if (!c || typeof c !== 'string') continue;
|
|
159
|
+
const r = resolveEntryPoint(c, packagePath);
|
|
160
|
+
if (r) resolved.add(r);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Default fallback: try index.js
|
|
164
|
+
if (resolved.size === 0) {
|
|
165
|
+
const r = resolveEntryPoint('index.js', packagePath);
|
|
166
|
+
if (r) resolved.add(r);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [...resolved];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract local .js file targets from child_process spawn/fork/execFile calls.
|
|
174
|
+
* Handles patterns like:
|
|
175
|
+
* fork('./worker.js')
|
|
176
|
+
* spawn('node', [path.join(__dirname, 'worker.js')])
|
|
177
|
+
* spawn(process.execPath, ['./stealer.js'])
|
|
178
|
+
* @param {string} absFile - Absolute path to the file to parse
|
|
179
|
+
* @param {string} packagePath - Package root
|
|
180
|
+
* @returns {string[]} Resolved relative paths of spawn targets
|
|
181
|
+
*/
|
|
182
|
+
function extractSpawnTargets(absFile, packagePath) {
|
|
183
|
+
const ast = parseFile(absFile);
|
|
184
|
+
if (!ast) return [];
|
|
185
|
+
|
|
186
|
+
const fileDir = path.dirname(absFile);
|
|
187
|
+
const targets = [];
|
|
188
|
+
|
|
189
|
+
walkForSpawnTargets(ast, fileDir, packagePath, targets);
|
|
190
|
+
return [...new Set(targets)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function walkForSpawnTargets(node, fileDir, packagePath, targets) {
|
|
194
|
+
if (!node || typeof node !== 'object') return;
|
|
195
|
+
|
|
196
|
+
if (node.type === 'CallExpression' && node.callee) {
|
|
197
|
+
const name = getSpawnCalleeName(node.callee);
|
|
198
|
+
|
|
199
|
+
if (name === 'fork' && node.arguments.length >= 1) {
|
|
200
|
+
// child_process.fork(modulePath) — first arg is a .js file
|
|
201
|
+
const target = resolvePathArg(node.arguments[0], fileDir, packagePath);
|
|
202
|
+
if (target) targets.push(target);
|
|
203
|
+
} else if ((name === 'spawn' || name === 'execFile') && node.arguments.length >= 2) {
|
|
204
|
+
// spawn('node', [filePath]) or spawn(process.execPath, [filePath])
|
|
205
|
+
const argsNode = node.arguments[1];
|
|
206
|
+
if (argsNode && argsNode.type === 'ArrayExpression' && argsNode.elements.length >= 1) {
|
|
207
|
+
const target = resolvePathArg(argsNode.elements[0], fileDir, packagePath);
|
|
208
|
+
if (target) targets.push(target);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const key of Object.keys(node)) {
|
|
214
|
+
if (key === 'type') continue;
|
|
215
|
+
const child = node[key];
|
|
216
|
+
if (Array.isArray(child)) {
|
|
217
|
+
for (const item of child) {
|
|
218
|
+
if (item && typeof item === 'object') walkForSpawnTargets(item, fileDir, packagePath, targets);
|
|
219
|
+
}
|
|
220
|
+
} else if (child && typeof child === 'object') {
|
|
221
|
+
walkForSpawnTargets(child, fileDir, packagePath, targets);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get the function name from a callee node (spawn, fork, cp.spawn, child_process.fork, etc.)
|
|
228
|
+
*/
|
|
229
|
+
function getSpawnCalleeName(callee) {
|
|
230
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
231
|
+
if (callee.type === 'MemberExpression' && callee.property) {
|
|
232
|
+
return callee.property.name || callee.property.value || '';
|
|
233
|
+
}
|
|
234
|
+
return '';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolve a path argument from AST node to a relative file path.
|
|
239
|
+
* Handles: string literals, path.join(__dirname, 'file.js'), template literals.
|
|
240
|
+
*/
|
|
241
|
+
function resolvePathArg(argNode, fileDir, packagePath) {
|
|
242
|
+
if (!argNode) return null;
|
|
243
|
+
|
|
244
|
+
// Simple string literal: './worker.js' or 'worker.js'
|
|
245
|
+
if (argNode.type === 'Literal' && typeof argNode.value === 'string') {
|
|
246
|
+
const val = argNode.value;
|
|
247
|
+
if (val.endsWith('.js') || val.endsWith('.mjs') || val.endsWith('.cjs')) {
|
|
248
|
+
return resolveLocal(fileDir, val.startsWith('.') ? val : './' + val, packagePath);
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// path.join(__dirname, 'worker.js') pattern
|
|
254
|
+
if (argNode.type === 'CallExpression' && argNode.callee &&
|
|
255
|
+
argNode.callee.type === 'MemberExpression' &&
|
|
256
|
+
argNode.callee.object && argNode.callee.object.name === 'path' &&
|
|
257
|
+
argNode.callee.property && argNode.callee.property.name === 'join') {
|
|
258
|
+
// Look for __dirname as first arg + string literals for the rest
|
|
259
|
+
const args = argNode.arguments;
|
|
260
|
+
if (args.length >= 2 && args[0].type === 'Identifier' && args[0].name === '__dirname') {
|
|
261
|
+
const parts = [];
|
|
262
|
+
for (let i = 1; i < args.length; i++) {
|
|
263
|
+
if (args[i].type === 'Literal' && typeof args[i].value === 'string') {
|
|
264
|
+
parts.push(args[i].value);
|
|
265
|
+
} else {
|
|
266
|
+
return null; // Can't resolve dynamic parts
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const relPath = './' + parts.join('/');
|
|
270
|
+
return resolveLocal(fileDir, relPath, packagePath);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* BFS traversal from entry points through local imports and spawn targets.
|
|
279
|
+
* @param {string} packagePath - Absolute path to package root
|
|
280
|
+
* @returns {{ reachableFiles: Set<string>, entryPoints: string[], skipped: boolean }}
|
|
281
|
+
*/
|
|
282
|
+
function computeReachableFiles(packagePath) {
|
|
283
|
+
const entryPoints = getEntryPoints(packagePath);
|
|
284
|
+
|
|
285
|
+
if (entryPoints.length === 0) {
|
|
286
|
+
return { reachableFiles: new Set(), entryPoints: [], skipped: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const reachable = new Set();
|
|
290
|
+
const queue = [...entryPoints];
|
|
291
|
+
|
|
292
|
+
// Seed with entry points
|
|
293
|
+
for (const ep of entryPoints) {
|
|
294
|
+
reachable.add(ep);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
while (queue.length > 0) {
|
|
298
|
+
const relFile = queue.shift();
|
|
299
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
300
|
+
|
|
301
|
+
// Follow require/import edges
|
|
302
|
+
let imports;
|
|
303
|
+
try {
|
|
304
|
+
imports = extractLocalImports(absFile, packagePath);
|
|
305
|
+
} catch {
|
|
306
|
+
imports = [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Follow child_process spawn/fork targets
|
|
310
|
+
let spawnTargets;
|
|
311
|
+
try {
|
|
312
|
+
spawnTargets = extractSpawnTargets(absFile, packagePath);
|
|
313
|
+
} catch {
|
|
314
|
+
spawnTargets = [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const allTargets = [...imports, ...spawnTargets];
|
|
318
|
+
for (const target of allTargets) {
|
|
319
|
+
if (!reachable.has(target)) {
|
|
320
|
+
reachable.add(target);
|
|
321
|
+
queue.push(target);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { reachableFiles: reachable, entryPoints, skipped: false };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = { computeReachableFiles, getEntryPoints, extractExportsPaths, extractScriptJsFiles };
|