muaddib-scanner 2.2.14 → 2.2.16
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/package.json +1 -1
- package/src/index.js +53 -504
- package/src/ioc/scraper.js +60 -46
- package/src/monitor.js +56 -10
- package/src/output-formatter.js +192 -0
- package/src/scanner/ast-detectors.js +933 -0
- package/src/scanner/ast.js +36 -951
- package/src/scanner/typosquat.js +6 -0
- package/src/scoring.js +213 -0
- package/src/temporal-runner.js +139 -0
- package/src/utils.js +28 -2
package/package.json
CHANGED
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,156 +19,13 @@ 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 {
|
|
25
|
-
const {
|
|
26
|
-
const {
|
|
27
|
-
const {
|
|
28
|
-
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages } = 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;
|
|
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');
|
|
64
26
|
|
|
65
27
|
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
66
28
|
|
|
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
|
-
]);
|
|
87
|
-
|
|
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
|
-
}
|
|
173
|
-
|
|
174
29
|
// Paranoid mode scanner
|
|
175
30
|
function scanParanoid(targetPath) {
|
|
176
31
|
const threats = [];
|
|
@@ -342,19 +197,46 @@ async function run(targetPath, options = {}) {
|
|
|
342
197
|
// Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
|
|
343
198
|
const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
|
|
344
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
|
+
|
|
345
203
|
// Cross-file module graph analysis (before individual scanners)
|
|
204
|
+
// Wrapped in yieldThen to unblock spinner animation
|
|
346
205
|
let crossFileFlows = [];
|
|
347
206
|
if (!options.noModuleGraph) {
|
|
348
207
|
try {
|
|
349
|
-
const graph = buildModuleGraph(targetPath);
|
|
350
|
-
const tainted = annotateTaintedExports(graph, targetPath);
|
|
351
|
-
crossFileFlows = detectCrossFileFlows(graph, tainted, targetPath);
|
|
208
|
+
const graph = await yieldThen(() => buildModuleGraph(targetPath));
|
|
209
|
+
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
210
|
+
crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
|
|
352
211
|
} catch {
|
|
353
212
|
// Graceful fallback — module graph is best-effort
|
|
354
213
|
}
|
|
355
214
|
}
|
|
356
215
|
|
|
357
216
|
// Parallel execution of all independent scanners
|
|
217
|
+
// Sync scanners use yieldThen() to yield to event loop (keeps spinner animating)
|
|
218
|
+
let scanResult;
|
|
219
|
+
try {
|
|
220
|
+
scanResult = await Promise.all([
|
|
221
|
+
scanPackageJson(targetPath),
|
|
222
|
+
scanShellScripts(targetPath),
|
|
223
|
+
analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
|
|
224
|
+
yieldThen(() => detectObfuscation(targetPath)),
|
|
225
|
+
scanDependencies(targetPath),
|
|
226
|
+
scanHashes(targetPath),
|
|
227
|
+
analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
|
|
228
|
+
scanTyposquatting(targetPath),
|
|
229
|
+
yieldThen(() => scanGitHubActions(targetPath)),
|
|
230
|
+
yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
|
|
231
|
+
yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
|
|
232
|
+
yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
|
|
233
|
+
yieldThen(() => scanAIConfig(targetPath))
|
|
234
|
+
]);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (spinner) spinner.fail(`[MUADDIB] Scan failed: ${err.message}`);
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
|
|
358
240
|
const [
|
|
359
241
|
packageThreats,
|
|
360
242
|
shellThreats,
|
|
@@ -369,21 +251,7 @@ async function run(targetPath, options = {}) {
|
|
|
369
251
|
pypiTyposquatThreats,
|
|
370
252
|
entropyThreats,
|
|
371
253
|
aiConfigThreats
|
|
372
|
-
] =
|
|
373
|
-
scanPackageJson(targetPath),
|
|
374
|
-
scanShellScripts(targetPath),
|
|
375
|
-
analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
|
|
376
|
-
Promise.resolve(detectObfuscation(targetPath)),
|
|
377
|
-
scanDependencies(targetPath),
|
|
378
|
-
scanHashes(targetPath),
|
|
379
|
-
analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
|
|
380
|
-
scanTyposquatting(targetPath),
|
|
381
|
-
Promise.resolve(scanGitHubActions(targetPath)),
|
|
382
|
-
Promise.resolve(matchPythonIOCs(pythonDeps, targetPath)),
|
|
383
|
-
Promise.resolve(checkPyPITyposquatting(pythonDeps, targetPath)),
|
|
384
|
-
Promise.resolve(scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
|
|
385
|
-
Promise.resolve(scanAIConfig(targetPath))
|
|
386
|
-
]);
|
|
254
|
+
] = scanResult;
|
|
387
255
|
|
|
388
256
|
// Stop spinner now that scanning is complete
|
|
389
257
|
if (spinner) {
|
|
@@ -419,127 +287,11 @@ async function run(targetPath, options = {}) {
|
|
|
419
287
|
threats.push(...paranoidThreats);
|
|
420
288
|
}
|
|
421
289
|
|
|
422
|
-
// Temporal
|
|
423
|
-
if (options.temporal) {
|
|
424
|
-
if (!options._capture && !options.json) {
|
|
425
|
-
console.log('[TEMPORAL] Analyzing lifecycle script changes (this makes network requests)...\n');
|
|
426
|
-
}
|
|
290
|
+
// Temporal analyses (--temporal, --temporal-ast, --temporal-publish, --temporal-maintainer)
|
|
291
|
+
if (options.temporal || options.temporalAst || options.temporalPublish || options.temporalMaintainer) {
|
|
427
292
|
const pkgNames = listInstalledPackages(targetPath);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
for (let i = 0; i < pkgNames.length; i += TEMPORAL_CONCURRENCY) {
|
|
431
|
-
const batch = pkgNames.slice(i, i + TEMPORAL_CONCURRENCY);
|
|
432
|
-
const results = await Promise.allSettled(
|
|
433
|
-
batch.map(name => detectSuddenLifecycleChange(name))
|
|
434
|
-
);
|
|
435
|
-
for (const r of results) {
|
|
436
|
-
if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
|
|
437
|
-
const det = r.value;
|
|
438
|
-
for (const f of det.findings) {
|
|
439
|
-
const isCriticalScript = ['preinstall', 'install', 'postinstall'].includes(f.script);
|
|
440
|
-
const threatType = f.type === 'lifecycle_added'
|
|
441
|
-
? (isCriticalScript ? 'lifecycle_added_critical' : 'lifecycle_added_high')
|
|
442
|
-
: 'lifecycle_modified';
|
|
443
|
-
threats.push({
|
|
444
|
-
type: threatType,
|
|
445
|
-
severity: f.severity,
|
|
446
|
-
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}"`,
|
|
447
|
-
file: `node_modules/${det.packageName}/package.json`
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Temporal AST analysis (--temporal-ast or --temporal-full flag, off by default)
|
|
456
|
-
if (options.temporalAst) {
|
|
457
|
-
if (!options._capture && !options.json) {
|
|
458
|
-
console.log('[TEMPORAL-AST] Analyzing dangerous API changes (this downloads tarballs)...\n');
|
|
459
|
-
}
|
|
460
|
-
const pkgNames = listInstalledPackages(targetPath);
|
|
461
|
-
{
|
|
462
|
-
const AST_CONCURRENCY = 3;
|
|
463
|
-
for (let i = 0; i < pkgNames.length; i += AST_CONCURRENCY) {
|
|
464
|
-
const batch = pkgNames.slice(i, i + AST_CONCURRENCY);
|
|
465
|
-
const results = await Promise.allSettled(
|
|
466
|
-
batch.map(name => detectSuddenAstChanges(name))
|
|
467
|
-
);
|
|
468
|
-
for (const r of results) {
|
|
469
|
-
if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
|
|
470
|
-
const det = r.value;
|
|
471
|
-
for (const f of det.findings) {
|
|
472
|
-
const threatType = f.severity === 'CRITICAL' ? 'dangerous_api_added_critical'
|
|
473
|
-
: f.severity === 'HIGH' ? 'dangerous_api_added_high'
|
|
474
|
-
: 'dangerous_api_added_medium';
|
|
475
|
-
threats.push({
|
|
476
|
-
type: threatType,
|
|
477
|
-
severity: f.severity,
|
|
478
|
-
message: `Package "${det.packageName}" v${det.latestVersion} now uses ${f.pattern} (not in v${det.previousVersion})`,
|
|
479
|
-
file: `node_modules/${det.packageName}/package.json`
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Temporal publish frequency analysis (--temporal-publish or --temporal-full flag, off by default)
|
|
488
|
-
if (options.temporalPublish) {
|
|
489
|
-
if (!options._capture && !options.json) {
|
|
490
|
-
console.log('[TEMPORAL-PUBLISH] Analyzing publish frequency anomalies (this makes network requests)...\n');
|
|
491
|
-
}
|
|
492
|
-
const pkgNames = listInstalledPackages(targetPath);
|
|
493
|
-
{
|
|
494
|
-
const PUBLISH_CONCURRENCY = 5;
|
|
495
|
-
for (let i = 0; i < pkgNames.length; i += PUBLISH_CONCURRENCY) {
|
|
496
|
-
const batch = pkgNames.slice(i, i + PUBLISH_CONCURRENCY);
|
|
497
|
-
const results = await Promise.allSettled(
|
|
498
|
-
batch.map(name => detectPublishAnomaly(name))
|
|
499
|
-
);
|
|
500
|
-
for (const r of results) {
|
|
501
|
-
if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
|
|
502
|
-
const det = r.value;
|
|
503
|
-
for (const a of det.anomalies) {
|
|
504
|
-
threats.push({
|
|
505
|
-
type: a.type,
|
|
506
|
-
severity: a.severity,
|
|
507
|
-
message: a.description,
|
|
508
|
-
file: `node_modules/${det.packageName}/package.json`
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Temporal maintainer change analysis (--temporal-maintainer or --temporal-full flag, off by default)
|
|
517
|
-
if (options.temporalMaintainer) {
|
|
518
|
-
if (!options._capture && !options.json) {
|
|
519
|
-
console.log('[TEMPORAL-MAINTAINER] Analyzing maintainer changes (this makes network requests)...\n');
|
|
520
|
-
}
|
|
521
|
-
const pkgNames = listInstalledPackages(targetPath);
|
|
522
|
-
{
|
|
523
|
-
const MAINTAINER_CONCURRENCY = 5;
|
|
524
|
-
for (let i = 0; i < pkgNames.length; i += MAINTAINER_CONCURRENCY) {
|
|
525
|
-
const batch = pkgNames.slice(i, i + MAINTAINER_CONCURRENCY);
|
|
526
|
-
const results = await Promise.allSettled(
|
|
527
|
-
batch.map(name => detectMaintainerChange(name))
|
|
528
|
-
);
|
|
529
|
-
for (const r of results) {
|
|
530
|
-
if (r.status !== 'fulfilled' || !r.value.suspicious) continue;
|
|
531
|
-
const det = r.value;
|
|
532
|
-
for (const f of det.findings) {
|
|
533
|
-
threats.push({
|
|
534
|
-
type: f.type,
|
|
535
|
-
severity: f.severity,
|
|
536
|
-
message: f.description,
|
|
537
|
-
file: `node_modules/${det.packageName}/package.json`
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
293
|
+
const temporalThreats = await runTemporalAnalyses(targetPath, options, pkgNames);
|
|
294
|
+
threats.push(...temporalThreats);
|
|
543
295
|
}
|
|
544
296
|
|
|
545
297
|
// Sandbox integration
|
|
@@ -603,62 +355,12 @@ async function run(targetPath, options = {}) {
|
|
|
603
355
|
.map(t => ({ rule: t.rule_id, type: t.type, points: t.points, reason: t.message }))
|
|
604
356
|
.sort((a, b) => b.points - a.points);
|
|
605
357
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const fileLevelThreats = [];
|
|
613
|
-
for (const t of deduped) {
|
|
614
|
-
if (isPackageLevelThreat(t)) {
|
|
615
|
-
packageLevelThreats.push(t);
|
|
616
|
-
} else {
|
|
617
|
-
fileLevelThreats.push(t);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// 2. Group file-level threats by file
|
|
622
|
-
const fileGroups = new Map();
|
|
623
|
-
for (const t of fileLevelThreats) {
|
|
624
|
-
const key = t.file || '(unknown)';
|
|
625
|
-
if (!fileGroups.has(key)) fileGroups.set(key, []);
|
|
626
|
-
fileGroups.get(key).push(t);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// 3. Compute per-file scores and find the most suspicious file
|
|
630
|
-
let maxFileScore = 0;
|
|
631
|
-
let mostSuspiciousFile = null;
|
|
632
|
-
const fileScores = {};
|
|
633
|
-
for (const [file, fileThreats] of fileGroups) {
|
|
634
|
-
const score = computeGroupScore(fileThreats);
|
|
635
|
-
fileScores[file] = score;
|
|
636
|
-
if (score > maxFileScore) {
|
|
637
|
-
maxFileScore = score;
|
|
638
|
-
mostSuspiciousFile = file;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
|
|
643
|
-
const packageScore = computeGroupScore(packageLevelThreats);
|
|
644
|
-
|
|
645
|
-
// 5. Final score = max file score + package-level score, capped at 100
|
|
646
|
-
const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
|
|
647
|
-
|
|
648
|
-
// 6. Old global score for comparison (sum of ALL findings)
|
|
649
|
-
const globalRiskScore = computeGroupScore(deduped);
|
|
650
|
-
|
|
651
|
-
// 7. Severity counts (global, for summary display)
|
|
652
|
-
const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
|
|
653
|
-
const highCount = deduped.filter(t => t.severity === 'HIGH').length;
|
|
654
|
-
const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
|
|
655
|
-
const lowCount = deduped.filter(t => t.severity === 'LOW').length;
|
|
656
|
-
|
|
657
|
-
const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
|
|
658
|
-
: riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
|
|
659
|
-
: riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
|
|
660
|
-
: riskScore > 0 ? 'LOW'
|
|
661
|
-
: 'SAFE';
|
|
358
|
+
// Per-file max scoring (v2.2.11)
|
|
359
|
+
const {
|
|
360
|
+
riskScore, riskLevel, globalRiskScore,
|
|
361
|
+
maxFileScore, packageScore, mostSuspiciousFile, fileScores,
|
|
362
|
+
criticalCount, highCount, mediumCount, lowCount
|
|
363
|
+
} = calculateRiskScore(deduped);
|
|
662
364
|
|
|
663
365
|
// Python scan metadata
|
|
664
366
|
const pythonInfo = pythonDeps.length > 0 ? {
|
|
@@ -693,169 +395,15 @@ async function run(targetPath, options = {}) {
|
|
|
693
395
|
// _capture mode: return result directly without printing (used by diff.js)
|
|
694
396
|
if (options._capture) {
|
|
695
397
|
setExtraExcludes([]);
|
|
398
|
+
clearFileListCache();
|
|
696
399
|
return result;
|
|
697
400
|
}
|
|
698
401
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
else if (options.html) {
|
|
705
|
-
saveReport(result, options.html);
|
|
706
|
-
console.log(`[OK] HTML report generated: ${options.html}`);
|
|
707
|
-
}
|
|
708
|
-
// SARIF output
|
|
709
|
-
else if (options.sarif) {
|
|
710
|
-
saveSARIF(result, options.sarif);
|
|
711
|
-
console.log(`[OK] SARIF report generated: ${options.sarif}`);
|
|
712
|
-
}
|
|
713
|
-
// Explain output
|
|
714
|
-
else if (options.explain) {
|
|
715
|
-
if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
|
|
716
|
-
else console.log('');
|
|
717
|
-
|
|
718
|
-
const explainScoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
|
|
719
|
-
console.log(`[SCORE] ${result.summary.riskScore}/100 [${explainScoreBar}] ${result.summary.riskLevel}`);
|
|
720
|
-
if (mostSuspiciousFile) {
|
|
721
|
-
console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
|
|
722
|
-
if (packageScore > 0) {
|
|
723
|
-
console.log(` Package-level: +${packageScore} pts`);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
console.log('');
|
|
727
|
-
|
|
728
|
-
if (options.breakdown && breakdown.length > 0) {
|
|
729
|
-
console.log('[BREAKDOWN] Score contributors:');
|
|
730
|
-
for (const entry of breakdown) {
|
|
731
|
-
const pts = String(entry.points).padStart(2);
|
|
732
|
-
console.log(` +${pts} ${entry.reason} (${entry.rule})`);
|
|
733
|
-
}
|
|
734
|
-
if (globalRiskScore !== riskScore) {
|
|
735
|
-
console.log(' ----');
|
|
736
|
-
console.log(` Global sum: ${globalRiskScore}, Per-file max: ${riskScore}`);
|
|
737
|
-
}
|
|
738
|
-
console.log('');
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (pythonInfo) {
|
|
742
|
-
console.log(`[PYTHON] ${pythonInfo.dependencies} dependencies detected (${pythonInfo.files.join(', ')})`);
|
|
743
|
-
if (pythonInfo.threats > 0) {
|
|
744
|
-
console.log(`[PYTHON] ${pythonInfo.threats} malicious PyPI package(s) found!\n`);
|
|
745
|
-
} else {
|
|
746
|
-
console.log(`[PYTHON] No known malicious PyPI packages.\n`);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (enrichedThreats.length === 0) {
|
|
751
|
-
console.log('[OK] No threats detected.\n');
|
|
752
|
-
} else {
|
|
753
|
-
console.log(`[ALERT] ${enrichedThreats.length} threat(s) detected:\n`);
|
|
754
|
-
enrichedThreats.forEach((t, i) => {
|
|
755
|
-
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
756
|
-
const countStr = t.count > 1 ? ` (x${t.count})` : '';
|
|
757
|
-
console.log(` ${i + 1}. [${t.severity}] ${t.rule_name}${countStr}`);
|
|
758
|
-
console.log(` Rule ID: ${t.rule_id}`);
|
|
759
|
-
console.log(` File: ${t.file}`);
|
|
760
|
-
if (t.line) console.log(` Line: ${t.line}`);
|
|
761
|
-
console.log(` Confidence: ${t.confidence}`);
|
|
762
|
-
console.log(` Message: ${t.message}`);
|
|
763
|
-
if (t.mitre) console.log(` MITRE: ${t.mitre} (https://attack.mitre.org/techniques/${t.mitre.replace('.', '/')})`);
|
|
764
|
-
if (t.references && t.references.length > 0) {
|
|
765
|
-
console.log(` References:`);
|
|
766
|
-
t.references.forEach(ref => console.log(` - ${ref}`));
|
|
767
|
-
}
|
|
768
|
-
console.log(` Playbook: ${t.playbook}`);
|
|
769
|
-
console.log('');
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// Sandbox section (explain)
|
|
774
|
-
if (sandboxData) {
|
|
775
|
-
console.log(`\n[SANDBOX] Dynamic analysis — ${sandboxData.package}`);
|
|
776
|
-
console.log(` Score: ${sandboxData.score}/100`);
|
|
777
|
-
console.log(` Severity: ${sandboxData.severity}`);
|
|
778
|
-
if (sandboxData.findings.length === 0) {
|
|
779
|
-
console.log(' No suspicious behavior detected.\n');
|
|
780
|
-
} else {
|
|
781
|
-
console.log(` ${sandboxData.findings.length} finding(s):`);
|
|
782
|
-
sandboxData.findings.forEach(f => {
|
|
783
|
-
console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
|
|
784
|
-
});
|
|
785
|
-
console.log('');
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
// Normal output
|
|
790
|
-
else {
|
|
791
|
-
if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
|
|
792
|
-
else console.log('');
|
|
793
|
-
|
|
794
|
-
const scoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
|
|
795
|
-
console.log(`[SCORE] ${result.summary.riskScore}/100 [${scoreBar}] ${result.summary.riskLevel}`);
|
|
796
|
-
if (mostSuspiciousFile) {
|
|
797
|
-
console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
|
|
798
|
-
if (packageScore > 0) {
|
|
799
|
-
console.log(` Package-level: +${packageScore} pts`);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
console.log('');
|
|
803
|
-
|
|
804
|
-
if (options.breakdown && breakdown.length > 0) {
|
|
805
|
-
console.log('[BREAKDOWN] Score contributors:');
|
|
806
|
-
for (const entry of breakdown) {
|
|
807
|
-
const pts = String(entry.points).padStart(2);
|
|
808
|
-
console.log(` +${pts} ${entry.reason} (${entry.rule})`);
|
|
809
|
-
}
|
|
810
|
-
if (globalRiskScore !== riskScore) {
|
|
811
|
-
console.log(' ----');
|
|
812
|
-
console.log(` Global sum: ${globalRiskScore}, Per-file max: ${riskScore}`);
|
|
813
|
-
}
|
|
814
|
-
console.log('');
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
if (pythonInfo) {
|
|
818
|
-
console.log(`[PYTHON] ${pythonInfo.dependencies} dependencies detected (${pythonInfo.files.join(', ')})`);
|
|
819
|
-
if (pythonInfo.threats > 0) {
|
|
820
|
-
console.log(`[PYTHON] ${pythonInfo.threats} malicious PyPI package(s) found!\n`);
|
|
821
|
-
} else {
|
|
822
|
-
console.log(`[PYTHON] No known malicious PyPI packages.\n`);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
if (deduped.length === 0) {
|
|
827
|
-
console.log('[OK] No threats detected.\n');
|
|
828
|
-
} else {
|
|
829
|
-
console.log(`[ALERT] ${deduped.length} threat(s) detected:\n`);
|
|
830
|
-
deduped.forEach((t, i) => {
|
|
831
|
-
const countStr = t.count > 1 ? ` (x${t.count})` : '';
|
|
832
|
-
console.log(` ${i + 1}. [${t.severity}] ${t.type}${countStr}`);
|
|
833
|
-
console.log(` ${t.message}`);
|
|
834
|
-
console.log(` File: ${t.file}`);
|
|
835
|
-
const playbook = getPlaybook(t.type);
|
|
836
|
-
if (playbook) {
|
|
837
|
-
console.log(` \u2192 ${playbook}`);
|
|
838
|
-
}
|
|
839
|
-
console.log('');
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Sandbox section (normal)
|
|
844
|
-
if (sandboxData) {
|
|
845
|
-
console.log(`[SANDBOX] Dynamic analysis — ${sandboxData.package}`);
|
|
846
|
-
console.log(` Score: ${sandboxData.score}/100`);
|
|
847
|
-
console.log(` Severity: ${sandboxData.severity}`);
|
|
848
|
-
if (sandboxData.findings.length === 0) {
|
|
849
|
-
console.log(' No suspicious behavior detected.\n');
|
|
850
|
-
} else {
|
|
851
|
-
console.log(` ${sandboxData.findings.length} finding(s):`);
|
|
852
|
-
sandboxData.findings.forEach(f => {
|
|
853
|
-
console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
|
|
854
|
-
});
|
|
855
|
-
console.log('');
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
402
|
+
formatOutput(result, options, {
|
|
403
|
+
spinner, sandboxData, mostSuspiciousFile, maxFileScore,
|
|
404
|
+
packageScore, globalRiskScore, deduped, enrichedThreats,
|
|
405
|
+
pythonInfo, breakdown, targetPath
|
|
406
|
+
});
|
|
859
407
|
|
|
860
408
|
// Send webhook if configured
|
|
861
409
|
if (options.webhook && enrichedThreats.length > 0) {
|
|
@@ -879,8 +427,9 @@ async function run(targetPath, options = {}) {
|
|
|
879
427
|
const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
|
|
880
428
|
const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
|
|
881
429
|
|
|
882
|
-
// Clear runtime
|
|
430
|
+
// Clear runtime state
|
|
883
431
|
setExtraExcludes([]);
|
|
432
|
+
clearFileListCache();
|
|
884
433
|
|
|
885
434
|
return Math.min(failingThreats.length, 125);
|
|
886
435
|
}
|