muaddib-scanner 2.7.8 → 2.7.10
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/README.md +4 -4
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/ioc/scraper.js +36 -5
- package/src/ioc/updater.js +1 -1
- package/src/scoring.js +42 -17
- package/src/shared/download.js +5 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
|
|
32
32
|
|
|
33
|
-
MUAD'DIB combines **14 parallel scanners** (
|
|
33
|
+
MUAD'DIB combines **14 parallel scanners** (134 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **per-file max scoring**, Docker sandbox with **monkey-patching preload** for time-bomb detection, **behavioral anomaly detection**, and **ground truth validation** to detect threats AND guide your response — even before they appear in any IOC database.
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -195,7 +195,7 @@ muaddib replay # Ground truth validation (46/49 TPR)
|
|
|
195
195
|
| GitHub Actions | Shai-Hulud backdoor detection |
|
|
196
196
|
| Hash Scanner | Known malicious file hashes |
|
|
197
197
|
|
|
198
|
-
###
|
|
198
|
+
### 134 detection rules
|
|
199
199
|
|
|
200
200
|
All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v262) for the complete rules reference.
|
|
201
201
|
|
|
@@ -286,7 +286,7 @@ repos:
|
|
|
286
286
|
| **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
|
|
287
287
|
| **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
|
|
288
288
|
|
|
289
|
-
**
|
|
289
|
+
**2166 tests** across 49 files. **134 rules** (129 RULES + 5 PARANOID).
|
|
290
290
|
|
|
291
291
|
> **Methodology caveats:**
|
|
292
292
|
> - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
|
|
@@ -327,7 +327,7 @@ npm test
|
|
|
327
327
|
|
|
328
328
|
### Testing
|
|
329
329
|
|
|
330
|
-
- **
|
|
330
|
+
- **2166 tests** across 49 modular test files
|
|
331
331
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
332
332
|
- **Datadog 17K benchmark** - 17,922 real malware samples
|
|
333
333
|
- **Ground truth validation** - 51 real-world attacks (93.9% TPR)
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -592,7 +592,8 @@ async function run(targetPath, options = {}) {
|
|
|
592
592
|
// Enrich each threat with rules
|
|
593
593
|
const enrichedThreats = deduped.map(t => {
|
|
594
594
|
const rule = getRule(t.type);
|
|
595
|
-
const
|
|
595
|
+
const confFactor = { high: 1.0, medium: 0.85, low: 0.6 }[rule.confidence] || 1.0;
|
|
596
|
+
const points = Math.round((SEVERITY_WEIGHTS[t.severity] || 0) * confFactor);
|
|
596
597
|
return {
|
|
597
598
|
...t,
|
|
598
599
|
rule_id: rule.id || t.type,
|
package/src/ioc/scraper.js
CHANGED
|
@@ -154,6 +154,8 @@ function loadStaticIOCs() {
|
|
|
154
154
|
|
|
155
155
|
const MAX_REDIRECTS = 5;
|
|
156
156
|
const MAX_RESPONSE_SIZE = 200 * 1024 * 1024; // 200MB
|
|
157
|
+
const MAX_ENTRY_UNCOMPRESSED = 50 * 1024 * 1024; // 50MB per zip entry
|
|
158
|
+
const MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024; // 500MB total budget per zip
|
|
157
159
|
|
|
158
160
|
function fetchJSON(url, options = {}, redirectCount = 0) {
|
|
159
161
|
return new Promise((resolve, reject) => {
|
|
@@ -762,6 +764,7 @@ async function scrapeOSVDataDump() {
|
|
|
762
764
|
|
|
763
765
|
let malCount = 0;
|
|
764
766
|
let skippedCount = 0;
|
|
767
|
+
let totalUncompressed = 0;
|
|
765
768
|
|
|
766
769
|
const spinner = new Spinner();
|
|
767
770
|
try {
|
|
@@ -775,6 +778,19 @@ async function scrapeOSVDataDump() {
|
|
|
775
778
|
if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
|
|
776
779
|
skippedCount++;
|
|
777
780
|
} else {
|
|
781
|
+
// Zip bomb protection: check declared uncompressed size
|
|
782
|
+
const entrySize = entry.header ? entry.header.size : 0;
|
|
783
|
+
if (entrySize > MAX_ENTRY_UNCOMPRESSED) {
|
|
784
|
+
console.warn(`[WARN] Zip bomb protection: skipping oversized entry ${name} (${entrySize} bytes)`);
|
|
785
|
+
skippedCount++;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
totalUncompressed += entrySize;
|
|
789
|
+
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
790
|
+
console.warn(`[WARN] Zip bomb protection: total uncompressed budget exceeded at entry ${name}, stopping`);
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
|
|
778
794
|
try {
|
|
779
795
|
const content = entry.getData().toString('utf8');
|
|
780
796
|
const vuln = JSON.parse(content);
|
|
@@ -784,8 +800,8 @@ async function scrapeOSVDataDump() {
|
|
|
784
800
|
// Track known IDs so OSSF can skip them
|
|
785
801
|
knownIds.add(vuln.id || path.basename(name, '.json'));
|
|
786
802
|
malCount++;
|
|
787
|
-
} catch {
|
|
788
|
-
|
|
803
|
+
} catch (parseErr) {
|
|
804
|
+
console.warn(`[WARN] Skipping unparseable entry: ${name}`);
|
|
789
805
|
}
|
|
790
806
|
}
|
|
791
807
|
|
|
@@ -824,6 +840,7 @@ async function scrapeOSVPyPIDataDump() {
|
|
|
824
840
|
|
|
825
841
|
let malCount = 0;
|
|
826
842
|
let skippedCount = 0;
|
|
843
|
+
let totalUncompressed = 0;
|
|
827
844
|
|
|
828
845
|
const spinner = new Spinner();
|
|
829
846
|
try {
|
|
@@ -837,14 +854,27 @@ async function scrapeOSVPyPIDataDump() {
|
|
|
837
854
|
if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
|
|
838
855
|
skippedCount++;
|
|
839
856
|
} else {
|
|
857
|
+
// Zip bomb protection: check declared uncompressed size
|
|
858
|
+
const entrySize = entry.header ? entry.header.size : 0;
|
|
859
|
+
if (entrySize > MAX_ENTRY_UNCOMPRESSED) {
|
|
860
|
+
console.warn(`[WARN] Zip bomb protection: skipping oversized entry ${name} (${entrySize} bytes)`);
|
|
861
|
+
skippedCount++;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
totalUncompressed += entrySize;
|
|
865
|
+
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
866
|
+
console.warn(`[WARN] Zip bomb protection: total uncompressed budget exceeded at entry ${name}, stopping`);
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
|
|
840
870
|
try {
|
|
841
871
|
const content = entry.getData().toString('utf8');
|
|
842
872
|
const vuln = JSON.parse(content);
|
|
843
873
|
const parsed = parseOSVEntry(vuln, 'osv-malicious-pypi', 'PyPI');
|
|
844
874
|
for (const p of parsed) packages.push(p);
|
|
845
875
|
malCount++;
|
|
846
|
-
} catch {
|
|
847
|
-
|
|
876
|
+
} catch (parseErr) {
|
|
877
|
+
console.warn(`[WARN] Skipping unparseable entry: ${name}`);
|
|
848
878
|
}
|
|
849
879
|
}
|
|
850
880
|
|
|
@@ -1356,7 +1386,8 @@ module.exports = {
|
|
|
1356
1386
|
parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
|
|
1357
1387
|
createFreshness, isAllowedRedirect, loadStaticIOCs,
|
|
1358
1388
|
validateIOCEntry,
|
|
1359
|
-
CONFIDENCE_ORDER, ALLOWED_REDIRECT_DOMAINS
|
|
1389
|
+
CONFIDENCE_ORDER, ALLOWED_REDIRECT_DOMAINS,
|
|
1390
|
+
MAX_ENTRY_UNCOMPRESSED, MAX_TOTAL_UNCOMPRESSED
|
|
1360
1391
|
};
|
|
1361
1392
|
|
|
1362
1393
|
// Direct execution if called as CLI
|
package/src/ioc/updater.js
CHANGED
|
@@ -345,7 +345,7 @@ function createOptimizedIOCs(iocs) {
|
|
|
345
345
|
const NEVER_WILDCARD = new Set([
|
|
346
346
|
'event-stream', 'ua-parser-js', 'coa', 'rc',
|
|
347
347
|
'colors', 'faker', 'node-ipc',
|
|
348
|
-
'posthog-node', 'ngx-bootstrap', '@asyncapi/specs'
|
|
348
|
+
'posthog-node', 'posthog-js', 'ngx-bootstrap', '@asyncapi/specs'
|
|
349
349
|
]);
|
|
350
350
|
|
|
351
351
|
function generateCompactIOCs(fullIOCs) {
|
package/src/scoring.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { getRule } = require('./rules/index.js');
|
|
2
|
+
|
|
1
3
|
// ============================================
|
|
2
4
|
// SCORING CONSTANTS
|
|
3
5
|
// ============================================
|
|
@@ -38,6 +40,13 @@ const MAX_RISK_SCORE = 100;
|
|
|
38
40
|
// to limit noise while preserving some signal. CRITICAL and HIGH prototype_hook findings still score normally.
|
|
39
41
|
const PROTO_HOOK_MEDIUM_CAP = 15;
|
|
40
42
|
|
|
43
|
+
// Confidence-weighted scoring factors (v2.7.10)
|
|
44
|
+
// High-confidence detections (eval, IOC, shell injection) score at full weight.
|
|
45
|
+
// Medium-confidence heuristics (lifecycle_script, obfuscation, high_entropy) are discounted.
|
|
46
|
+
// Low-confidence informational findings (possible_obfuscation, base64_in_script) are heavily discounted.
|
|
47
|
+
// Unknown/paranoid rules default to 1.0 (no penalty).
|
|
48
|
+
const CONFIDENCE_FACTORS = { high: 1.0, medium: 0.85, low: 0.6 };
|
|
49
|
+
|
|
41
50
|
// ============================================
|
|
42
51
|
// PER-FILE MAX SCORING (v2.2.11)
|
|
43
52
|
// ============================================
|
|
@@ -76,24 +85,24 @@ function isPackageLevelThreat(threat) {
|
|
|
76
85
|
* @returns {number} score 0-100
|
|
77
86
|
*/
|
|
78
87
|
function computeGroupScore(threats) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const mediumCount = threats.filter(t => t.severity === 'MEDIUM').length;
|
|
82
|
-
const lowCount = threats.filter(t => t.severity === 'LOW').length;
|
|
88
|
+
let score = 0;
|
|
89
|
+
let protoHookMediumPoints = 0;
|
|
83
90
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const otherMediumCount = mediumCount - mediumProtoHookCount;
|
|
91
|
+
for (const t of threats) {
|
|
92
|
+
const weight = SEVERITY_WEIGHTS[t.severity] || 0;
|
|
93
|
+
const rule = getRule(t.type);
|
|
94
|
+
const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
if (t.type === 'prototype_hook' && t.severity === 'MEDIUM') {
|
|
97
|
+
protoHookMediumPoints += weight * factor;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
score += weight * factor;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
score += Math.min(protoHookMediumPoints, PROTO_HOOK_MEDIUM_CAP);
|
|
105
|
+
return Math.min(MAX_RISK_SCORE, Math.round(score));
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
// ============================================
|
|
@@ -198,6 +207,11 @@ const FRAMEWORK_PROTO_RE = new RegExp(
|
|
|
198
207
|
);
|
|
199
208
|
|
|
200
209
|
function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
210
|
+
// Initialize reductions audit trail on each threat
|
|
211
|
+
for (const t of threats) {
|
|
212
|
+
t.reductions = [];
|
|
213
|
+
}
|
|
214
|
+
|
|
201
215
|
// Count occurrences of each threat type (package-level, across all files)
|
|
202
216
|
const typeCounts = {};
|
|
203
217
|
for (const t of threats) {
|
|
@@ -224,6 +238,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
224
238
|
if ((t.type === 'dynamic_require' || t.type === 'dynamic_import') && t.severity === 'HIGH') {
|
|
225
239
|
const f = t.file || '(unknown)';
|
|
226
240
|
if (perFilePluginCount[f] > 4) {
|
|
241
|
+
t.reductions.push({ rule: 'plugin_loader_per_file', from: 'HIGH', to: 'LOW' });
|
|
227
242
|
t.severity = 'LOW';
|
|
228
243
|
}
|
|
229
244
|
}
|
|
@@ -245,6 +260,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
245
260
|
if (typeRatio < 0.4 ||
|
|
246
261
|
(t.type === 'suspicious_dataflow' && typeCounts[t.type] > rule.maxCount) ||
|
|
247
262
|
(t.type === 'vm_code_execution' && typeCounts[t.type] > rule.maxCount)) {
|
|
263
|
+
t.reductions.push({ rule: 'count_threshold', from: t.severity, to: rule.to });
|
|
248
264
|
t.severity = rule.to;
|
|
249
265
|
}
|
|
250
266
|
}
|
|
@@ -253,6 +269,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
253
269
|
// Malware poisons cache repeatedly; a single access is framework behavior
|
|
254
270
|
if (t.type === 'require_cache_poison' && t.severity === 'CRITICAL' &&
|
|
255
271
|
typeCounts.require_cache_poison === 1) {
|
|
272
|
+
t.reductions.push({ rule: 'cache_poison_single', from: 'CRITICAL', to: 'HIGH' });
|
|
256
273
|
t.severity = 'HIGH';
|
|
257
274
|
}
|
|
258
275
|
|
|
@@ -261,6 +278,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
261
278
|
// Browser/native APIs (globalThis.fetch, XMLHttpRequest) stay HIGH
|
|
262
279
|
if (t.type === 'prototype_hook' && t.severity === 'HIGH' &&
|
|
263
280
|
FRAMEWORK_PROTO_RE.test(t.message)) {
|
|
281
|
+
t.reductions.push({ rule: 'framework_prototype', from: 'HIGH', to: 'MEDIUM' });
|
|
264
282
|
t.severity = 'MEDIUM';
|
|
265
283
|
}
|
|
266
284
|
|
|
@@ -271,6 +289,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
271
289
|
typeCounts.prototype_hook > 20) {
|
|
272
290
|
const HTTP_PROTO_RE = /\b(Request|Response|IncomingMessage|ClientRequest|ServerResponse|fetch)\b/i;
|
|
273
291
|
if (HTTP_PROTO_RE.test(t.message)) {
|
|
292
|
+
t.reductions.push({ rule: 'http_client_whitelist', from: t.severity, to: 'MEDIUM' });
|
|
274
293
|
t.severity = 'MEDIUM';
|
|
275
294
|
}
|
|
276
295
|
}
|
|
@@ -283,14 +302,18 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
283
302
|
if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
|
|
284
303
|
if (DIST_BUNDLER_ARTIFACT_TYPES.has(t.type)) {
|
|
285
304
|
// Two-notch downgrade for bundler artifacts
|
|
305
|
+
const fromSev = t.severity;
|
|
286
306
|
if (t.severity === 'CRITICAL') t.severity = 'MEDIUM';
|
|
287
307
|
else if (t.severity === 'HIGH') t.severity = 'LOW';
|
|
288
308
|
else if (t.severity === 'MEDIUM') t.severity = 'LOW';
|
|
309
|
+
if (t.severity !== fromSev) t.reductions.push({ rule: 'dist_file', from: fromSev, to: t.severity });
|
|
289
310
|
} else {
|
|
290
311
|
// One-notch downgrade for other non-exempt types
|
|
312
|
+
const fromSev = t.severity;
|
|
291
313
|
if (t.severity === 'CRITICAL') t.severity = 'HIGH';
|
|
292
314
|
else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
|
|
293
315
|
else if (t.severity === 'MEDIUM') t.severity = 'LOW';
|
|
316
|
+
if (t.severity !== fromSev) t.reductions.push({ rule: 'dist_file', from: fromSev, to: t.severity });
|
|
294
317
|
}
|
|
295
318
|
}
|
|
296
319
|
|
|
@@ -300,6 +323,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
300
323
|
!isPackageLevelThreat(t)) {
|
|
301
324
|
const normalizedFile = t.file.replace(/\\/g, '/');
|
|
302
325
|
if (!reachableFiles.has(normalizedFile)) {
|
|
326
|
+
t.reductions.push({ rule: 'unreachable', from: t.severity, to: 'LOW' });
|
|
303
327
|
t.severity = 'LOW';
|
|
304
328
|
t.unreachable = true;
|
|
305
329
|
}
|
|
@@ -312,6 +336,7 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
312
336
|
if (t.type === 'mcp_config_injection' && t.severity === 'CRITICAL' &&
|
|
313
337
|
packageDeps && typeof packageDeps === 'object' &&
|
|
314
338
|
packageDeps['@modelcontextprotocol/sdk']) {
|
|
339
|
+
t.reductions.push({ rule: 'mcp_sdk', from: 'CRITICAL', to: 'MEDIUM' });
|
|
315
340
|
t.severity = 'MEDIUM';
|
|
316
341
|
t.mcpSdkDowngrade = true;
|
|
317
342
|
}
|
|
@@ -418,6 +443,6 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
418
443
|
}
|
|
419
444
|
|
|
420
445
|
module.exports = {
|
|
421
|
-
SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE,
|
|
446
|
+
SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
|
|
422
447
|
isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore
|
|
423
448
|
};
|
package/src/shared/download.js
CHANGED
|
@@ -23,7 +23,11 @@ const PRIVATE_IP_PATTERNS = [
|
|
|
23
23
|
/^::1$/,
|
|
24
24
|
/^::ffff:127\./,
|
|
25
25
|
/^fc00:/,
|
|
26
|
-
/^fe80
|
|
26
|
+
/^fe80:/,
|
|
27
|
+
/^ff/i, // IPv6 multicast (RFC 4291 ff00::/8)
|
|
28
|
+
/^2001:0?db8:/i, // IPv6 documentation (RFC 3849 2001:db8::/32)
|
|
29
|
+
/^100:/, // IPv6 discard (RFC 6666 100::/64)
|
|
30
|
+
/^64:ff9b:/i // IPv6 NAT64 well-known (RFC 6052 64:ff9b::/96)
|
|
27
31
|
];
|
|
28
32
|
|
|
29
33
|
/**
|