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 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** (133 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.
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
- ### 133 detection rules
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
- **2093 tests** across 49 files. **134 rules** (129 RULES + 5 PARANOID).
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
- - **2093 tests** across 49 modular test files
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.7.8",
3
+ "version": "2.7.10",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 points = SEVERITY_WEIGHTS[t.severity] || 0;
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,
@@ -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
- // Skip unparseable entries
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
- // Skip unparseable entries
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
@@ -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
- const criticalCount = threats.filter(t => t.severity === 'CRITICAL').length;
80
- const highCount = threats.filter(t => t.severity === 'HIGH').length;
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 mediumProtoHookCount = threats.filter(
85
- t => t.type === 'prototype_hook' && t.severity === 'MEDIUM'
86
- ).length;
87
- const protoHookPoints = Math.min(mediumProtoHookCount * SEVERITY_WEIGHTS.MEDIUM, PROTO_HOOK_MEDIUM_CAP);
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
- let score = 0;
91
- score += criticalCount * SEVERITY_WEIGHTS.CRITICAL;
92
- score += highCount * SEVERITY_WEIGHTS.HIGH;
93
- score += otherMediumCount * SEVERITY_WEIGHTS.MEDIUM;
94
- score += protoHookPoints;
95
- score += lowCount * SEVERITY_WEIGHTS.LOW;
96
- return Math.min(MAX_RISK_SCORE, score);
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
  };
@@ -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
  /**