muaddib-scanner 2.4.3 → 2.4.5
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 +20 -20
- package/README.md +15 -1
- package/iocs/builtin.yaml +131 -131
- package/iocs/hashes.yaml +214 -214
- package/iocs/packages.yaml +276 -276
- package/package.json +2 -3
- package/src/canary-tokens.js +184 -184
- package/src/ioc/bootstrap.js +181 -181
- package/src/ioc/yaml-loader.js +223 -223
- package/src/maintainer-change.js +224 -224
- package/src/output-formatter.js +192 -192
- package/src/publish-anomaly.js +206 -206
- package/src/report.js +230 -230
- package/src/sarif.js +96 -96
- package/src/scanner/ai-config.js +183 -183
- package/src/scanner/ast-detectors.js +40 -17
- package/src/scanner/ast.js +1 -0
- package/src/scanner/dataflow.js +14 -2
- package/src/scanner/dependencies.js +223 -223
- package/src/scanner/entropy.js +7 -0
- package/src/scanner/hash.js +118 -118
- package/src/scanner/npm-registry.js +128 -128
- package/src/scanner/python.js +442 -442
- package/src/scoring.js +3 -1
- package/src/shared/analyze-helper.js +49 -49
- package/src/temporal-analysis.js +260 -260
- package/src/temporal-runner.js +139 -139
- package/src/utils.js +327 -327
- package/src/watch.js +55 -55
package/src/utils.js
CHANGED
|
@@ -1,327 +1,327 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Directories excluded from scanning.
|
|
7
|
-
* Only skip dependency/VCS/cache dirs - never skip user source code.
|
|
8
|
-
*/
|
|
9
|
-
const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Extra directories to exclude (set at runtime via --exclude flag).
|
|
13
|
-
* Merged into every findFiles() call on top of the caller's excludedDirs.
|
|
14
|
-
*/
|
|
15
|
-
let _extraExcludedDirs = [];
|
|
16
|
-
let _scanRoot = '';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Memoization cache for findFiles(). Key = dir|extensions|excludedDirs.
|
|
20
|
-
* Cleared between scans via clearFileListCache().
|
|
21
|
-
*/
|
|
22
|
-
const _fileListCache = new Map();
|
|
23
|
-
|
|
24
|
-
function setExtraExcludes(dirs, scanRoot) {
|
|
25
|
-
_extraExcludedDirs = Array.isArray(dirs) ? dirs : [];
|
|
26
|
-
_scanRoot = scanRoot || '';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getExtraExcludes() {
|
|
30
|
-
return _extraExcludedDirs;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Patterns to identify dev/test files
|
|
35
|
-
*/
|
|
36
|
-
const DEV_PATTERNS = [
|
|
37
|
-
/^scripts\//,
|
|
38
|
-
/^bin\//,
|
|
39
|
-
/^tools\//,
|
|
40
|
-
/^build\//,
|
|
41
|
-
/^fixtures\//,
|
|
42
|
-
/^examples\//,
|
|
43
|
-
/^__tests__\//,
|
|
44
|
-
/^__mocks__\//,
|
|
45
|
-
/^benchmark/,
|
|
46
|
-
/^docs?\//,
|
|
47
|
-
/^compiler\//,
|
|
48
|
-
/^packages\/.*\/scripts\//,
|
|
49
|
-
/\.test\.js$/,
|
|
50
|
-
/\.spec\.js$/,
|
|
51
|
-
/test\.js$/,
|
|
52
|
-
/spec\.js$/
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Checks if a path corresponds to a dev/test file
|
|
57
|
-
* @param {string} relativePath - Relative path of the file
|
|
58
|
-
* @returns {boolean}
|
|
59
|
-
*/
|
|
60
|
-
function isDevFile(relativePath) {
|
|
61
|
-
return DEV_PATTERNS.some(pattern => pattern.test(relativePath));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Generic recursive file finder with symlink protection and depth limit.
|
|
66
|
-
* @param {string} dir - Starting directory
|
|
67
|
-
* @param {object} [options] - Options
|
|
68
|
-
* @param {string[]} [options.extensions=['.js']] - File extensions to match
|
|
69
|
-
* @param {string[]} [options.excludedDirs=EXCLUDED_DIRS] - Dirs to skip
|
|
70
|
-
* @param {number} [options.maxDepth=100] - Max recursion depth
|
|
71
|
-
* @param {string[]} [options.results=[]] - Accumulator (internal)
|
|
72
|
-
* @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection (note: inode tracking
|
|
73
|
-
* is unreliable on Windows where stat.ino may be 0; maxDepth serves as fallback protection)
|
|
74
|
-
* @param {number} [options.depth=0] - Current depth (internal)
|
|
75
|
-
* @returns {string[]} List of matching file paths
|
|
76
|
-
*/
|
|
77
|
-
function findFiles(dir, options = {}) {
|
|
78
|
-
const {
|
|
79
|
-
extensions = ['.js'],
|
|
80
|
-
excludedDirs = EXCLUDED_DIRS,
|
|
81
|
-
maxDepth = 100,
|
|
82
|
-
results = [],
|
|
83
|
-
visitedInodes = new Set(),
|
|
84
|
-
depth = 0
|
|
85
|
-
} = options;
|
|
86
|
-
|
|
87
|
-
// Top-level memoization: identical (dir, extensions, excludedDirs) → cached result
|
|
88
|
-
if (depth === 0) {
|
|
89
|
-
const cacheKey = dir + '|' + extensions.slice().sort().join(',') + '|' +
|
|
90
|
-
[...excludedDirs, ..._extraExcludedDirs].sort().join(',');
|
|
91
|
-
const cached = _fileListCache.get(cacheKey);
|
|
92
|
-
if (cached) return [...cached]; // return copy to prevent mutation
|
|
93
|
-
const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
94
|
-
_fileListCache.set(cacheKey, [...result]);
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth }) {
|
|
102
|
-
if (depth > maxDepth) return results;
|
|
103
|
-
if (!fs.existsSync(dir)) return results;
|
|
104
|
-
|
|
105
|
-
// Merge runtime --exclude dirs so every scanner respects them
|
|
106
|
-
const allExcludedDirs = _extraExcludedDirs.length > 0
|
|
107
|
-
? [...new Set([...excludedDirs, ..._extraExcludedDirs])]
|
|
108
|
-
: excludedDirs;
|
|
109
|
-
|
|
110
|
-
let items;
|
|
111
|
-
try {
|
|
112
|
-
items = fs.readdirSync(dir);
|
|
113
|
-
} catch {
|
|
114
|
-
return results;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
for (const item of items) {
|
|
118
|
-
const fullPath = path.join(dir, item);
|
|
119
|
-
|
|
120
|
-
// Check both bare name ("tests") and relative path ("src/scanner")
|
|
121
|
-
if (allExcludedDirs.includes(item)) continue;
|
|
122
|
-
if (_extraExcludedDirs.length > 0 && _scanRoot) {
|
|
123
|
-
const rel = path.relative(_scanRoot, fullPath).replace(/\\/g, '/');
|
|
124
|
-
if (_extraExcludedDirs.some(ex => rel === ex || rel.startsWith(ex + '/'))) continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const lstat = fs.lstatSync(fullPath);
|
|
129
|
-
|
|
130
|
-
// Symlink protection
|
|
131
|
-
if (lstat.isSymbolicLink()) {
|
|
132
|
-
try {
|
|
133
|
-
const realPath = fs.realpathSync(fullPath);
|
|
134
|
-
const realStat = fs.statSync(realPath);
|
|
135
|
-
if (realStat.ino !== 0 && visitedInodes.has(realStat.ino)) continue;
|
|
136
|
-
if (realStat.ino !== 0) visitedInodes.add(realStat.ino);
|
|
137
|
-
if (realStat.isDirectory()) {
|
|
138
|
-
_findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
139
|
-
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
140
|
-
results.push(realPath);
|
|
141
|
-
}
|
|
142
|
-
} catch {
|
|
143
|
-
// Broken symlink, skip
|
|
144
|
-
}
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (lstat.ino !== 0) visitedInodes.add(lstat.ino);
|
|
149
|
-
|
|
150
|
-
if (lstat.isDirectory()) {
|
|
151
|
-
_findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
152
|
-
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
153
|
-
results.push(fullPath);
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
// Ignore permission errors
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return results;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Recursively searches for JavaScript files (convenience wrapper)
|
|
165
|
-
* @param {string} dir - Starting directory
|
|
166
|
-
* @param {string[]} [results=[]] - Accumulator array (internal use)
|
|
167
|
-
* @returns {string[]} List of .js file paths
|
|
168
|
-
*/
|
|
169
|
-
function findJsFiles(dir, results = []) {
|
|
170
|
-
return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function clearFileListCache() {
|
|
174
|
-
_fileListCache.clear();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Escapes HTML characters to prevent XSS
|
|
179
|
-
* @param {string} str - String to escape
|
|
180
|
-
* @returns {string} Escaped string
|
|
181
|
-
*/
|
|
182
|
-
function escapeHtml(str) {
|
|
183
|
-
if (str === null || str === undefined) return '';
|
|
184
|
-
return String(str)
|
|
185
|
-
.replace(/&/g, '&')
|
|
186
|
-
.replace(/</g, '<')
|
|
187
|
-
.replace(/>/g, '>')
|
|
188
|
-
.replace(/"/g, '"')
|
|
189
|
-
.replace(/'/g, ''');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Extracts the function/method name from a CallExpression AST node
|
|
194
|
-
* @param {object} node - AST CallExpression node
|
|
195
|
-
* @returns {string} Function name or empty string
|
|
196
|
-
*/
|
|
197
|
-
function getCallName(node) {
|
|
198
|
-
if (node.callee.type === 'Identifier') {
|
|
199
|
-
return node.callee.name;
|
|
200
|
-
}
|
|
201
|
-
if (node.callee.type === 'MemberExpression' && node.callee.property) {
|
|
202
|
-
return node.callee.property.name;
|
|
203
|
-
}
|
|
204
|
-
return '';
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Minimal CLI spinner (npm/ora style, no external deps).
|
|
209
|
-
* Frames rotate every 100ms via setInterval.
|
|
210
|
-
* Uses ANSI escapes to clear/rewrite the current line.
|
|
211
|
-
*/
|
|
212
|
-
class Spinner {
|
|
213
|
-
constructor() {
|
|
214
|
-
this._frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
215
|
-
this._index = 0;
|
|
216
|
-
this._interval = null;
|
|
217
|
-
this._text = '';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
start(text) {
|
|
221
|
-
this._text = text;
|
|
222
|
-
this._index = 0;
|
|
223
|
-
if (this._interval) clearInterval(this._interval);
|
|
224
|
-
this._render();
|
|
225
|
-
this._interval = setInterval(() => this._render(), 100);
|
|
226
|
-
return this;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
update(text) {
|
|
230
|
-
this._text = text;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
succeed(text) {
|
|
234
|
-
this._stop();
|
|
235
|
-
process.stdout.write('\r\x1b[K\x1b[32m\u2713\x1b[0m ' + text + '\n');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
fail(text) {
|
|
239
|
-
this._stop();
|
|
240
|
-
process.stdout.write('\r\x1b[K\x1b[31m\u2717\x1b[0m ' + text + '\n');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
_render() {
|
|
244
|
-
const frame = this._frames[this._index % this._frames.length];
|
|
245
|
-
process.stdout.write('\r\x1b[K' + frame + ' ' + this._text);
|
|
246
|
-
this._index++;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
_stop() {
|
|
250
|
-
if (this._interval) {
|
|
251
|
-
clearInterval(this._interval);
|
|
252
|
-
this._interval = null;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Iterates files with size guard and error handling.
|
|
259
|
-
* Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
|
|
260
|
-
*/
|
|
261
|
-
function forEachSafeFile(files, callback) {
|
|
262
|
-
for (const file of files) {
|
|
263
|
-
try {
|
|
264
|
-
const stat = fs.statSync(file);
|
|
265
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
266
|
-
} catch { continue; }
|
|
267
|
-
let content;
|
|
268
|
-
try {
|
|
269
|
-
content = fs.readFileSync(file, 'utf8');
|
|
270
|
-
} catch { continue; }
|
|
271
|
-
callback(file, content);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Lists installed packages in node_modules (handles scoped packages).
|
|
277
|
-
* @param {string} targetPath - Root of the project
|
|
278
|
-
* @returns {string[]} Package names (e.g. ['express', '@babel/core'])
|
|
279
|
-
*/
|
|
280
|
-
function listInstalledPackages(targetPath) {
|
|
281
|
-
const nm = path.join(targetPath, 'node_modules');
|
|
282
|
-
if (!fs.existsSync(nm)) return [];
|
|
283
|
-
const names = [];
|
|
284
|
-
try {
|
|
285
|
-
for (const item of fs.readdirSync(nm)) {
|
|
286
|
-
if (item.startsWith('.')) continue;
|
|
287
|
-
const itemPath = path.join(nm, item);
|
|
288
|
-
try {
|
|
289
|
-
const stat = fs.lstatSync(itemPath);
|
|
290
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
291
|
-
if (item.startsWith('@')) {
|
|
292
|
-
for (const si of fs.readdirSync(itemPath)) {
|
|
293
|
-
const ss = fs.lstatSync(path.join(itemPath, si));
|
|
294
|
-
if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
|
|
295
|
-
}
|
|
296
|
-
} else {
|
|
297
|
-
names.push(item);
|
|
298
|
-
}
|
|
299
|
-
} catch { /* skip unreadable */ }
|
|
300
|
-
}
|
|
301
|
-
} catch { /* no node_modules readable */ }
|
|
302
|
-
return names;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
|
|
307
|
-
*/
|
|
308
|
-
function debugLog(...args) {
|
|
309
|
-
if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
module.exports = {
|
|
313
|
-
EXCLUDED_DIRS,
|
|
314
|
-
DEV_PATTERNS,
|
|
315
|
-
isDevFile,
|
|
316
|
-
findFiles,
|
|
317
|
-
findJsFiles,
|
|
318
|
-
clearFileListCache,
|
|
319
|
-
escapeHtml,
|
|
320
|
-
getCallName,
|
|
321
|
-
Spinner,
|
|
322
|
-
setExtraExcludes,
|
|
323
|
-
getExtraExcludes,
|
|
324
|
-
forEachSafeFile,
|
|
325
|
-
listInstalledPackages,
|
|
326
|
-
debugLog
|
|
327
|
-
};
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directories excluded from scanning.
|
|
7
|
+
* Only skip dependency/VCS/cache dirs - never skip user source code.
|
|
8
|
+
*/
|
|
9
|
+
const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extra directories to exclude (set at runtime via --exclude flag).
|
|
13
|
+
* Merged into every findFiles() call on top of the caller's excludedDirs.
|
|
14
|
+
*/
|
|
15
|
+
let _extraExcludedDirs = [];
|
|
16
|
+
let _scanRoot = '';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Memoization cache for findFiles(). Key = dir|extensions|excludedDirs.
|
|
20
|
+
* Cleared between scans via clearFileListCache().
|
|
21
|
+
*/
|
|
22
|
+
const _fileListCache = new Map();
|
|
23
|
+
|
|
24
|
+
function setExtraExcludes(dirs, scanRoot) {
|
|
25
|
+
_extraExcludedDirs = Array.isArray(dirs) ? dirs : [];
|
|
26
|
+
_scanRoot = scanRoot || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getExtraExcludes() {
|
|
30
|
+
return _extraExcludedDirs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Patterns to identify dev/test files
|
|
35
|
+
*/
|
|
36
|
+
const DEV_PATTERNS = [
|
|
37
|
+
/^scripts\//,
|
|
38
|
+
/^bin\//,
|
|
39
|
+
/^tools\//,
|
|
40
|
+
/^build\//,
|
|
41
|
+
/^fixtures\//,
|
|
42
|
+
/^examples\//,
|
|
43
|
+
/^__tests__\//,
|
|
44
|
+
/^__mocks__\//,
|
|
45
|
+
/^benchmark/,
|
|
46
|
+
/^docs?\//,
|
|
47
|
+
/^compiler\//,
|
|
48
|
+
/^packages\/.*\/scripts\//,
|
|
49
|
+
/\.test\.js$/,
|
|
50
|
+
/\.spec\.js$/,
|
|
51
|
+
/test\.js$/,
|
|
52
|
+
/spec\.js$/
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks if a path corresponds to a dev/test file
|
|
57
|
+
* @param {string} relativePath - Relative path of the file
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isDevFile(relativePath) {
|
|
61
|
+
return DEV_PATTERNS.some(pattern => pattern.test(relativePath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generic recursive file finder with symlink protection and depth limit.
|
|
66
|
+
* @param {string} dir - Starting directory
|
|
67
|
+
* @param {object} [options] - Options
|
|
68
|
+
* @param {string[]} [options.extensions=['.js']] - File extensions to match
|
|
69
|
+
* @param {string[]} [options.excludedDirs=EXCLUDED_DIRS] - Dirs to skip
|
|
70
|
+
* @param {number} [options.maxDepth=100] - Max recursion depth
|
|
71
|
+
* @param {string[]} [options.results=[]] - Accumulator (internal)
|
|
72
|
+
* @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection (note: inode tracking
|
|
73
|
+
* is unreliable on Windows where stat.ino may be 0; maxDepth serves as fallback protection)
|
|
74
|
+
* @param {number} [options.depth=0] - Current depth (internal)
|
|
75
|
+
* @returns {string[]} List of matching file paths
|
|
76
|
+
*/
|
|
77
|
+
function findFiles(dir, options = {}) {
|
|
78
|
+
const {
|
|
79
|
+
extensions = ['.js'],
|
|
80
|
+
excludedDirs = EXCLUDED_DIRS,
|
|
81
|
+
maxDepth = 100,
|
|
82
|
+
results = [],
|
|
83
|
+
visitedInodes = new Set(),
|
|
84
|
+
depth = 0
|
|
85
|
+
} = options;
|
|
86
|
+
|
|
87
|
+
// Top-level memoization: identical (dir, extensions, excludedDirs) → cached result
|
|
88
|
+
if (depth === 0) {
|
|
89
|
+
const cacheKey = dir + '|' + extensions.slice().sort().join(',') + '|' +
|
|
90
|
+
[...excludedDirs, ..._extraExcludedDirs].sort().join(',');
|
|
91
|
+
const cached = _fileListCache.get(cacheKey);
|
|
92
|
+
if (cached) return [...cached]; // return copy to prevent mutation
|
|
93
|
+
const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
94
|
+
_fileListCache.set(cacheKey, [...result]);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth }) {
|
|
102
|
+
if (depth > maxDepth) return results;
|
|
103
|
+
if (!fs.existsSync(dir)) return results;
|
|
104
|
+
|
|
105
|
+
// Merge runtime --exclude dirs so every scanner respects them
|
|
106
|
+
const allExcludedDirs = _extraExcludedDirs.length > 0
|
|
107
|
+
? [...new Set([...excludedDirs, ..._extraExcludedDirs])]
|
|
108
|
+
: excludedDirs;
|
|
109
|
+
|
|
110
|
+
let items;
|
|
111
|
+
try {
|
|
112
|
+
items = fs.readdirSync(dir);
|
|
113
|
+
} catch {
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const fullPath = path.join(dir, item);
|
|
119
|
+
|
|
120
|
+
// Check both bare name ("tests") and relative path ("src/scanner")
|
|
121
|
+
if (allExcludedDirs.includes(item)) continue;
|
|
122
|
+
if (_extraExcludedDirs.length > 0 && _scanRoot) {
|
|
123
|
+
const rel = path.relative(_scanRoot, fullPath).replace(/\\/g, '/');
|
|
124
|
+
if (_extraExcludedDirs.some(ex => rel === ex || rel.startsWith(ex + '/'))) continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const lstat = fs.lstatSync(fullPath);
|
|
129
|
+
|
|
130
|
+
// Symlink protection
|
|
131
|
+
if (lstat.isSymbolicLink()) {
|
|
132
|
+
try {
|
|
133
|
+
const realPath = fs.realpathSync(fullPath);
|
|
134
|
+
const realStat = fs.statSync(realPath);
|
|
135
|
+
if (realStat.ino !== 0 && visitedInodes.has(realStat.ino)) continue;
|
|
136
|
+
if (realStat.ino !== 0) visitedInodes.add(realStat.ino);
|
|
137
|
+
if (realStat.isDirectory()) {
|
|
138
|
+
_findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
139
|
+
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
140
|
+
results.push(realPath);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Broken symlink, skip
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (lstat.ino !== 0) visitedInodes.add(lstat.ino);
|
|
149
|
+
|
|
150
|
+
if (lstat.isDirectory()) {
|
|
151
|
+
_findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
|
|
152
|
+
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
153
|
+
results.push(fullPath);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore permission errors
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Recursively searches for JavaScript files (convenience wrapper)
|
|
165
|
+
* @param {string} dir - Starting directory
|
|
166
|
+
* @param {string[]} [results=[]] - Accumulator array (internal use)
|
|
167
|
+
* @returns {string[]} List of .js file paths
|
|
168
|
+
*/
|
|
169
|
+
function findJsFiles(dir, results = []) {
|
|
170
|
+
return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function clearFileListCache() {
|
|
174
|
+
_fileListCache.clear();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Escapes HTML characters to prevent XSS
|
|
179
|
+
* @param {string} str - String to escape
|
|
180
|
+
* @returns {string} Escaped string
|
|
181
|
+
*/
|
|
182
|
+
function escapeHtml(str) {
|
|
183
|
+
if (str === null || str === undefined) return '';
|
|
184
|
+
return String(str)
|
|
185
|
+
.replace(/&/g, '&')
|
|
186
|
+
.replace(/</g, '<')
|
|
187
|
+
.replace(/>/g, '>')
|
|
188
|
+
.replace(/"/g, '"')
|
|
189
|
+
.replace(/'/g, ''');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extracts the function/method name from a CallExpression AST node
|
|
194
|
+
* @param {object} node - AST CallExpression node
|
|
195
|
+
* @returns {string} Function name or empty string
|
|
196
|
+
*/
|
|
197
|
+
function getCallName(node) {
|
|
198
|
+
if (node.callee.type === 'Identifier') {
|
|
199
|
+
return node.callee.name;
|
|
200
|
+
}
|
|
201
|
+
if (node.callee.type === 'MemberExpression' && node.callee.property) {
|
|
202
|
+
return node.callee.property.name;
|
|
203
|
+
}
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Minimal CLI spinner (npm/ora style, no external deps).
|
|
209
|
+
* Frames rotate every 100ms via setInterval.
|
|
210
|
+
* Uses ANSI escapes to clear/rewrite the current line.
|
|
211
|
+
*/
|
|
212
|
+
class Spinner {
|
|
213
|
+
constructor() {
|
|
214
|
+
this._frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
215
|
+
this._index = 0;
|
|
216
|
+
this._interval = null;
|
|
217
|
+
this._text = '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
start(text) {
|
|
221
|
+
this._text = text;
|
|
222
|
+
this._index = 0;
|
|
223
|
+
if (this._interval) clearInterval(this._interval);
|
|
224
|
+
this._render();
|
|
225
|
+
this._interval = setInterval(() => this._render(), 100);
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
update(text) {
|
|
230
|
+
this._text = text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
succeed(text) {
|
|
234
|
+
this._stop();
|
|
235
|
+
process.stdout.write('\r\x1b[K\x1b[32m\u2713\x1b[0m ' + text + '\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fail(text) {
|
|
239
|
+
this._stop();
|
|
240
|
+
process.stdout.write('\r\x1b[K\x1b[31m\u2717\x1b[0m ' + text + '\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_render() {
|
|
244
|
+
const frame = this._frames[this._index % this._frames.length];
|
|
245
|
+
process.stdout.write('\r\x1b[K' + frame + ' ' + this._text);
|
|
246
|
+
this._index++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_stop() {
|
|
250
|
+
if (this._interval) {
|
|
251
|
+
clearInterval(this._interval);
|
|
252
|
+
this._interval = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Iterates files with size guard and error handling.
|
|
259
|
+
* Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
|
|
260
|
+
*/
|
|
261
|
+
function forEachSafeFile(files, callback) {
|
|
262
|
+
for (const file of files) {
|
|
263
|
+
try {
|
|
264
|
+
const stat = fs.statSync(file);
|
|
265
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
266
|
+
} catch { continue; }
|
|
267
|
+
let content;
|
|
268
|
+
try {
|
|
269
|
+
content = fs.readFileSync(file, 'utf8');
|
|
270
|
+
} catch { continue; }
|
|
271
|
+
callback(file, content);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Lists installed packages in node_modules (handles scoped packages).
|
|
277
|
+
* @param {string} targetPath - Root of the project
|
|
278
|
+
* @returns {string[]} Package names (e.g. ['express', '@babel/core'])
|
|
279
|
+
*/
|
|
280
|
+
function listInstalledPackages(targetPath) {
|
|
281
|
+
const nm = path.join(targetPath, 'node_modules');
|
|
282
|
+
if (!fs.existsSync(nm)) return [];
|
|
283
|
+
const names = [];
|
|
284
|
+
try {
|
|
285
|
+
for (const item of fs.readdirSync(nm)) {
|
|
286
|
+
if (item.startsWith('.')) continue;
|
|
287
|
+
const itemPath = path.join(nm, item);
|
|
288
|
+
try {
|
|
289
|
+
const stat = fs.lstatSync(itemPath);
|
|
290
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
291
|
+
if (item.startsWith('@')) {
|
|
292
|
+
for (const si of fs.readdirSync(itemPath)) {
|
|
293
|
+
const ss = fs.lstatSync(path.join(itemPath, si));
|
|
294
|
+
if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
names.push(item);
|
|
298
|
+
}
|
|
299
|
+
} catch { /* skip unreadable */ }
|
|
300
|
+
}
|
|
301
|
+
} catch { /* no node_modules readable */ }
|
|
302
|
+
return names;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
|
|
307
|
+
*/
|
|
308
|
+
function debugLog(...args) {
|
|
309
|
+
if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
EXCLUDED_DIRS,
|
|
314
|
+
DEV_PATTERNS,
|
|
315
|
+
isDevFile,
|
|
316
|
+
findFiles,
|
|
317
|
+
findJsFiles,
|
|
318
|
+
clearFileListCache,
|
|
319
|
+
escapeHtml,
|
|
320
|
+
getCallName,
|
|
321
|
+
Spinner,
|
|
322
|
+
setExtraExcludes,
|
|
323
|
+
getExtraExcludes,
|
|
324
|
+
forEachSafeFile,
|
|
325
|
+
listInstalledPackages,
|
|
326
|
+
debugLog
|
|
327
|
+
};
|