muaddib-scanner 2.9.4 → 2.9.6

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** (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.
33
+ MUAD'DIB combines **14 parallel scanners** (152 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **per-file max scoring**, **compound scoring rules**, Docker sandbox with **monkey-patching preload** for time-bomb detection, **behavioral anomaly detection**, **GlassWorm campaign 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,14 +195,15 @@ 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
- ### 134 detection rules
198
+ ### 152 detection rules
199
199
 
200
- All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v262) for the complete rules reference.
200
+ All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v294) for the complete rules reference.
201
201
 
202
202
  ### Detected campaigns
203
203
 
204
204
  | Campaign | Status |
205
205
  |----------|--------|
206
+ | GlassWorm (2026, 433+ packages) | Detected |
206
207
  | Shai-Hulud v1/v2/v3 (2025) | Detected |
207
208
  | event-stream (2018) | Detected |
208
209
  | eslint-scope (2018) | Detected |
@@ -281,12 +282,12 @@ repos:
281
282
 
282
283
  | Metric | Result | Details |
283
284
  |--------|--------|---------|
284
- | **Wild TPR** (Datadog 17K) | **88.2%** raw / **~100%** adjusted | 17,922 real malware. 2,077 out-of-scope (phishing, binaries, corrected) |
285
+ | **Wild TPR** (Datadog 17K) | **92.5%** (13,486/14,587 in-scope) | 17,922 packages. 3,335 skipped (no JS). By category: compromised_lib 97.8%, malicious_intent 92.1% |
285
286
  | **TPR** (Ground Truth) | **93.9%** (46/49) | 51 real attacks. 3 out-of-scope: browser-only |
286
- | **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
287
- | **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
287
+ | **FPR** (Benign) | **12.9%** (68/529) | 529 npm packages, real source via `npm pack` |
288
+ | **ADR** (Adversarial + Holdout) | **96.3%** (103/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
288
289
 
289
- **2166 tests** across 49 files. **134 rules** (129 RULES + 5 PARANOID).
290
+ **2336 tests** across 50 files. **152 rules** (147 RULES + 5 PARANOID).
290
291
 
291
292
  > **Methodology caveats:**
292
293
  > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
@@ -327,11 +328,11 @@ npm test
327
328
 
328
329
  ### Testing
329
330
 
330
- - **2166 tests** across 49 modular test files
331
+ - **2336 tests** across 50 modular test files
331
332
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
332
333
  - **Datadog 17K benchmark** - 17,922 real malware samples
333
334
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
334
- - **False positive validation** - 12.1% FPR on 529 real npm packages
335
+ - **False positive validation** - 12.9% FPR on 529 real npm packages
335
336
 
336
337
  ---
337
338
 
@@ -347,7 +348,7 @@ npm test
347
348
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
348
349
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
349
350
  - [Adversarial Evaluation](ADVERSARIAL.md) - Red team samples and ADR results
350
- - [Security Policy](SECURITY.md) - Detection rules reference (134 rules)
351
+ - [Security Policy](SECURITY.md) - Detection rules reference (152 rules)
351
352
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
352
353
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
353
354
 
package/bin/muaddib.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { exec } = require('child_process');
2
+ const { execFile } = require('child_process');
3
3
  const { run } = require('../src/index.js');
4
4
  const { updateIOCs } = require('../src/ioc/updater.js');
5
5
  const { watch } = require('../src/watch.js');
@@ -160,7 +160,8 @@ for (let i = 0; i < options.length; i++) {
160
160
  if (!jsonOutput && !sarifOutput && command !== 'feed' && command !== 'serve') {
161
161
  try {
162
162
  const currentVersion = require('../package.json').version;
163
- exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
163
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
164
+ execFile(npmBin, ['view', 'muaddib-scanner', 'version'], { timeout: 5000 }, (err, stdout) => {
164
165
  if (err) return; // No network or npm unavailable
165
166
  const latest = (stdout || '').toString().trim();
166
167
  if (!latest || latest === currentVersion) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.9.4",
3
+ "version": "2.9.6",
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
@@ -186,8 +186,8 @@ function scanParanoid(targetPath) {
186
186
  }
187
187
  }
188
188
  });
189
- } catch {
190
- // Ignore read/parse errors
189
+ } catch (e) {
190
+ debugLog('[PARANOID] AST parse error:', e?.message);
191
191
  }
192
192
  }
193
193
 
@@ -218,8 +218,8 @@ function scanParanoid(targetPath) {
218
218
  const relFile = path.relative(targetPath, filePath);
219
219
  scanFileContent(filePath, content, relFile);
220
220
  }
221
- } catch {
222
- // Ignore read errors
221
+ } catch (e) {
222
+ debugLog('[PARANOID] file read error:', e?.message);
223
223
  }
224
224
  }
225
225
 
@@ -247,8 +247,8 @@ function scanParanoid(targetPath) {
247
247
  scanFile(fullPath);
248
248
  }
249
249
  }
250
- } catch {
251
- // Ignore walk errors
250
+ } catch (e) {
251
+ debugLog('[PARANOID] walkDir error:', e?.message);
252
252
  }
253
253
  }
254
254
 
@@ -550,7 +550,8 @@ async function run(targetPath, options = {}) {
550
550
  if (!reachability.skipped) {
551
551
  reachableFiles = reachability.reachableFiles;
552
552
  }
553
- } catch {
553
+ } catch (e) {
554
+ debugLog('[REACHABILITY] error:', e?.message);
554
555
  // Graceful fallback — treat all files as reachable
555
556
  }
556
557
  }
@@ -1495,10 +1495,15 @@ function handleCallExpression(node, ctx) {
1495
1495
  if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
1496
1496
  (ctx.globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
1497
1497
  ctx.hasEvalInFile = true;
1498
+ // Resolve variable value via stringVarValues (e.g., const f = 'eval'; globalThis[f]())
1499
+ const resolvedValue = ctx.stringVarValues.get(prop.name);
1500
+ const isEvalOrFunction = resolvedValue === 'eval' || resolvedValue === 'Function';
1498
1501
  ctx.threats.push({
1499
1502
  type: 'dangerous_call_eval',
1500
- severity: 'HIGH',
1501
- message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
1503
+ severity: isEvalOrFunction ? 'CRITICAL' : 'HIGH',
1504
+ message: isEvalOrFunction
1505
+ ? `Resolved indirect ${resolvedValue}() via computed property (${obj.name}[${prop.name}="${resolvedValue}"]) — confirmed eval evasion.`
1506
+ : `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
1502
1507
  file: ctx.relFile
1503
1508
  });
1504
1509
  }
@@ -154,6 +154,27 @@ function deobfuscate(sourceCode) {
154
154
  const hexResult = tryResolveHexArrayMap(node, sourceCode);
155
155
  if (hexResult !== null) {
156
156
  replacements.push(hexResult);
157
+ return;
158
+ }
159
+
160
+ // ---- 5. ARRAY JOIN ----
161
+ // ['e','v','a','l'].join('') → "eval"
162
+ if (node.callee?.type === 'MemberExpression' &&
163
+ node.callee.property?.name === 'join' &&
164
+ node.callee.object?.type === 'ArrayExpression' &&
165
+ node.arguments?.length === 1 &&
166
+ node.arguments[0]?.type === 'Literal' &&
167
+ node.arguments[0].value === '') {
168
+ const elements = node.callee.object.elements;
169
+ if (elements.length > 0 && elements.every(el => el?.type === 'Literal' && typeof el.value === 'string')) {
170
+ const joined = elements.map(el => el.value).join('');
171
+ const before = sourceCode.slice(node.start, node.end);
172
+ replacements.push({
173
+ start: node.start, end: node.end,
174
+ value: quoteString(joined),
175
+ type: 'array_join', before
176
+ });
177
+ }
157
178
  }
158
179
  }
159
180
  });
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles, forEachSafeFile } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile, debugLog } = require('../utils.js');
4
4
  const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
5
 
6
6
  const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
@@ -56,7 +56,7 @@ function scanFileContent(file, content, targetPath, threats) {
56
56
  function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
57
57
  if (depth > 20) return results;
58
58
  let items;
59
- try { items = fs.readdirSync(dir); } catch { return results; }
59
+ try { items = fs.readdirSync(dir); } catch (e) { debugLog('[SHELL] readdirSync error:', e?.message); return results; }
60
60
 
61
61
  for (const item of items) {
62
62
  if (excludedDirs.includes(item)) continue;
@@ -69,7 +69,7 @@ function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
69
69
  } else if (lstat.isFile() && !path.extname(item) && lstat.size <= MAX_FILE_SIZE) {
70
70
  results.push(fullPath);
71
71
  }
72
- } catch { /* permission error */ }
72
+ } catch (e) { debugLog('[SHELL] stat error:', e?.message); }
73
73
  }
74
74
  return results;
75
75
  }
@@ -94,7 +94,7 @@ async function scanShellScripts(targetPath) {
94
94
  if (SHEBANG_RE.test(firstLine)) {
95
95
  scanFileContent(file, content, targetPath, threats);
96
96
  }
97
- } catch { /* ignore unreadable files */ }
97
+ } catch (e) { debugLog('[SHELL] readFile error:', e?.message); }
98
98
  }
99
99
 
100
100
  return threats;
package/src/scoring.js CHANGED
@@ -279,11 +279,11 @@ function applyCompoundBoosts(threats) {
279
279
 
280
280
  // Check all required types are present
281
281
  if (compound.requires.every(req => typeSet.has(req))) {
282
- // Severity gate: at least one component must have severity >= MEDIUM
283
- // after FP reductions. If all components were downgraded to LOW,
284
- // the compound signal is not strong enough to justify a CRITICAL boost.
282
+ // Severity gate: at least one component must have had original severity >= MEDIUM.
283
+ // Uses originalSeverity (pre-FP-reduction) to prevent attackers from
284
+ // manipulating compound gates via count-threshold or dist-file downgrades.
285
285
  const hasSignificantComponent = compound.requires.some(req =>
286
- threats.some(t => t.type === req && t.severity !== 'LOW')
286
+ threats.some(t => t.type === req && (t.originalSeverity || t.severity) !== 'LOW')
287
287
  );
288
288
  if (!hasSignificantComponent) continue;
289
289
 
@@ -323,8 +323,11 @@ const FRAMEWORK_PROTO_RE = new RegExp(
323
323
 
324
324
  function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
325
325
  // Initialize reductions audit trail on each threat
326
+ // Store original severity before any FP reductions, so compound
327
+ // severity gates can check pre-reduction severity (GAP 4b).
326
328
  for (const t of threats) {
327
329
  t.reductions = [];
330
+ t.originalSeverity = t.severity;
328
331
  }
329
332
 
330
333
  // Count occurrences of each threat type (package-level, across all files)
@@ -384,6 +387,29 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
384
387
  // The READ/WRITE distinction in ast-detectors already handles the FP case:
385
388
  // READ-only → LOW (hot-reload, introspection), WRITE → CRITICAL (malicious replacement).
386
389
  // A single cache WRITE is genuinely malicious — no downgrade needed.
390
+ }
391
+
392
+ // Dilution floor: retain at least one instance at original severity per type
393
+ // to prevent complete count-threshold dilution by injected benign patterns.
394
+ // Only applies to types with low maxCount (≤3) and a severity constraint (from field),
395
+ // where injection of benign patterns is feasible. High-count types (dynamic_require,
396
+ // env_access) and unconstrained types (suspicious_dataflow) represent legitimate
397
+ // framework patterns and should allow full downgrade.
398
+ const restoredTypes = new Set();
399
+ for (const t of threats) {
400
+ const lastReduction = t.reductions?.find(r => r.rule === 'count_threshold');
401
+ if (lastReduction && !restoredTypes.has(t.type)) {
402
+ const rule = FP_COUNT_THRESHOLDS[t.type];
403
+ if (rule && rule.from && rule.maxCount <= 3) {
404
+ t.severity = lastReduction.from;
405
+ t.reductions = t.reductions.filter(r => r.rule !== 'count_threshold');
406
+ t.reductions.push({ rule: 'count_threshold_floor', note: 'retained one instance at original severity' });
407
+ restoredTypes.add(t.type);
408
+ }
409
+ }
410
+ }
411
+
412
+ for (const t of threats) {
387
413
 
388
414
  // Prototype hook: framework class prototypes → MEDIUM
389
415
  // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL