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/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
- console.log('[PARANOID] Ultra-strict mode enabled\n');
287
- const paranoidThreats = scanParanoid(targetPath);
288
- threats.push(...paranoidThreats);
289
- }
290
-
291
- // Temporal analyses (--temporal, --temporal-ast, --temporal-publish, --temporal-maintainer)
292
- if (options.temporal || options.temporalAst || options.temporalPublish || options.temporalMaintainer) {
293
- const pkgNames = listInstalledPackages(targetPath);
294
- const temporalThreats = await runTemporalAnalyses(targetPath, options, pkgNames);
295
- threats.push(...temporalThreats);
296
- }
297
-
298
- // Sandbox integration
299
- let sandboxData = null;
300
- if (options.sandboxResult && Array.isArray(options.sandboxResult.findings)) {
301
- const sr = options.sandboxResult;
302
- const pkg = sr.raw_report?.package || 'unknown';
303
- sandboxData = {
304
- package: pkg,
305
- score: sr.score,
306
- severity: sr.severity,
307
- findings: sr.findings,
308
- network: sr.raw_report?.network || null
309
- };
310
- for (const f of sr.findings) {
311
- threats.push({
312
- type: 'sandbox_' + f.type,
313
- severity: f.severity,
314
- message: f.detail,
315
- file: `[SANDBOX] ${pkg}`
316
- });
317
- }
318
- }
319
-
320
- // Deduplicate: same file + same type + same message = show once with count
321
- const deduped = [];
322
- const seen = new Map();
323
- for (const t of threats) {
324
- const key = `${t.file}::${t.type}::${t.message}`;
325
- if (seen.has(key)) {
326
- seen.get(key).count++;
327
- } else {
328
- const entry = { ...t, count: 1 };
329
- seen.set(key, entry);
330
- deduped.push(entry);
331
- }
332
- }
333
-
334
- // FP reduction: legitimate frameworks produce high volumes of certain threat types.
335
- // A malware package typically has 1-3 occurrences, not dozens.
336
- applyFPReductions(deduped);
337
-
338
- // Enrich each threat with rules
339
- const enrichedThreats = deduped.map(t => {
340
- const rule = getRule(t.type);
341
- const points = SEVERITY_WEIGHTS[t.severity] || 0;
342
- return {
343
- ...t,
344
- rule_id: rule.id || t.type,
345
- rule_name: rule.name || t.type,
346
- confidence: rule.confidence || 'medium',
347
- references: rule.references || [],
348
- mitre: t.mitre || rule.mitre,
349
- playbook: getPlaybook(t.type),
350
- points
351
- };
352
- });
353
-
354
- // Build score breakdown sorted by impact (descending)
355
- const breakdown = enrichedThreats
356
- .map(t => ({ rule: t.rule_id, type: t.type, points: t.points, reason: t.message }))
357
- .sort((a, b) => b.points - a.points);
358
-
359
- // Per-file max scoring (v2.2.11)
360
- const {
361
- riskScore, riskLevel, globalRiskScore,
362
- maxFileScore, packageScore, mostSuspiciousFile, fileScores,
363
- criticalCount, highCount, mediumCount, lowCount
364
- } = calculateRiskScore(deduped);
365
-
366
- // Python scan metadata
367
- const pythonInfo = pythonDeps.length > 0 ? {
368
- dependencies: pythonDeps.length,
369
- files: [...new Set(pythonDeps.map(d => path.relative(targetPath, d.file) || d.file))],
370
- threats: pythonThreats.length + pypiTyposquatThreats.length
371
- } : null;
372
-
373
- const result = {
374
- target: targetPath,
375
- timestamp: new Date().toISOString(),
376
- threats: enrichedThreats,
377
- python: pythonInfo,
378
- summary: {
379
- total: deduped.length,
380
- critical: criticalCount,
381
- high: highCount,
382
- medium: mediumCount,
383
- low: lowCount,
384
- riskScore,
385
- riskLevel,
386
- globalRiskScore,
387
- maxFileScore,
388
- packageScore,
389
- mostSuspiciousFile,
390
- fileScores,
391
- breakdown
392
- },
393
- sandbox: sandboxData
394
- };
395
-
396
- // _capture mode: return result directly without printing (used by diff.js)
397
- if (options._capture) {
398
- setExtraExcludes([]);
399
- clearFileListCache();
400
- return result;
401
- }
402
-
403
- formatOutput(result, options, {
404
- spinner, sandboxData, mostSuspiciousFile, maxFileScore,
405
- packageScore, globalRiskScore, deduped, enrichedThreats,
406
- pythonInfo, breakdown, targetPath
407
- });
408
-
409
- // Send webhook if configured
410
- if (options.webhook && enrichedThreats.length > 0) {
411
- try {
412
- await sendWebhook(options.webhook, result);
413
- console.log(`[OK] Alert sent to webhook`);
414
- } catch (err) {
415
- console.log(`[WARN] Webhook send failed: ${err.message}`);
416
- }
417
- }
418
-
419
- // Calculate exit code based on fail level
420
- const failLevel = options.failLevel || 'high';
421
- const severityLevels = {
422
- critical: ['CRITICAL'],
423
- high: ['CRITICAL', 'HIGH'],
424
- medium: ['CRITICAL', 'HIGH', 'MEDIUM'],
425
- low: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
426
- };
427
-
428
- const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
429
- const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
430
-
431
- // Clear runtime state
432
- setExtraExcludes([]);
433
- clearFileListCache();
434
-
435
- return Math.min(failingThreats.length, 125);
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 };