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 +11 -10
- package/bin/muaddib.js +3 -2
- package/package.json +1 -1
- package/src/index.js +8 -7
- package/src/scanner/ast-detectors.js +7 -2
- package/src/scanner/deobfuscate.js +21 -0
- package/src/scanner/shell.js +4 -4
- package/src/scoring.js +30 -4
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** (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
|
-
###
|
|
198
|
+
### 152 detection rules
|
|
199
199
|
|
|
200
|
-
All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-
|
|
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) | **
|
|
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.
|
|
287
|
-
| **ADR** (Adversarial + Holdout) | **
|
|
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
|
-
**
|
|
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
|
-
- **
|
|
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.
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
package/src/index.js
CHANGED
|
@@ -186,8 +186,8 @@ function scanParanoid(targetPath) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
|
-
} catch {
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
});
|
package/src/scanner/shell.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
//
|
|
284
|
-
//
|
|
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
|