muaddib-scanner 2.2.19 → 2.2.22
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/.gitattributes +18 -0
- package/README.fr.md +819 -825
- package/README.md +822 -822
- package/bin/muaddib.js +868 -849
- package/docker/Dockerfile +19 -18
- package/docker/sandbox-runner.sh +313 -292
- package/package.json +61 -61
- package/src/commands/evaluate.js +484 -484
- package/src/diff.js +411 -411
- package/src/hooks-init.js +264 -258
- package/src/index.js +439 -437
- package/src/ioc/scraper.js +1293 -1293
- package/src/monitor.js +1817 -1764
- package/src/sandbox.js +631 -620
- package/src/scanner/ast-detectors.js +946 -933
- package/src/scanner/ast.js +96 -96
- package/src/scanner/github-actions.js +96 -96
- package/src/scanner/module-graph.js +2 -2
- package/src/scanner/obfuscation.js +128 -128
- package/src/scanner/shell.js +48 -48
- package/src/shared/download.js +181 -171
- package/src/webhook.js +413 -353
package/src/index.js
CHANGED
|
@@ -1,438 +1,440 @@
|
|
|
1
|
-
const { scanPackageJson } = require('./scanner/package.js');
|
|
2
|
-
const { scanShellScripts } = require('./scanner/shell.js');
|
|
3
|
-
const { analyzeAST } = require('./scanner/ast.js');
|
|
4
|
-
const { detectObfuscation } = require('./scanner/obfuscation.js');
|
|
5
|
-
const { scanDependencies } = require('./scanner/dependencies.js');
|
|
6
|
-
const { scanHashes } = require('./scanner/hash.js');
|
|
7
|
-
const { analyzeDataFlow } = require('./scanner/dataflow.js');
|
|
8
|
-
const { getPlaybook } = require('./response/playbooks.js');
|
|
9
|
-
const { getRule, PARANOID_RULES } = require('./rules/index.js');
|
|
10
|
-
const { scanTyposquatting, findPyPITyposquatMatch } = require('./scanner/typosquat.js');
|
|
11
|
-
const { sendWebhook } = require('./webhook.js');
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const { scanGitHubActions } = require('./scanner/github-actions.js');
|
|
15
|
-
const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
|
|
16
|
-
const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
17
|
-
const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
18
|
-
const { scanEntropy } = require('./scanner/entropy.js');
|
|
19
|
-
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
20
|
-
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
21
|
-
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
|
|
22
|
-
const { runTemporalAnalyses } = require('./temporal-runner.js');
|
|
23
|
-
const { formatOutput } = require('./output-formatter.js');
|
|
24
|
-
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
|
|
25
|
-
const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
|
|
26
|
-
|
|
27
|
-
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
28
|
-
|
|
29
|
-
// Paranoid mode scanner
|
|
30
|
-
function scanParanoid(targetPath) {
|
|
31
|
-
const threats = [];
|
|
32
|
-
|
|
33
|
-
function scanFile(filePath) {
|
|
34
|
-
try {
|
|
35
|
-
const stat = fs.statSync(filePath);
|
|
36
|
-
if (stat.size > MAX_FILE_SIZE) return;
|
|
37
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
38
|
-
|
|
39
|
-
// Ignore URLs (they often contain patterns like .git)
|
|
40
|
-
const contentWithoutUrls = content.replace(/https?:\/\/[^\s"']+/g, '');
|
|
41
|
-
|
|
42
|
-
for (const [, rule] of Object.entries(PARANOID_RULES)) {
|
|
43
|
-
for (const pattern of rule.patterns) {
|
|
44
|
-
if (contentWithoutUrls.includes(pattern)) {
|
|
45
|
-
threats.push({
|
|
46
|
-
type: rule.id,
|
|
47
|
-
severity: rule.severity.toUpperCase(),
|
|
48
|
-
message: `${rule.message}: "${pattern}"`,
|
|
49
|
-
file: path.relative(targetPath, filePath),
|
|
50
|
-
mitre: rule.mitre
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
// Ignore read errors
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function walkDir(dir, depth) {
|
|
61
|
-
if (depth > 50) return; // Max depth guard (IDX-06)
|
|
62
|
-
const excluded = ['node_modules', '.git', '.muaddib-cache', ...getExtraExcludes()];
|
|
63
|
-
try {
|
|
64
|
-
const files = fs.readdirSync(dir);
|
|
65
|
-
for (const file of files) {
|
|
66
|
-
const fullPath = path.join(dir, file);
|
|
67
|
-
// Use lstatSync to avoid following symlinks
|
|
68
|
-
const stat = fs.lstatSync(fullPath);
|
|
69
|
-
|
|
70
|
-
if (stat.isSymbolicLink()) continue;
|
|
71
|
-
|
|
72
|
-
if (stat.isDirectory()) {
|
|
73
|
-
const rel = path.relative(targetPath, fullPath).replace(/\\/g, '/');
|
|
74
|
-
const isExcluded = excluded.includes(file) ||
|
|
75
|
-
excluded.some(ex => rel === ex || rel.startsWith(ex + '/'));
|
|
76
|
-
if (!isExcluded) {
|
|
77
|
-
walkDir(fullPath, depth + 1);
|
|
78
|
-
}
|
|
79
|
-
} else if (file.endsWith('.js') || file.endsWith('.json') || file.endsWith('.sh')) {
|
|
80
|
-
scanFile(fullPath);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
// Ignore walk errors
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
walkDir(targetPath, 0);
|
|
89
|
-
return threats;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Match detected Python dependencies against PyPI IOCs.
|
|
94
|
-
* @param {Array<{name: string, version: string, file: string}>} deps
|
|
95
|
-
* @param {string} targetPath
|
|
96
|
-
* @returns {Array} threats
|
|
97
|
-
*/
|
|
98
|
-
function matchPythonIOCs(deps, targetPath) {
|
|
99
|
-
if (deps.length === 0) return [];
|
|
100
|
-
|
|
101
|
-
const iocs = loadCachedIOCs();
|
|
102
|
-
const threats = [];
|
|
103
|
-
|
|
104
|
-
for (const dep of deps) {
|
|
105
|
-
const name = normalizePythonName(dep.name);
|
|
106
|
-
let malicious = null;
|
|
107
|
-
|
|
108
|
-
// Check wildcard (all versions malicious)
|
|
109
|
-
if (iocs.pypiWildcardPackages && iocs.pypiWildcardPackages.has(name)) {
|
|
110
|
-
const pkgList = iocs.pypiPackagesMap.get(name);
|
|
111
|
-
malicious = pkgList ? pkgList.find(p => p.version === '*') : { name, version: '*', severity: 'critical' };
|
|
112
|
-
}
|
|
113
|
-
// Check specific version via Map
|
|
114
|
-
else if (iocs.pypiPackagesMap && iocs.pypiPackagesMap.has(name)) {
|
|
115
|
-
const pkgList = iocs.pypiPackagesMap.get(name);
|
|
116
|
-
const cleanVersion = dep.version.replace(/^(==|>=|<=|~=|!=|>|<)/, '');
|
|
117
|
-
malicious = pkgList.find(p => p.version === cleanVersion || p.version === dep.version || p.version === '*');
|
|
118
|
-
}
|
|
119
|
-
// Fallback: linear search
|
|
120
|
-
else if (!iocs.pypiPackagesMap && iocs.pypi_packages) {
|
|
121
|
-
malicious = iocs.pypi_packages.find(p => {
|
|
122
|
-
if (normalizePythonName(p.name) !== name) return false;
|
|
123
|
-
if (p.version === '*') return true;
|
|
124
|
-
const cleanVersion = dep.version.replace(/^(==|>=|<=|~=|!=|>|<)/, '');
|
|
125
|
-
return p.version === cleanVersion || p.version === dep.version;
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (malicious) {
|
|
130
|
-
const severity = (malicious.severity || 'critical').toUpperCase();
|
|
131
|
-
const relFile = path.relative(targetPath, dep.file) || dep.file;
|
|
132
|
-
threats.push({
|
|
133
|
-
type: 'pypi_malicious_package',
|
|
134
|
-
severity: severity,
|
|
135
|
-
message: `Malicious PyPI package: ${dep.name}@${malicious.version} (source: ${malicious.source || 'OSV'})`,
|
|
136
|
-
file: relFile
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return threats;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Check Python dependencies for PyPI typosquatting (Levenshtein only, no API).
|
|
146
|
-
* @param {Array<{name: string, version: string, file: string}>} deps
|
|
147
|
-
* @param {string} targetPath
|
|
148
|
-
* @returns {Array} threats
|
|
149
|
-
*/
|
|
150
|
-
function checkPyPITyposquatting(deps, targetPath) {
|
|
151
|
-
const threats = [];
|
|
152
|
-
|
|
153
|
-
for (const dep of deps) {
|
|
154
|
-
const match = findPyPITyposquatMatch(dep.name);
|
|
155
|
-
if (match) {
|
|
156
|
-
const relFile = path.relative(targetPath, dep.file) || dep.file;
|
|
157
|
-
threats.push({
|
|
158
|
-
type: 'pypi_typosquat_detected',
|
|
159
|
-
severity: 'HIGH',
|
|
160
|
-
message: `PyPI package "${dep.name}" resembles "${match.original}" (${match.type}, distance: ${match.distance})`,
|
|
161
|
-
file: relFile
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return threats;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function run(targetPath, options = {}) {
|
|
170
|
-
// Validate targetPath exists and is a directory
|
|
171
|
-
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
172
|
-
throw new Error(`Target path does not exist: ${targetPath}`);
|
|
173
|
-
}
|
|
174
|
-
if (!fs.statSync(targetPath).isDirectory()) {
|
|
175
|
-
throw new Error(`Target path is not a directory: ${targetPath}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Ensure IOCs are downloaded (first run only, graceful failure)
|
|
179
|
-
await ensureIOCs();
|
|
180
|
-
|
|
181
|
-
// Apply --exclude dirs for this scan
|
|
182
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
183
|
-
setExtraExcludes(options.exclude, targetPath);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Detect Python project (synchronous, fast file reads)
|
|
187
|
-
const pythonDeps = detectPythonProject(targetPath);
|
|
188
|
-
|
|
189
|
-
// Show spinner during scan (TTY only; piped/CI output keeps static message)
|
|
190
|
-
const useTTYSpinner = !options._capture && process.stdout.isTTY;
|
|
191
|
-
let spinner = null;
|
|
192
|
-
if (useTTYSpinner) {
|
|
193
|
-
spinner = new Spinner();
|
|
194
|
-
spinner.start(`[MUADDIB] Scanning ${targetPath}...`);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
|
|
198
|
-
const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
|
|
199
|
-
|
|
200
|
-
// Helper: yield to event loop so spinner can animate between sync operations
|
|
201
|
-
const yieldThen = (fn) => new Promise(resolve => setImmediate(() => resolve(fn())));
|
|
202
|
-
|
|
203
|
-
// Cross-file module graph analysis (before individual scanners)
|
|
204
|
-
// Wrapped in yieldThen to unblock spinner animation
|
|
205
|
-
let crossFileFlows = [];
|
|
206
|
-
if (!options.noModuleGraph) {
|
|
207
|
-
try {
|
|
208
|
-
const graph = await yieldThen(() => buildModuleGraph(targetPath));
|
|
209
|
-
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
210
|
-
crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
|
|
211
|
-
} catch {
|
|
212
|
-
// Graceful fallback — module graph is best-effort
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Sequential execution of scanners with event loop yields between each.
|
|
217
|
-
// All scanners (even "async" ones) are effectively synchronous (readFileSync, readdirSync).
|
|
218
|
-
// Running them via yieldThen ensures the spinner animates between each scanner.
|
|
219
|
-
let scanResult;
|
|
220
|
-
try {
|
|
221
|
-
scanResult = await Promise.all([
|
|
222
|
-
yieldThen(() => scanPackageJson(targetPath)),
|
|
223
|
-
yieldThen(() => scanShellScripts(targetPath)),
|
|
224
|
-
yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
|
|
225
|
-
yieldThen(() => detectObfuscation(targetPath)),
|
|
226
|
-
yieldThen(() => scanDependencies(targetPath)),
|
|
227
|
-
yieldThen(() => scanHashes(targetPath)),
|
|
228
|
-
yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
|
|
229
|
-
yieldThen(() => scanTyposquatting(targetPath)),
|
|
230
|
-
yieldThen(() => scanGitHubActions(targetPath)),
|
|
231
|
-
yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
|
|
232
|
-
yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
|
|
233
|
-
yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
|
|
234
|
-
yieldThen(() => scanAIConfig(targetPath))
|
|
235
|
-
]);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
if (spinner) spinner.fail(`[MUADDIB] Scan failed: ${err.message}`);
|
|
238
|
-
throw err;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const [
|
|
242
|
-
packageThreats,
|
|
243
|
-
shellThreats,
|
|
244
|
-
astThreats,
|
|
245
|
-
obfuscationThreats,
|
|
246
|
-
dependencyThreats,
|
|
247
|
-
hashThreats,
|
|
248
|
-
dataflowThreats,
|
|
249
|
-
typosquatThreats,
|
|
250
|
-
ghActionsThreats,
|
|
251
|
-
pythonThreats,
|
|
252
|
-
pypiTyposquatThreats,
|
|
253
|
-
entropyThreats,
|
|
254
|
-
aiConfigThreats
|
|
255
|
-
] = scanResult;
|
|
256
|
-
|
|
257
|
-
// Stop spinner now that scanning is complete
|
|
258
|
-
if (spinner) {
|
|
259
|
-
spinner.succeed(`[MUADDIB] Scanned ${targetPath}`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const threats = [
|
|
263
|
-
...packageThreats,
|
|
264
|
-
...shellThreats,
|
|
265
|
-
...astThreats,
|
|
266
|
-
...obfuscationThreats,
|
|
267
|
-
...dependencyThreats,
|
|
268
|
-
...hashThreats,
|
|
269
|
-
...dataflowThreats,
|
|
270
|
-
...typosquatThreats,
|
|
271
|
-
...ghActionsThreats,
|
|
272
|
-
...pythonThreats,
|
|
273
|
-
...pypiTyposquatThreats,
|
|
274
|
-
...entropyThreats,
|
|
275
|
-
...aiConfigThreats,
|
|
276
|
-
...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
|
|
277
|
-
type: f.type,
|
|
278
|
-
severity: f.severity,
|
|
279
|
-
message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
|
|
280
|
-
file: f.sinkFile
|
|
281
|
-
}))
|
|
282
|
-
];
|
|
283
|
-
|
|
284
|
-
// Paranoid mode
|
|
285
|
-
if (options.paranoid) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
console.log(`[
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1
|
+
const { scanPackageJson } = require('./scanner/package.js');
|
|
2
|
+
const { scanShellScripts } = require('./scanner/shell.js');
|
|
3
|
+
const { analyzeAST } = require('./scanner/ast.js');
|
|
4
|
+
const { detectObfuscation } = require('./scanner/obfuscation.js');
|
|
5
|
+
const { scanDependencies } = require('./scanner/dependencies.js');
|
|
6
|
+
const { scanHashes } = require('./scanner/hash.js');
|
|
7
|
+
const { analyzeDataFlow } = require('./scanner/dataflow.js');
|
|
8
|
+
const { getPlaybook } = require('./response/playbooks.js');
|
|
9
|
+
const { getRule, PARANOID_RULES } = require('./rules/index.js');
|
|
10
|
+
const { scanTyposquatting, findPyPITyposquatMatch } = require('./scanner/typosquat.js');
|
|
11
|
+
const { sendWebhook } = require('./webhook.js');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { scanGitHubActions } = require('./scanner/github-actions.js');
|
|
15
|
+
const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
|
|
16
|
+
const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
17
|
+
const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
18
|
+
const { scanEntropy } = require('./scanner/entropy.js');
|
|
19
|
+
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
20
|
+
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
21
|
+
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
|
|
22
|
+
const { runTemporalAnalyses } = require('./temporal-runner.js');
|
|
23
|
+
const { formatOutput } = require('./output-formatter.js');
|
|
24
|
+
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
|
|
25
|
+
const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
|
|
26
|
+
|
|
27
|
+
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
28
|
+
|
|
29
|
+
// Paranoid mode scanner
|
|
30
|
+
function scanParanoid(targetPath) {
|
|
31
|
+
const threats = [];
|
|
32
|
+
|
|
33
|
+
function scanFile(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
const stat = fs.statSync(filePath);
|
|
36
|
+
if (stat.size > MAX_FILE_SIZE) return;
|
|
37
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
38
|
+
|
|
39
|
+
// Ignore URLs (they often contain patterns like .git)
|
|
40
|
+
const contentWithoutUrls = content.replace(/https?:\/\/[^\s"']+/g, '');
|
|
41
|
+
|
|
42
|
+
for (const [, rule] of Object.entries(PARANOID_RULES)) {
|
|
43
|
+
for (const pattern of rule.patterns) {
|
|
44
|
+
if (contentWithoutUrls.includes(pattern)) {
|
|
45
|
+
threats.push({
|
|
46
|
+
type: rule.id,
|
|
47
|
+
severity: rule.severity.toUpperCase(),
|
|
48
|
+
message: `${rule.message}: "${pattern}"`,
|
|
49
|
+
file: path.relative(targetPath, filePath),
|
|
50
|
+
mitre: rule.mitre
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore read errors
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function walkDir(dir, depth) {
|
|
61
|
+
if (depth > 50) return; // Max depth guard (IDX-06)
|
|
62
|
+
const excluded = ['node_modules', '.git', '.muaddib-cache', ...getExtraExcludes()];
|
|
63
|
+
try {
|
|
64
|
+
const files = fs.readdirSync(dir);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const fullPath = path.join(dir, file);
|
|
67
|
+
// Use lstatSync to avoid following symlinks
|
|
68
|
+
const stat = fs.lstatSync(fullPath);
|
|
69
|
+
|
|
70
|
+
if (stat.isSymbolicLink()) continue;
|
|
71
|
+
|
|
72
|
+
if (stat.isDirectory()) {
|
|
73
|
+
const rel = path.relative(targetPath, fullPath).replace(/\\/g, '/');
|
|
74
|
+
const isExcluded = excluded.includes(file) ||
|
|
75
|
+
excluded.some(ex => rel === ex || rel.startsWith(ex + '/'));
|
|
76
|
+
if (!isExcluded) {
|
|
77
|
+
walkDir(fullPath, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
} else if (file.endsWith('.js') || file.endsWith('.json') || file.endsWith('.sh')) {
|
|
80
|
+
scanFile(fullPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore walk errors
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
walkDir(targetPath, 0);
|
|
89
|
+
return threats;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Match detected Python dependencies against PyPI IOCs.
|
|
94
|
+
* @param {Array<{name: string, version: string, file: string}>} deps
|
|
95
|
+
* @param {string} targetPath
|
|
96
|
+
* @returns {Array} threats
|
|
97
|
+
*/
|
|
98
|
+
function matchPythonIOCs(deps, targetPath) {
|
|
99
|
+
if (deps.length === 0) return [];
|
|
100
|
+
|
|
101
|
+
const iocs = loadCachedIOCs();
|
|
102
|
+
const threats = [];
|
|
103
|
+
|
|
104
|
+
for (const dep of deps) {
|
|
105
|
+
const name = normalizePythonName(dep.name);
|
|
106
|
+
let malicious = null;
|
|
107
|
+
|
|
108
|
+
// Check wildcard (all versions malicious)
|
|
109
|
+
if (iocs.pypiWildcardPackages && iocs.pypiWildcardPackages.has(name)) {
|
|
110
|
+
const pkgList = iocs.pypiPackagesMap.get(name);
|
|
111
|
+
malicious = pkgList ? pkgList.find(p => p.version === '*') : { name, version: '*', severity: 'critical' };
|
|
112
|
+
}
|
|
113
|
+
// Check specific version via Map
|
|
114
|
+
else if (iocs.pypiPackagesMap && iocs.pypiPackagesMap.has(name)) {
|
|
115
|
+
const pkgList = iocs.pypiPackagesMap.get(name);
|
|
116
|
+
const cleanVersion = dep.version.replace(/^(==|>=|<=|~=|!=|>|<)/, '');
|
|
117
|
+
malicious = pkgList.find(p => p.version === cleanVersion || p.version === dep.version || p.version === '*');
|
|
118
|
+
}
|
|
119
|
+
// Fallback: linear search
|
|
120
|
+
else if (!iocs.pypiPackagesMap && iocs.pypi_packages) {
|
|
121
|
+
malicious = iocs.pypi_packages.find(p => {
|
|
122
|
+
if (normalizePythonName(p.name) !== name) return false;
|
|
123
|
+
if (p.version === '*') return true;
|
|
124
|
+
const cleanVersion = dep.version.replace(/^(==|>=|<=|~=|!=|>|<)/, '');
|
|
125
|
+
return p.version === cleanVersion || p.version === dep.version;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (malicious) {
|
|
130
|
+
const severity = (malicious.severity || 'critical').toUpperCase();
|
|
131
|
+
const relFile = path.relative(targetPath, dep.file) || dep.file;
|
|
132
|
+
threats.push({
|
|
133
|
+
type: 'pypi_malicious_package',
|
|
134
|
+
severity: severity,
|
|
135
|
+
message: `Malicious PyPI package: ${dep.name}@${malicious.version} (source: ${malicious.source || 'OSV'})`,
|
|
136
|
+
file: relFile
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return threats;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check Python dependencies for PyPI typosquatting (Levenshtein only, no API).
|
|
146
|
+
* @param {Array<{name: string, version: string, file: string}>} deps
|
|
147
|
+
* @param {string} targetPath
|
|
148
|
+
* @returns {Array} threats
|
|
149
|
+
*/
|
|
150
|
+
function checkPyPITyposquatting(deps, targetPath) {
|
|
151
|
+
const threats = [];
|
|
152
|
+
|
|
153
|
+
for (const dep of deps) {
|
|
154
|
+
const match = findPyPITyposquatMatch(dep.name);
|
|
155
|
+
if (match) {
|
|
156
|
+
const relFile = path.relative(targetPath, dep.file) || dep.file;
|
|
157
|
+
threats.push({
|
|
158
|
+
type: 'pypi_typosquat_detected',
|
|
159
|
+
severity: 'HIGH',
|
|
160
|
+
message: `PyPI package "${dep.name}" resembles "${match.original}" (${match.type}, distance: ${match.distance})`,
|
|
161
|
+
file: relFile
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return threats;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function run(targetPath, options = {}) {
|
|
170
|
+
// Validate targetPath exists and is a directory
|
|
171
|
+
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
172
|
+
throw new Error(`Target path does not exist: ${targetPath}`);
|
|
173
|
+
}
|
|
174
|
+
if (!fs.statSync(targetPath).isDirectory()) {
|
|
175
|
+
throw new Error(`Target path is not a directory: ${targetPath}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Ensure IOCs are downloaded (first run only, graceful failure)
|
|
179
|
+
await ensureIOCs();
|
|
180
|
+
|
|
181
|
+
// Apply --exclude dirs for this scan
|
|
182
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
183
|
+
setExtraExcludes(options.exclude, targetPath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Detect Python project (synchronous, fast file reads)
|
|
187
|
+
const pythonDeps = detectPythonProject(targetPath);
|
|
188
|
+
|
|
189
|
+
// Show spinner during scan (TTY only; piped/CI output keeps static message)
|
|
190
|
+
const useTTYSpinner = !options._capture && process.stdout.isTTY;
|
|
191
|
+
let spinner = null;
|
|
192
|
+
if (useTTYSpinner) {
|
|
193
|
+
spinner = new Spinner();
|
|
194
|
+
spinner.start(`[MUADDIB] Scanning ${targetPath}...`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
|
|
198
|
+
const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
|
|
199
|
+
|
|
200
|
+
// Helper: yield to event loop so spinner can animate between sync operations
|
|
201
|
+
const yieldThen = (fn) => new Promise(resolve => setImmediate(() => resolve(fn())));
|
|
202
|
+
|
|
203
|
+
// Cross-file module graph analysis (before individual scanners)
|
|
204
|
+
// Wrapped in yieldThen to unblock spinner animation
|
|
205
|
+
let crossFileFlows = [];
|
|
206
|
+
if (!options.noModuleGraph) {
|
|
207
|
+
try {
|
|
208
|
+
const graph = await yieldThen(() => buildModuleGraph(targetPath));
|
|
209
|
+
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
210
|
+
crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
|
|
211
|
+
} catch {
|
|
212
|
+
// Graceful fallback — module graph is best-effort
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Sequential execution of scanners with event loop yields between each.
|
|
217
|
+
// All scanners (even "async" ones) are effectively synchronous (readFileSync, readdirSync).
|
|
218
|
+
// Running them via yieldThen ensures the spinner animates between each scanner.
|
|
219
|
+
let scanResult;
|
|
220
|
+
try {
|
|
221
|
+
scanResult = await Promise.all([
|
|
222
|
+
yieldThen(() => scanPackageJson(targetPath)),
|
|
223
|
+
yieldThen(() => scanShellScripts(targetPath)),
|
|
224
|
+
yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
|
|
225
|
+
yieldThen(() => detectObfuscation(targetPath)),
|
|
226
|
+
yieldThen(() => scanDependencies(targetPath)),
|
|
227
|
+
yieldThen(() => scanHashes(targetPath)),
|
|
228
|
+
yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
|
|
229
|
+
yieldThen(() => scanTyposquatting(targetPath)),
|
|
230
|
+
yieldThen(() => scanGitHubActions(targetPath)),
|
|
231
|
+
yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
|
|
232
|
+
yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
|
|
233
|
+
yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
|
|
234
|
+
yieldThen(() => scanAIConfig(targetPath))
|
|
235
|
+
]);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (spinner) spinner.fail(`[MUADDIB] Scan failed: ${err.message}`);
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const [
|
|
242
|
+
packageThreats,
|
|
243
|
+
shellThreats,
|
|
244
|
+
astThreats,
|
|
245
|
+
obfuscationThreats,
|
|
246
|
+
dependencyThreats,
|
|
247
|
+
hashThreats,
|
|
248
|
+
dataflowThreats,
|
|
249
|
+
typosquatThreats,
|
|
250
|
+
ghActionsThreats,
|
|
251
|
+
pythonThreats,
|
|
252
|
+
pypiTyposquatThreats,
|
|
253
|
+
entropyThreats,
|
|
254
|
+
aiConfigThreats
|
|
255
|
+
] = scanResult;
|
|
256
|
+
|
|
257
|
+
// Stop spinner now that scanning is complete
|
|
258
|
+
if (spinner) {
|
|
259
|
+
spinner.succeed(`[MUADDIB] Scanned ${targetPath}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const threats = [
|
|
263
|
+
...packageThreats,
|
|
264
|
+
...shellThreats,
|
|
265
|
+
...astThreats,
|
|
266
|
+
...obfuscationThreats,
|
|
267
|
+
...dependencyThreats,
|
|
268
|
+
...hashThreats,
|
|
269
|
+
...dataflowThreats,
|
|
270
|
+
...typosquatThreats,
|
|
271
|
+
...ghActionsThreats,
|
|
272
|
+
...pythonThreats,
|
|
273
|
+
...pypiTyposquatThreats,
|
|
274
|
+
...entropyThreats,
|
|
275
|
+
...aiConfigThreats,
|
|
276
|
+
...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
|
|
277
|
+
type: f.type,
|
|
278
|
+
severity: f.severity,
|
|
279
|
+
message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
|
|
280
|
+
file: f.sinkFile
|
|
281
|
+
}))
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
// Paranoid mode
|
|
285
|
+
if (options.paranoid) {
|
|
286
|
+
if (!options.json) {
|
|
287
|
+
console.log('[PARANOID] Ultra-strict mode enabled\n');
|
|
288
|
+
}
|
|
289
|
+
const paranoidThreats = scanParanoid(targetPath);
|
|
290
|
+
threats.push(...paranoidThreats);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Temporal analyses (--temporal, --temporal-ast, --temporal-publish, --temporal-maintainer)
|
|
294
|
+
if (options.temporal || options.temporalAst || options.temporalPublish || options.temporalMaintainer) {
|
|
295
|
+
const pkgNames = listInstalledPackages(targetPath);
|
|
296
|
+
const temporalThreats = await runTemporalAnalyses(targetPath, options, pkgNames);
|
|
297
|
+
threats.push(...temporalThreats);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Sandbox integration
|
|
301
|
+
let sandboxData = null;
|
|
302
|
+
if (options.sandboxResult && Array.isArray(options.sandboxResult.findings)) {
|
|
303
|
+
const sr = options.sandboxResult;
|
|
304
|
+
const pkg = sr.raw_report?.package || 'unknown';
|
|
305
|
+
sandboxData = {
|
|
306
|
+
package: pkg,
|
|
307
|
+
score: sr.score,
|
|
308
|
+
severity: sr.severity,
|
|
309
|
+
findings: sr.findings,
|
|
310
|
+
network: sr.raw_report?.network || null
|
|
311
|
+
};
|
|
312
|
+
for (const f of sr.findings) {
|
|
313
|
+
threats.push({
|
|
314
|
+
type: 'sandbox_' + f.type,
|
|
315
|
+
severity: f.severity,
|
|
316
|
+
message: f.detail,
|
|
317
|
+
file: `[SANDBOX] ${pkg}`
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Deduplicate: same file + same type + same message = show once with count
|
|
323
|
+
const deduped = [];
|
|
324
|
+
const seen = new Map();
|
|
325
|
+
for (const t of threats) {
|
|
326
|
+
const key = `${t.file}::${t.type}::${t.message}`;
|
|
327
|
+
if (seen.has(key)) {
|
|
328
|
+
seen.get(key).count++;
|
|
329
|
+
} else {
|
|
330
|
+
const entry = { ...t, count: 1 };
|
|
331
|
+
seen.set(key, entry);
|
|
332
|
+
deduped.push(entry);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// FP reduction: legitimate frameworks produce high volumes of certain threat types.
|
|
337
|
+
// A malware package typically has 1-3 occurrences, not dozens.
|
|
338
|
+
applyFPReductions(deduped);
|
|
339
|
+
|
|
340
|
+
// Enrich each threat with rules
|
|
341
|
+
const enrichedThreats = deduped.map(t => {
|
|
342
|
+
const rule = getRule(t.type);
|
|
343
|
+
const points = SEVERITY_WEIGHTS[t.severity] || 0;
|
|
344
|
+
return {
|
|
345
|
+
...t,
|
|
346
|
+
rule_id: rule.id || t.type,
|
|
347
|
+
rule_name: rule.name || t.type,
|
|
348
|
+
confidence: rule.confidence || 'medium',
|
|
349
|
+
references: rule.references || [],
|
|
350
|
+
mitre: t.mitre || rule.mitre,
|
|
351
|
+
playbook: getPlaybook(t.type),
|
|
352
|
+
points
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Build score breakdown sorted by impact (descending)
|
|
357
|
+
const breakdown = enrichedThreats
|
|
358
|
+
.map(t => ({ rule: t.rule_id, type: t.type, points: t.points, reason: t.message }))
|
|
359
|
+
.sort((a, b) => b.points - a.points);
|
|
360
|
+
|
|
361
|
+
// Per-file max scoring (v2.2.11)
|
|
362
|
+
const {
|
|
363
|
+
riskScore, riskLevel, globalRiskScore,
|
|
364
|
+
maxFileScore, packageScore, mostSuspiciousFile, fileScores,
|
|
365
|
+
criticalCount, highCount, mediumCount, lowCount
|
|
366
|
+
} = calculateRiskScore(deduped);
|
|
367
|
+
|
|
368
|
+
// Python scan metadata
|
|
369
|
+
const pythonInfo = pythonDeps.length > 0 ? {
|
|
370
|
+
dependencies: pythonDeps.length,
|
|
371
|
+
files: [...new Set(pythonDeps.map(d => path.relative(targetPath, d.file) || d.file))],
|
|
372
|
+
threats: pythonThreats.length + pypiTyposquatThreats.length
|
|
373
|
+
} : null;
|
|
374
|
+
|
|
375
|
+
const result = {
|
|
376
|
+
target: targetPath,
|
|
377
|
+
timestamp: new Date().toISOString(),
|
|
378
|
+
threats: enrichedThreats,
|
|
379
|
+
python: pythonInfo,
|
|
380
|
+
summary: {
|
|
381
|
+
total: deduped.length,
|
|
382
|
+
critical: criticalCount,
|
|
383
|
+
high: highCount,
|
|
384
|
+
medium: mediumCount,
|
|
385
|
+
low: lowCount,
|
|
386
|
+
riskScore,
|
|
387
|
+
riskLevel,
|
|
388
|
+
globalRiskScore,
|
|
389
|
+
maxFileScore,
|
|
390
|
+
packageScore,
|
|
391
|
+
mostSuspiciousFile,
|
|
392
|
+
fileScores,
|
|
393
|
+
breakdown
|
|
394
|
+
},
|
|
395
|
+
sandbox: sandboxData
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// _capture mode: return result directly without printing (used by diff.js)
|
|
399
|
+
if (options._capture) {
|
|
400
|
+
setExtraExcludes([]);
|
|
401
|
+
clearFileListCache();
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
formatOutput(result, options, {
|
|
406
|
+
spinner, sandboxData, mostSuspiciousFile, maxFileScore,
|
|
407
|
+
packageScore, globalRiskScore, deduped, enrichedThreats,
|
|
408
|
+
pythonInfo, breakdown, targetPath
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Send webhook if configured
|
|
412
|
+
if (options.webhook && enrichedThreats.length > 0) {
|
|
413
|
+
try {
|
|
414
|
+
await sendWebhook(options.webhook, result);
|
|
415
|
+
console.log(`[OK] Alert sent to webhook`);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.log(`[WARN] Webhook send failed: ${err.message}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Calculate exit code based on fail level
|
|
422
|
+
const failLevel = options.failLevel || 'high';
|
|
423
|
+
const severityLevels = {
|
|
424
|
+
critical: ['CRITICAL'],
|
|
425
|
+
high: ['CRITICAL', 'HIGH'],
|
|
426
|
+
medium: ['CRITICAL', 'HIGH', 'MEDIUM'],
|
|
427
|
+
low: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
|
|
431
|
+
const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
|
|
432
|
+
|
|
433
|
+
// Clear runtime state
|
|
434
|
+
setExtraExcludes([]);
|
|
435
|
+
clearFileListCache();
|
|
436
|
+
|
|
437
|
+
return Math.min(failingThreats.length, 125);
|
|
438
|
+
}
|
|
439
|
+
|
|
438
440
|
module.exports = { run, isPackageLevelThreat, computeGroupScore };
|