muaddib-scanner 2.2.13 → 2.2.15

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
@@ -7,8 +7,6 @@ const { scanHashes } = require('./scanner/hash.js');
7
7
  const { analyzeDataFlow } = require('./scanner/dataflow.js');
8
8
  const { getPlaybook } = require('./response/playbooks.js');
9
9
  const { getRule, PARANOID_RULES } = require('./rules/index.js');
10
- const { saveReport } = require('./report.js');
11
- const { saveSARIF } = require('./sarif.js');
12
10
  const { scanTyposquatting, findPyPITyposquatMatch } = require('./scanner/typosquat.js');
13
11
  const { sendWebhook } = require('./webhook.js');
14
12
  const fs = require('fs');
@@ -21,155 +19,12 @@ const { scanEntropy } = require('./scanner/entropy.js');
21
19
  const { scanAIConfig } = require('./scanner/ai-config.js');
22
20
  const { deobfuscate } = require('./scanner/deobfuscate.js');
23
21
  const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
24
- const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
25
- const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
26
- const { detectPublishAnomaly } = require('./publish-anomaly.js');
27
- const { detectMaintainerChange } = require('./maintainer-change.js');
28
- const { setExtraExcludes, getExtraExcludes, Spinner } = require('./utils.js');
29
-
30
- // ============================================
31
- // SCORING CONSTANTS
32
- // ============================================
33
- // Severity weights for risk score calculation (0-100)
34
- // These values determine the impact of each threat type on the final score.
35
- // Example: 4 CRITICAL threats = 100 (max score), 10 HIGH threats = 100
36
- const SEVERITY_WEIGHTS = {
37
- // CRITICAL: Threats with immediate impact (active malware, data exfiltration)
38
- // High weight because a single critical threat justifies immediate action
39
- CRITICAL: 25,
40
-
41
- // HIGH: Serious threats (dangerous code, known malicious dependencies)
42
- // 10 HIGH threats reach the maximum score
43
- HIGH: 10,
44
-
45
- // MEDIUM: Potential threats (suspicious patterns, light obfuscation)
46
- // Moderate impact, requires investigation but not necessarily malicious
47
- MEDIUM: 3,
48
-
49
- // LOW: Informational findings, minimal impact on risk score
50
- LOW: 1
51
- };
52
-
53
- // Thresholds for determining the overall risk level
54
- const RISK_THRESHOLDS = {
55
- CRITICAL: 75, // >= 75: Immediate action required
56
- HIGH: 50, // >= 50: Priority investigation
57
- MEDIUM: 25 // >= 25: Monitor
58
- // < 25 && > 0: LOW
59
- // === 0: SAFE
60
- };
61
-
62
- // Maximum score (capped)
63
- const MAX_RISK_SCORE = 100;
64
-
65
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
66
-
67
- // Cap MEDIUM prototype_hook contribution (frameworks like Restify have 50+ extensions)
68
- const PROTO_HOOK_MEDIUM_CAP = 15;
69
-
70
- // ============================================
71
- // PER-FILE MAX SCORING (v2.2.11)
72
- // ============================================
73
- // Threat types classified as package-level (not tied to a specific source file).
74
- // These are added to the package score, not grouped by file.
75
- const PACKAGE_LEVEL_TYPES = new Set([
76
- 'lifecycle_script', 'lifecycle_shell_pipe',
77
- 'lifecycle_added_critical', 'lifecycle_added_high', 'lifecycle_modified',
78
- 'known_malicious_package', 'typosquat_detected',
79
- 'shai_hulud_marker', 'suspicious_file',
80
- 'pypi_malicious_package', 'pypi_typosquat_detected',
81
- 'dangerous_api_added_critical', 'dangerous_api_added_high', 'dangerous_api_added_medium',
82
- 'publish_burst', 'publish_dormant_spike', 'publish_rapid_succession',
83
- 'maintainer_new_suspicious', 'maintainer_sole_change',
84
- 'sandbox_network_activity', 'sandbox_file_changes', 'sandbox_process_spawns',
85
- 'sandbox_canary_exfiltration'
86
- ]);
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');
87
26
 
88
- /**
89
- * Classify a threat as package-level or file-level.
90
- * Package-level: metadata findings (package.json, node_modules, sandbox)
91
- * File-level: code-level findings in specific source files
92
- */
93
- function isPackageLevelThreat(threat) {
94
- if (PACKAGE_LEVEL_TYPES.has(threat.type)) return true;
95
- if (threat.file === 'package.json') return true;
96
- if (threat.file && (threat.file.startsWith('node_modules/') || threat.file.startsWith('node_modules\\'))) return true;
97
- if (threat.file && threat.file.startsWith('[SANDBOX]')) return true;
98
- return false;
99
- }
100
-
101
- /**
102
- * Compute a risk score for a group of threats using standard weights.
103
- * Handles prototype_hook MEDIUM cap per group.
104
- * @param {Array} threats - array of threat objects (after FP reductions)
105
- * @returns {number} score 0-100
106
- */
107
- function computeGroupScore(threats) {
108
- const criticalCount = threats.filter(t => t.severity === 'CRITICAL').length;
109
- const highCount = threats.filter(t => t.severity === 'HIGH').length;
110
- const mediumCount = threats.filter(t => t.severity === 'MEDIUM').length;
111
- const lowCount = threats.filter(t => t.severity === 'LOW').length;
112
-
113
- const mediumProtoHookCount = threats.filter(
114
- t => t.type === 'prototype_hook' && t.severity === 'MEDIUM'
115
- ).length;
116
- const protoHookPoints = Math.min(mediumProtoHookCount * SEVERITY_WEIGHTS.MEDIUM, PROTO_HOOK_MEDIUM_CAP);
117
- const otherMediumCount = mediumCount - mediumProtoHookCount;
118
-
119
- let score = 0;
120
- score += criticalCount * SEVERITY_WEIGHTS.CRITICAL;
121
- score += highCount * SEVERITY_WEIGHTS.HIGH;
122
- score += otherMediumCount * SEVERITY_WEIGHTS.MEDIUM;
123
- score += protoHookPoints;
124
- score += lowCount * SEVERITY_WEIGHTS.LOW;
125
- return Math.min(MAX_RISK_SCORE, score);
126
- }
127
-
128
- // ============================================
129
- // FP REDUCTION POST-PROCESSING
130
- // ============================================
131
- // Legitimate frameworks produce high volumes of certain threat types that
132
- // malware never does. This function downgrades severity when the count
133
- // exceeds thresholds only seen in legitimate codebases.
134
- const FP_COUNT_THRESHOLDS = {
135
- dynamic_require: { maxCount: 10, from: 'HIGH', to: 'LOW' },
136
- dangerous_call_function: { maxCount: 5, from: 'MEDIUM', to: 'LOW' },
137
- require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
138
- suspicious_dataflow: { maxCount: 5, to: 'LOW' },
139
- obfuscation_detected: { maxCount: 3, to: 'LOW' }
140
- };
141
-
142
- // Custom class prototypes that HTTP frameworks legitimately extend.
143
- // Distinguished from dangerous core Node.js prototype hooks.
144
- const FRAMEWORK_PROTOTYPES = ['Request', 'Response', 'App', 'Router'];
145
- const FRAMEWORK_PROTO_RE = new RegExp(
146
- '^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
147
- );
148
-
149
- function applyFPReductions(threats) {
150
- // Count occurrences of each threat type (package-level, across all files)
151
- const typeCounts = {};
152
- for (const t of threats) {
153
- typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
154
- }
155
-
156
- for (const t of threats) {
157
- // Count-based downgrade: if a threat type appears too many times,
158
- // it's a framework/plugin system, not malware
159
- const rule = FP_COUNT_THRESHOLDS[t.type];
160
- if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
161
- t.severity = rule.to;
162
- }
163
-
164
- // Prototype hook: framework class prototypes → MEDIUM
165
- // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
166
- // Browser/native APIs (globalThis.fetch, XMLHttpRequest) stay HIGH
167
- if (t.type === 'prototype_hook' && t.severity === 'HIGH' &&
168
- FRAMEWORK_PROTO_RE.test(t.message)) {
169
- t.severity = 'MEDIUM';
170
- }
171
- }
172
- }
27
+ const { MAX_FILE_SIZE } = require('./shared/constants.js');
173
28
 
174
29
  // Paranoid mode scanner
175
30
  function scanParanoid(targetPath) {
@@ -312,6 +167,14 @@ function checkPyPITyposquatting(deps, targetPath) {
312
167
  }
313
168
 
314
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
+
315
178
  // Ensure IOCs are downloaded (first run only, graceful failure)
316
179
  await ensureIOCs();
317
180
 
@@ -396,7 +259,7 @@ async function run(targetPath, options = {}) {
396
259
  ...pypiTyposquatThreats,
397
260
  ...entropyThreats,
398
261
  ...aiConfigThreats,
399
- ...crossFileFlows.map(f => ({
262
+ ...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
400
263
  type: f.type,
401
264
  severity: f.severity,
402
265
  message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
@@ -411,227 +274,11 @@ async function run(targetPath, options = {}) {
411
274
  threats.push(...paranoidThreats);
412
275
  }
413
276
 
414
- // Temporal analysis (--temporal flag, off by default)
415
- if (options.temporal) {
416
- if (!options._capture && !options.json) {
417
- console.log('[TEMPORAL] Analyzing lifecycle script changes (this makes network requests)...\n');
418
- }
419
- const nodeModulesPath = path.join(targetPath, 'node_modules');
420
- if (fs.existsSync(nodeModulesPath)) {
421
- const pkgNames = [];
422
- try {
423
- const items = fs.readdirSync(nodeModulesPath);
424
- for (const item of items) {
425
- if (item.startsWith('.')) continue;
426
- const itemPath = path.join(nodeModulesPath, item);
427
- try {
428
- const stat = fs.lstatSync(itemPath);
429
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
430
- if (item.startsWith('@')) {
431
- const scopedItems = fs.readdirSync(itemPath);
432
- for (const si of scopedItems) {
433
- const sp = path.join(itemPath, si);
434
- const ss = fs.lstatSync(sp);
435
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
436
- pkgNames.push(`${item}/${si}`);
437
- }
438
- }
439
- } else {
440
- pkgNames.push(item);
441
- }
442
- } catch { /* skip unreadable */ }
443
- }
444
- } catch { /* no node_modules readable */ }
445
-
446
- const TEMPORAL_CONCURRENCY = 5;
447
- for (let i = 0; i < pkgNames.length; i += TEMPORAL_CONCURRENCY) {
448
- const batch = pkgNames.slice(i, i + TEMPORAL_CONCURRENCY);
449
- const results = await Promise.allSettled(
450
- batch.map(name => detectSuddenLifecycleChange(name))
451
- );
452
- for (const r of results) {
453
- if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
454
- const det = r.value;
455
- for (const f of det.findings) {
456
- const isCriticalScript = ['preinstall', 'install', 'postinstall'].includes(f.script);
457
- const threatType = f.type === 'lifecycle_added'
458
- ? (isCriticalScript ? 'lifecycle_added_critical' : 'lifecycle_added_high')
459
- : 'lifecycle_modified';
460
- threats.push({
461
- type: threatType,
462
- severity: f.severity,
463
- message: `Package "${det.packageName}" v${det.latestVersion} ${f.type === 'lifecycle_added' ? 'added' : 'modified'} ${f.script} script (not in v${det.previousVersion}). Script: "${f.type === 'lifecycle_modified' ? f.newValue : f.value}"`,
464
- file: `node_modules/${det.packageName}/package.json`
465
- });
466
- }
467
- }
468
- }
469
- }
470
- }
471
-
472
- // Temporal AST analysis (--temporal-ast or --temporal-full flag, off by default)
473
- if (options.temporalAst) {
474
- if (!options._capture && !options.json) {
475
- console.log('[TEMPORAL-AST] Analyzing dangerous API changes (this downloads tarballs)...\n');
476
- }
477
- const nodeModulesPath = path.join(targetPath, 'node_modules');
478
- if (fs.existsSync(nodeModulesPath)) {
479
- const pkgNames = [];
480
- try {
481
- const items = fs.readdirSync(nodeModulesPath);
482
- for (const item of items) {
483
- if (item.startsWith('.')) continue;
484
- const itemPath = path.join(nodeModulesPath, item);
485
- try {
486
- const stat = fs.lstatSync(itemPath);
487
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
488
- if (item.startsWith('@')) {
489
- const scopedItems = fs.readdirSync(itemPath);
490
- for (const si of scopedItems) {
491
- const sp = path.join(itemPath, si);
492
- const ss = fs.lstatSync(sp);
493
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
494
- pkgNames.push(`${item}/${si}`);
495
- }
496
- }
497
- } else {
498
- pkgNames.push(item);
499
- }
500
- } catch { /* skip unreadable */ }
501
- }
502
- } catch { /* no node_modules readable */ }
503
-
504
- const AST_CONCURRENCY = 3;
505
- for (let i = 0; i < pkgNames.length; i += AST_CONCURRENCY) {
506
- const batch = pkgNames.slice(i, i + AST_CONCURRENCY);
507
- const results = await Promise.allSettled(
508
- batch.map(name => detectSuddenAstChanges(name))
509
- );
510
- for (const r of results) {
511
- if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
512
- const det = r.value;
513
- for (const f of det.findings) {
514
- const threatType = f.severity === 'CRITICAL' ? 'dangerous_api_added_critical'
515
- : f.severity === 'HIGH' ? 'dangerous_api_added_high'
516
- : 'dangerous_api_added_medium';
517
- threats.push({
518
- type: threatType,
519
- severity: f.severity,
520
- message: `Package "${det.packageName}" v${det.latestVersion} now uses ${f.pattern} (not in v${det.previousVersion})`,
521
- file: `node_modules/${det.packageName}/package.json`
522
- });
523
- }
524
- }
525
- }
526
- }
527
- }
528
-
529
- // Temporal publish frequency analysis (--temporal-publish or --temporal-full flag, off by default)
530
- if (options.temporalPublish) {
531
- if (!options._capture && !options.json) {
532
- console.log('[TEMPORAL-PUBLISH] Analyzing publish frequency anomalies (this makes network requests)...\n');
533
- }
534
- const nodeModulesPath = path.join(targetPath, 'node_modules');
535
- if (fs.existsSync(nodeModulesPath)) {
536
- const pkgNames = [];
537
- try {
538
- const items = fs.readdirSync(nodeModulesPath);
539
- for (const item of items) {
540
- if (item.startsWith('.')) continue;
541
- const itemPath = path.join(nodeModulesPath, item);
542
- try {
543
- const stat = fs.lstatSync(itemPath);
544
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
545
- if (item.startsWith('@')) {
546
- const scopedItems = fs.readdirSync(itemPath);
547
- for (const si of scopedItems) {
548
- const sp = path.join(itemPath, si);
549
- const ss = fs.lstatSync(sp);
550
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
551
- pkgNames.push(`${item}/${si}`);
552
- }
553
- }
554
- } else {
555
- pkgNames.push(item);
556
- }
557
- } catch { /* skip unreadable */ }
558
- }
559
- } catch { /* no node_modules readable */ }
560
-
561
- const PUBLISH_CONCURRENCY = 5;
562
- for (let i = 0; i < pkgNames.length; i += PUBLISH_CONCURRENCY) {
563
- const batch = pkgNames.slice(i, i + PUBLISH_CONCURRENCY);
564
- const results = await Promise.allSettled(
565
- batch.map(name => detectPublishAnomaly(name))
566
- );
567
- for (const r of results) {
568
- if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
569
- const det = r.value;
570
- for (const a of det.anomalies) {
571
- threats.push({
572
- type: a.type,
573
- severity: a.severity,
574
- message: a.description,
575
- file: `node_modules/${det.packageName}/package.json`
576
- });
577
- }
578
- }
579
- }
580
- }
581
- }
582
-
583
- // Temporal maintainer change analysis (--temporal-maintainer or --temporal-full flag, off by default)
584
- if (options.temporalMaintainer) {
585
- if (!options._capture && !options.json) {
586
- console.log('[TEMPORAL-MAINTAINER] Analyzing maintainer changes (this makes network requests)...\n');
587
- }
588
- const nodeModulesPath = path.join(targetPath, 'node_modules');
589
- if (fs.existsSync(nodeModulesPath)) {
590
- const pkgNames = [];
591
- try {
592
- const items = fs.readdirSync(nodeModulesPath);
593
- for (const item of items) {
594
- if (item.startsWith('.')) continue;
595
- const itemPath = path.join(nodeModulesPath, item);
596
- try {
597
- const stat = fs.lstatSync(itemPath);
598
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
599
- if (item.startsWith('@')) {
600
- const scopedItems = fs.readdirSync(itemPath);
601
- for (const si of scopedItems) {
602
- const sp = path.join(itemPath, si);
603
- const ss = fs.lstatSync(sp);
604
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
605
- pkgNames.push(`${item}/${si}`);
606
- }
607
- }
608
- } else {
609
- pkgNames.push(item);
610
- }
611
- } catch { /* skip unreadable */ }
612
- }
613
- } catch { /* no node_modules readable */ }
614
-
615
- const MAINTAINER_CONCURRENCY = 5;
616
- for (let i = 0; i < pkgNames.length; i += MAINTAINER_CONCURRENCY) {
617
- const batch = pkgNames.slice(i, i + MAINTAINER_CONCURRENCY);
618
- const results = await Promise.allSettled(
619
- batch.map(name => detectMaintainerChange(name))
620
- );
621
- for (const r of results) {
622
- if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
623
- const det = r.value;
624
- for (const f of det.findings) {
625
- threats.push({
626
- type: f.type,
627
- severity: f.severity,
628
- message: f.description,
629
- file: `node_modules/${det.packageName}/package.json`
630
- });
631
- }
632
- }
633
- }
634
- }
277
+ // Temporal analyses (--temporal, --temporal-ast, --temporal-publish, --temporal-maintainer)
278
+ if (options.temporal || options.temporalAst || options.temporalPublish || options.temporalMaintainer) {
279
+ const pkgNames = listInstalledPackages(targetPath);
280
+ const temporalThreats = await runTemporalAnalyses(targetPath, options, pkgNames);
281
+ threats.push(...temporalThreats);
635
282
  }
636
283
 
637
284
  // Sandbox integration
@@ -695,62 +342,12 @@ async function run(targetPath, options = {}) {
695
342
  .map(t => ({ rule: t.rule_id, type: t.type, points: t.points, reason: t.message }))
696
343
  .sort((a, b) => b.points - a.points);
697
344
 
698
- // ============================================
699
- // PER-FILE MAX SCORING (v2.2.11)
700
- // ============================================
701
-
702
- // 1. Separate deduped threats into package-level and file-level
703
- const packageLevelThreats = [];
704
- const fileLevelThreats = [];
705
- for (const t of deduped) {
706
- if (isPackageLevelThreat(t)) {
707
- packageLevelThreats.push(t);
708
- } else {
709
- fileLevelThreats.push(t);
710
- }
711
- }
712
-
713
- // 2. Group file-level threats by file
714
- const fileGroups = new Map();
715
- for (const t of fileLevelThreats) {
716
- const key = t.file || '(unknown)';
717
- if (!fileGroups.has(key)) fileGroups.set(key, []);
718
- fileGroups.get(key).push(t);
719
- }
720
-
721
- // 3. Compute per-file scores and find the most suspicious file
722
- let maxFileScore = 0;
723
- let mostSuspiciousFile = null;
724
- const fileScores = {};
725
- for (const [file, fileThreats] of fileGroups) {
726
- const score = computeGroupScore(fileThreats);
727
- fileScores[file] = score;
728
- if (score > maxFileScore) {
729
- maxFileScore = score;
730
- mostSuspiciousFile = file;
731
- }
732
- }
733
-
734
- // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
735
- const packageScore = computeGroupScore(packageLevelThreats);
736
-
737
- // 5. Final score = max file score + package-level score, capped at 100
738
- const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
739
-
740
- // 6. Old global score for comparison (sum of ALL findings)
741
- const globalRiskScore = computeGroupScore(deduped);
742
-
743
- // 7. Severity counts (global, for summary display)
744
- const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
745
- const highCount = deduped.filter(t => t.severity === 'HIGH').length;
746
- const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
747
- const lowCount = deduped.filter(t => t.severity === 'LOW').length;
748
-
749
- const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
750
- : riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
751
- : riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
752
- : riskScore > 0 ? 'LOW'
753
- : 'SAFE';
345
+ // Per-file max scoring (v2.2.11)
346
+ const {
347
+ riskScore, riskLevel, globalRiskScore,
348
+ maxFileScore, packageScore, mostSuspiciousFile, fileScores,
349
+ criticalCount, highCount, mediumCount, lowCount
350
+ } = calculateRiskScore(deduped);
754
351
 
755
352
  // Python scan metadata
756
353
  const pythonInfo = pythonDeps.length > 0 ? {
@@ -785,169 +382,15 @@ async function run(targetPath, options = {}) {
785
382
  // _capture mode: return result directly without printing (used by diff.js)
786
383
  if (options._capture) {
787
384
  setExtraExcludes([]);
385
+ clearFileListCache();
788
386
  return result;
789
387
  }
790
388
 
791
- // JSON output
792
- if (options.json) {
793
- console.log(JSON.stringify(result, null, 2));
794
- }
795
- // HTML output
796
- else if (options.html) {
797
- saveReport(result, options.html);
798
- console.log(`[OK] HTML report generated: ${options.html}`);
799
- }
800
- // SARIF output
801
- else if (options.sarif) {
802
- saveSARIF(result, options.sarif);
803
- console.log(`[OK] SARIF report generated: ${options.sarif}`);
804
- }
805
- // Explain output
806
- else if (options.explain) {
807
- if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
808
- else console.log('');
809
-
810
- const explainScoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
811
- console.log(`[SCORE] ${result.summary.riskScore}/100 [${explainScoreBar}] ${result.summary.riskLevel}`);
812
- if (mostSuspiciousFile) {
813
- console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
814
- if (packageScore > 0) {
815
- console.log(` Package-level: +${packageScore} pts`);
816
- }
817
- }
818
- console.log('');
819
-
820
- if (options.breakdown && breakdown.length > 0) {
821
- console.log('[BREAKDOWN] Score contributors:');
822
- for (const entry of breakdown) {
823
- const pts = String(entry.points).padStart(2);
824
- console.log(` +${pts} ${entry.reason} (${entry.rule})`);
825
- }
826
- if (globalRiskScore !== riskScore) {
827
- console.log(' ----');
828
- console.log(` Global sum: ${globalRiskScore}, Per-file max: ${riskScore}`);
829
- }
830
- console.log('');
831
- }
832
-
833
- if (pythonInfo) {
834
- console.log(`[PYTHON] ${pythonInfo.dependencies} dependencies detected (${pythonInfo.files.join(', ')})`);
835
- if (pythonInfo.threats > 0) {
836
- console.log(`[PYTHON] ${pythonInfo.threats} malicious PyPI package(s) found!\n`);
837
- } else {
838
- console.log(`[PYTHON] No known malicious PyPI packages.\n`);
839
- }
840
- }
841
-
842
- if (enrichedThreats.length === 0) {
843
- console.log('[OK] No threats detected.\n');
844
- } else {
845
- console.log(`[ALERT] ${enrichedThreats.length} threat(s) detected:\n`);
846
- enrichedThreats.forEach((t, i) => {
847
- console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
848
- const countStr = t.count > 1 ? ` (x${t.count})` : '';
849
- console.log(` ${i + 1}. [${t.severity}] ${t.rule_name}${countStr}`);
850
- console.log(` Rule ID: ${t.rule_id}`);
851
- console.log(` File: ${t.file}`);
852
- if (t.line) console.log(` Line: ${t.line}`);
853
- console.log(` Confidence: ${t.confidence}`);
854
- console.log(` Message: ${t.message}`);
855
- if (t.mitre) console.log(` MITRE: ${t.mitre} (https://attack.mitre.org/techniques/${t.mitre.replace('.', '/')})`);
856
- if (t.references && t.references.length > 0) {
857
- console.log(` References:`);
858
- t.references.forEach(ref => console.log(` - ${ref}`));
859
- }
860
- console.log(` Playbook: ${t.playbook}`);
861
- console.log('');
862
- });
863
- }
864
-
865
- // Sandbox section (explain)
866
- if (sandboxData) {
867
- console.log(`\n[SANDBOX] Dynamic analysis — ${sandboxData.package}`);
868
- console.log(` Score: ${sandboxData.score}/100`);
869
- console.log(` Severity: ${sandboxData.severity}`);
870
- if (sandboxData.findings.length === 0) {
871
- console.log(' No suspicious behavior detected.\n');
872
- } else {
873
- console.log(` ${sandboxData.findings.length} finding(s):`);
874
- sandboxData.findings.forEach(f => {
875
- console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
876
- });
877
- console.log('');
878
- }
879
- }
880
- }
881
- // Normal output
882
- else {
883
- if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
884
- else console.log('');
885
-
886
- const scoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
887
- console.log(`[SCORE] ${result.summary.riskScore}/100 [${scoreBar}] ${result.summary.riskLevel}`);
888
- if (mostSuspiciousFile) {
889
- console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
890
- if (packageScore > 0) {
891
- console.log(` Package-level: +${packageScore} pts`);
892
- }
893
- }
894
- console.log('');
895
-
896
- if (options.breakdown && breakdown.length > 0) {
897
- console.log('[BREAKDOWN] Score contributors:');
898
- for (const entry of breakdown) {
899
- const pts = String(entry.points).padStart(2);
900
- console.log(` +${pts} ${entry.reason} (${entry.rule})`);
901
- }
902
- if (globalRiskScore !== riskScore) {
903
- console.log(' ----');
904
- console.log(` Global sum: ${globalRiskScore}, Per-file max: ${riskScore}`);
905
- }
906
- console.log('');
907
- }
908
-
909
- if (pythonInfo) {
910
- console.log(`[PYTHON] ${pythonInfo.dependencies} dependencies detected (${pythonInfo.files.join(', ')})`);
911
- if (pythonInfo.threats > 0) {
912
- console.log(`[PYTHON] ${pythonInfo.threats} malicious PyPI package(s) found!\n`);
913
- } else {
914
- console.log(`[PYTHON] No known malicious PyPI packages.\n`);
915
- }
916
- }
917
-
918
- if (deduped.length === 0) {
919
- console.log('[OK] No threats detected.\n');
920
- } else {
921
- console.log(`[ALERT] ${deduped.length} threat(s) detected:\n`);
922
- deduped.forEach((t, i) => {
923
- const countStr = t.count > 1 ? ` (x${t.count})` : '';
924
- console.log(` ${i + 1}. [${t.severity}] ${t.type}${countStr}`);
925
- console.log(` ${t.message}`);
926
- console.log(` File: ${t.file}`);
927
- const playbook = getPlaybook(t.type);
928
- if (playbook) {
929
- console.log(` \u2192 ${playbook}`);
930
- }
931
- console.log('');
932
- });
933
- }
934
-
935
- // Sandbox section (normal)
936
- if (sandboxData) {
937
- console.log(`[SANDBOX] Dynamic analysis — ${sandboxData.package}`);
938
- console.log(` Score: ${sandboxData.score}/100`);
939
- console.log(` Severity: ${sandboxData.severity}`);
940
- if (sandboxData.findings.length === 0) {
941
- console.log(' No suspicious behavior detected.\n');
942
- } else {
943
- console.log(` ${sandboxData.findings.length} finding(s):`);
944
- sandboxData.findings.forEach(f => {
945
- console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
946
- });
947
- console.log('');
948
- }
949
- }
950
- }
389
+ formatOutput(result, options, {
390
+ spinner, sandboxData, mostSuspiciousFile, maxFileScore,
391
+ packageScore, globalRiskScore, deduped, enrichedThreats,
392
+ pythonInfo, breakdown, targetPath
393
+ });
951
394
 
952
395
  // Send webhook if configured
953
396
  if (options.webhook && enrichedThreats.length > 0) {
@@ -971,8 +414,9 @@ async function run(targetPath, options = {}) {
971
414
  const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
972
415
  const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
973
416
 
974
- // Clear runtime excludes
417
+ // Clear runtime state
975
418
  setExtraExcludes([]);
419
+ clearFileListCache();
976
420
 
977
421
  return Math.min(failingThreats.length, 125);
978
422
  }