muaddib-scanner 2.2.19 → 2.2.22

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.
@@ -1,484 +1,484 @@
1
- /**
2
- * MUAD'DIB Evaluate — Scanner effectiveness measurement
3
- *
4
- * Measures TPR (Ground Truth), FPR (Benign), and ADR (Adversarial).
5
- * Saves versioned metrics to metrics/v{version}.json.
6
- *
7
- * Benign FPR: downloads real npm tarballs and scans actual source code
8
- * with all 13+ scanners (AST, dataflow, obfuscation, entropy, etc.).
9
- * Tarballs are cached in .muaddib-cache/benign-tarballs/ to avoid
10
- * re-downloading on every run.
11
- */
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
- const zlib = require('zlib');
16
- const { execSync } = require('child_process');
17
- const { run } = require('../index.js');
18
-
19
- const ROOT = path.join(__dirname, '..', '..');
20
- const GT_DIR = path.join(ROOT, 'tests', 'ground-truth');
21
- const BENIGN_DIR = path.join(ROOT, 'datasets', 'benign');
22
- const ADVERSARIAL_DIR = path.join(ROOT, 'datasets', 'adversarial');
23
- const METRICS_DIR = path.join(ROOT, 'metrics');
24
- const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'benign-tarballs');
25
- const HOLDOUT_DIRS = [
26
- path.join(ROOT, 'datasets', 'holdout-v2'),
27
- path.join(ROOT, 'datasets', 'holdout-v3'),
28
- path.join(ROOT, 'datasets', 'holdout-v4'),
29
- path.join(ROOT, 'datasets', 'holdout-v5'),
30
- ];
31
-
32
- const GT_THRESHOLD = 3;
33
- const BENIGN_THRESHOLD = 20;
34
- const PACK_TIMEOUT_MS = 30000;
35
-
36
- const ADVERSARIAL_THRESHOLDS = {
37
- // Vague 1 (20 samples)
38
- 'ci-trigger-exfil': 35,
39
- 'delayed-exfil': 30,
40
- 'docker-aware': 35,
41
- 'staged-fetch': 35,
42
- 'dns-chunk-exfil': 35,
43
- 'string-concat-obfuscation': 30,
44
- 'postinstall-download': 30,
45
- 'dynamic-require': 40,
46
- 'iife-exfil': 40,
47
- 'conditional-chain': 30,
48
- 'template-literal-obfuscation': 30,
49
- 'proxy-env-intercept': 40,
50
- 'nested-payload': 30,
51
- 'dynamic-import': 30,
52
- 'websocket-exfil': 30,
53
- 'bun-runtime-evasion': 25,
54
- 'preinstall-exec': 35,
55
- 'remote-dynamic-dependency': 35,
56
- 'github-exfil': 30,
57
- 'detached-background': 35,
58
- // Vague 3 (5 samples)
59
- 'ai-agent-weaponization': 35,
60
- 'ai-config-injection': 30,
61
- 'rdd-zero-deps': 35,
62
- 'discord-webhook-exfil': 30,
63
- 'preinstall-background-fork': 35,
64
- // Holdout → promoted (10 samples)
65
- 'silent-error-swallow': 25,
66
- 'double-base64-exfil': 30,
67
- 'crypto-wallet-harvest': 25,
68
- 'self-hosted-runner-backdoor': 20,
69
- 'dead-mans-switch': 30,
70
- 'fake-captcha-fingerprint': 20,
71
- 'pyinstaller-dropper': 35,
72
- 'gh-cli-token-steal': 30,
73
- 'triple-base64-github-push': 30,
74
- 'browser-api-hook': 20,
75
- // Audit bypass samples (v2.2.13)
76
- 'indirect-eval-bypass': 10,
77
- 'muaddib-ignore-bypass': 25,
78
- 'mjs-extension-bypass': 100
79
- };
80
-
81
- const HOLDOUT_THRESHOLDS = {
82
- // holdout-v2 (10 samples)
83
- 'conditional-os-payload': 25, 'env-var-reconstruction': 25,
84
- 'github-workflow-inject': 20, 'homedir-ssh-key-steal': 25,
85
- 'npm-cache-poison': 20, 'npm-lifecycle-preinstall-curl': 25,
86
- 'process-env-proxy-getter': 20, 'readable-stream-hijack': 20,
87
- 'setTimeout-chain': 25, 'wasm-loader': 20,
88
- // holdout-v3 (10 samples)
89
- 'dns-txt-payload': 25, 'electron-rce': 30,
90
- 'env-file-parse-exfil': 20, 'git-credential-steal': 20,
91
- 'npm-hook-hijack': 25, 'postinstall-reverse-shell': 35,
92
- 'require-cache-poison': 20, 'steganography-payload': 15,
93
- 'symlink-escape': 25, 'timezone-trigger': 30,
94
- // holdout-v4 (10 samples — deobfuscation)
95
- 'atob-eval': 20, 'base64-require': 35,
96
- 'charcode-fetch': 25, 'charcode-spread-homedir': 30,
97
- 'concat-env-steal': 20, 'double-decode-exfil': 40,
98
- 'hex-array-exec': 20, 'mixed-obfuscation-stealer': 30,
99
- 'nested-base64-concat': 25, 'template-literal-hide': 40,
100
- // holdout-v5 (10 samples — inter-module dataflow)
101
- 'callback-exfil': 3, 'class-method-exfil': 20,
102
- 'conditional-split': 25, 'event-emitter-flow': 3,
103
- 'mixed-inline-split': 20, 'named-export-steal': 20,
104
- 'reexport-chain': 20, 'split-env-exfil': 20,
105
- 'split-npmrc-steal': 20, 'three-hop-chain': 20
106
- };
107
-
108
- /**
109
- * Scan a directory silently and return the result
110
- */
111
- async function silentScan(dir) {
112
- try {
113
- return await run(dir, { _capture: true });
114
- } catch (err) {
115
- return { summary: { riskScore: 0, total: 0 }, threats: [], error: err.message };
116
- }
117
- }
118
-
119
- /**
120
- * 1. Ground Truth — scan real-world attack samples
121
- */
122
- async function evaluateGroundTruth() {
123
- const attacksFile = path.join(GT_DIR, 'attacks.json');
124
- const data = JSON.parse(fs.readFileSync(attacksFile, 'utf8'));
125
- const attacks = data.attacks.filter(a => a.expected.min_threats > 0);
126
-
127
- const details = [];
128
- let detected = 0;
129
-
130
- for (const attack of attacks) {
131
- const sampleDir = path.join(GT_DIR, attack.sample_dir);
132
- const result = await silentScan(sampleDir);
133
- const score = result.summary.riskScore;
134
- const isDetected = score >= GT_THRESHOLD;
135
- if (isDetected) detected++;
136
- details.push({
137
- name: attack.name,
138
- id: attack.id,
139
- score,
140
- detected: isDetected,
141
- threshold: GT_THRESHOLD
142
- });
143
- }
144
-
145
- const total = attacks.length;
146
- const tpr = total > 0 ? detected / total : 0;
147
- return { detected, total, tpr, details };
148
- }
149
-
150
- // =========================================================================
151
- // 2. Benign — download real tarballs and scan actual source code
152
- // =========================================================================
153
-
154
- /**
155
- * Convert a package name to a safe cache directory name.
156
- * @scoped/pkg → _scoped_pkg
157
- */
158
- function pkgToCacheName(pkg) {
159
- return pkg.replace(/\//g, '_').replace(/@/g, '_');
160
- }
161
-
162
- /**
163
- * Extract a .tgz file using Node.js built-in zlib + minimal tar parser.
164
- * Only extracts regular files (type '0' or NUL).
165
- */
166
- function extractTgz(tgzPath, destDir) {
167
- const compressed = fs.readFileSync(tgzPath);
168
- const tarData = zlib.gunzipSync(compressed);
169
-
170
- let offset = 0;
171
- while (offset + 512 <= tarData.length) {
172
- const header = tarData.subarray(offset, offset + 512);
173
-
174
- // Check for end-of-archive (two zero blocks)
175
- if (header.every(b => b === 0)) break;
176
-
177
- // Parse tar header
178
- const name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
179
- const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
180
- const size = parseInt(sizeOctal, 8) || 0;
181
- const typeFlag = String.fromCharCode(header[156]);
182
-
183
- offset += 512; // move past header
184
-
185
- if (name && (typeFlag === '0' || typeFlag === '\0') && size > 0) {
186
- // Regular file — extract it
187
- const filePath = path.join(destDir, name);
188
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
189
- const fileData = tarData.subarray(offset, offset + size);
190
- fs.writeFileSync(filePath, fileData);
191
- }
192
-
193
- // Advance past data blocks (512-byte aligned)
194
- offset += Math.ceil(size / 512) * 512;
195
- }
196
- }
197
-
198
- /**
199
- * Download a package tarball via `npm pack` and extract with native Node.js.
200
- * Returns the path to the extracted package directory, or null on failure.
201
- * Uses a persistent cache to avoid re-downloading.
202
- */
203
- function downloadAndExtract(pkg, options = {}) {
204
- const cacheName = pkgToCacheName(pkg);
205
- const pkgCacheDir = path.join(CACHE_DIR, cacheName);
206
-
207
- // Check cache first (unless refreshing)
208
- if (!options.refreshBenign && fs.existsSync(pkgCacheDir)) {
209
- const extractedDir = path.join(pkgCacheDir, 'package');
210
- if (fs.existsSync(extractedDir)) {
211
- return extractedDir;
212
- }
213
- }
214
-
215
- // Download via npm pack (cwd approach avoids Windows path issues)
216
- fs.mkdirSync(pkgCacheDir, { recursive: true });
217
-
218
- let tgzFilename;
219
- try {
220
- const output = execSync(`npm pack ${pkg}`, {
221
- cwd: pkgCacheDir,
222
- encoding: 'utf8',
223
- timeout: PACK_TIMEOUT_MS,
224
- stdio: ['pipe', 'pipe', 'pipe']
225
- });
226
- tgzFilename = output.trim().split('\n').pop().trim();
227
- } catch (err) {
228
- if (process.env.MUADDIB_DEBUG) {
229
- console.error(`\n [DEBUG] npm pack ${pkg} failed: ${(err.stderr || err.message || '').slice(0, 200)}`);
230
- }
231
- fs.rmSync(pkgCacheDir, { recursive: true, force: true });
232
- return null;
233
- }
234
-
235
- const tgzPath = path.join(pkgCacheDir, tgzFilename);
236
- if (!fs.existsSync(tgzPath)) {
237
- fs.rmSync(pkgCacheDir, { recursive: true, force: true });
238
- return null;
239
- }
240
-
241
- // Extract tarball using native Node.js (no shell tar dependency)
242
- try {
243
- extractTgz(tgzPath, pkgCacheDir);
244
- } catch (err) {
245
- if (process.env.MUADDIB_DEBUG) {
246
- console.error(`\n [DEBUG] extract ${pkg} failed: ${(err.message || '').slice(0, 200)}`);
247
- }
248
- fs.rmSync(pkgCacheDir, { recursive: true, force: true });
249
- return null;
250
- }
251
-
252
- // Clean up tarball to save space
253
- try { fs.unlinkSync(tgzPath); } catch { /* ignore */ }
254
-
255
- const extractedDir = path.join(pkgCacheDir, 'package');
256
- if (!fs.existsSync(extractedDir)) {
257
- fs.rmSync(pkgCacheDir, { recursive: true, force: true });
258
- return null;
259
- }
260
-
261
- return extractedDir;
262
- }
263
-
264
- /**
265
- * Evaluate benign packages by downloading real source code and scanning it.
266
- */
267
- async function evaluateBenign(options = {}) {
268
- const listFile = path.join(BENIGN_DIR, 'packages-npm.txt');
269
- let packages = fs.readFileSync(listFile, 'utf8')
270
- .split('\n')
271
- .map(l => l.trim())
272
- .filter(l => l && !l.startsWith('#'));
273
-
274
- // Apply limit if specified
275
- const limit = options.benignLimit || 0;
276
- if (limit > 0) {
277
- packages = packages.slice(0, limit);
278
- }
279
-
280
- fs.mkdirSync(CACHE_DIR, { recursive: true });
281
-
282
- const details = [];
283
- let flagged = 0;
284
- let skipped = 0;
285
- const total = packages.length;
286
-
287
- for (let i = 0; i < packages.length; i++) {
288
- const pkg = packages[i];
289
- const progress = `[${i + 1}/${total}]`;
290
-
291
- // Progress indicator (overwrite line)
292
- if (!options.json && process.stdout.isTTY) {
293
- process.stdout.write(`\r [2/3] Benign ${progress} ${pkg}${''.padEnd(40)}`);
294
- }
295
-
296
- const extractedDir = downloadAndExtract(pkg, options);
297
- if (!extractedDir) {
298
- details.push({ name: pkg, score: 0, flagged: false, skipped: true, error: 'download failed' });
299
- skipped++;
300
- continue;
301
- }
302
-
303
- const result = await silentScan(extractedDir);
304
- const score = result.summary.riskScore;
305
- const isFlagged = score > BENIGN_THRESHOLD;
306
- if (isFlagged) flagged++;
307
-
308
- const entry = { name: pkg, score, flagged: isFlagged };
309
-
310
- // Include threat details for flagged packages (for debugging FPs)
311
- if (isFlagged && result.threats) {
312
- entry.threats = result.threats.map(t => ({
313
- type: t.type,
314
- severity: t.severity,
315
- message: t.message,
316
- file: t.file
317
- }));
318
- }
319
-
320
- details.push(entry);
321
- }
322
-
323
- // Clear progress line
324
- if (!options.json && process.stdout.isTTY) {
325
- process.stdout.write('\r' + ''.padEnd(80) + '\r');
326
- }
327
-
328
- const scanned = total - skipped;
329
- const fpr = scanned > 0 ? flagged / scanned : 0;
330
- return { flagged, total, scanned, skipped, fpr, details };
331
- }
332
-
333
- /**
334
- * 3. Adversarial — scan evasive malicious samples
335
- */
336
- async function evaluateAdversarial() {
337
- const details = [];
338
- let detected = 0;
339
-
340
- // --- Adversarial samples (35) ---
341
- for (const [name, threshold] of Object.entries(ADVERSARIAL_THRESHOLDS)) {
342
- const sampleDir = path.join(ADVERSARIAL_DIR, name);
343
- if (!fs.existsSync(sampleDir)) {
344
- details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'adversarial' });
345
- continue;
346
- }
347
- const result = await silentScan(sampleDir);
348
- const score = result.summary.riskScore;
349
- const isDetected = score >= threshold;
350
- if (isDetected) detected++;
351
- details.push({ name, score, threshold, detected: isDetected, source: 'adversarial' });
352
- }
353
-
354
- // --- Holdout samples (40) ---
355
- for (const [name, threshold] of Object.entries(HOLDOUT_THRESHOLDS)) {
356
- let sampleDir = null;
357
- for (const hDir of HOLDOUT_DIRS) {
358
- const candidate = path.join(hDir, name);
359
- if (fs.existsSync(candidate)) { sampleDir = candidate; break; }
360
- }
361
- if (!sampleDir) {
362
- details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'holdout' });
363
- continue;
364
- }
365
- const result = await silentScan(sampleDir);
366
- const score = result.summary.riskScore;
367
- const isDetected = score >= threshold;
368
- if (isDetected) detected++;
369
- details.push({ name, score, threshold, detected: isDetected, source: 'holdout' });
370
- }
371
-
372
- const total = Object.keys(ADVERSARIAL_THRESHOLDS).length + Object.keys(HOLDOUT_THRESHOLDS).length;
373
- const adr = total > 0 ? detected / total : 0;
374
- return { detected, total, adr, details };
375
- }
376
-
377
- /**
378
- * Save metrics to metrics/v{version}.json
379
- */
380
- function saveMetrics(report) {
381
- if (!fs.existsSync(METRICS_DIR)) {
382
- fs.mkdirSync(METRICS_DIR, { recursive: true });
383
- }
384
- const filename = `v${report.version}.json`;
385
- const filepath = path.join(METRICS_DIR, filename);
386
- fs.writeFileSync(filepath, JSON.stringify(report, null, 2));
387
- return filepath;
388
- }
389
-
390
- /**
391
- * Main evaluate function
392
- *
393
- * Options:
394
- * json — JSON output mode
395
- * benignLimit — Only test first N benign packages
396
- * refreshBenign — Force re-download of all tarballs
397
- */
398
- async function evaluate(options = {}) {
399
- const version = require('../../package.json').version;
400
- const jsonMode = options.json || false;
401
-
402
- if (!jsonMode) {
403
- console.log(`\n MUAD'DIB Evaluation (v${version})\n`);
404
- console.log(` [1/3] Ground Truth...`);
405
- }
406
- const groundTruth = await evaluateGroundTruth();
407
-
408
- if (!jsonMode) {
409
- console.log(` [2/3] Benign packages (real source code)...`);
410
- }
411
- const benign = await evaluateBenign(options);
412
-
413
- if (!jsonMode) {
414
- console.log(` [3/3] Adversarial samples...`);
415
- }
416
- const adversarial = await evaluateAdversarial();
417
-
418
- const report = {
419
- version,
420
- date: new Date().toISOString(),
421
- groundTruth,
422
- benign,
423
- adversarial
424
- };
425
-
426
- const metricsPath = saveMetrics(report);
427
-
428
- if (jsonMode) {
429
- console.log(JSON.stringify(report, null, 2));
430
- } else {
431
- const tprPct = (groundTruth.tpr * 100).toFixed(1);
432
- const fprPct = (benign.fpr * 100).toFixed(1);
433
- const adrPct = (adversarial.adr * 100).toFixed(1);
434
-
435
- console.log('');
436
- console.log(` Ground Truth (TPR): ${groundTruth.detected}/${groundTruth.total} ${tprPct}%`);
437
- console.log(` Benign (FPR): ${benign.flagged}/${benign.scanned} ${fprPct}% (${benign.skipped} skipped)`);
438
- console.log(` Adversarial (ADR): ${adversarial.detected}/${adversarial.total} ${adrPct}%`);
439
- console.log('');
440
-
441
- // Show failed adversarial samples
442
- const missed = adversarial.details.filter(d => !d.detected);
443
- if (missed.length > 0) {
444
- console.log(' Adversarial misses:');
445
- for (const m of missed) {
446
- console.log(` ${m.name}: score ${m.score} < threshold ${m.threshold}`);
447
- }
448
- console.log('');
449
- }
450
-
451
- // Show false positives with threat details
452
- const fps = benign.details.filter(d => d.flagged);
453
- if (fps.length > 0) {
454
- console.log(' False positives:');
455
- for (const fp of fps) {
456
- console.log(` ${fp.name}: score ${fp.score}`);
457
- if (fp.threats) {
458
- for (const t of fp.threats) {
459
- console.log(` [${t.severity}] ${t.type}: ${t.message}${t.file ? ' (' + t.file + ')' : ''}`);
460
- }
461
- }
462
- }
463
- console.log('');
464
- }
465
-
466
- console.log(` Saved: ${path.relative(ROOT, metricsPath)}`);
467
- console.log('');
468
- }
469
-
470
- return report;
471
- }
472
-
473
- module.exports = {
474
- evaluate,
475
- evaluateGroundTruth,
476
- evaluateBenign,
477
- evaluateAdversarial,
478
- saveMetrics,
479
- silentScan,
480
- ADVERSARIAL_THRESHOLDS,
481
- HOLDOUT_THRESHOLDS,
482
- GT_THRESHOLD,
483
- BENIGN_THRESHOLD
484
- };
1
+ /**
2
+ * MUAD'DIB Evaluate — Scanner effectiveness measurement
3
+ *
4
+ * Measures TPR (Ground Truth), FPR (Benign), and ADR (Adversarial).
5
+ * Saves versioned metrics to metrics/v{version}.json.
6
+ *
7
+ * Benign FPR: downloads real npm tarballs and scans actual source code
8
+ * with all 13+ scanners (AST, dataflow, obfuscation, entropy, etc.).
9
+ * Tarballs are cached in .muaddib-cache/benign-tarballs/ to avoid
10
+ * re-downloading on every run.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const zlib = require('zlib');
16
+ const { execSync } = require('child_process');
17
+ const { run } = require('../index.js');
18
+
19
+ const ROOT = path.join(__dirname, '..', '..');
20
+ const GT_DIR = path.join(ROOT, 'tests', 'ground-truth');
21
+ const BENIGN_DIR = path.join(ROOT, 'datasets', 'benign');
22
+ const ADVERSARIAL_DIR = path.join(ROOT, 'datasets', 'adversarial');
23
+ const METRICS_DIR = path.join(ROOT, 'metrics');
24
+ const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'benign-tarballs');
25
+ const HOLDOUT_DIRS = [
26
+ path.join(ROOT, 'datasets', 'holdout-v2'),
27
+ path.join(ROOT, 'datasets', 'holdout-v3'),
28
+ path.join(ROOT, 'datasets', 'holdout-v4'),
29
+ path.join(ROOT, 'datasets', 'holdout-v5'),
30
+ ];
31
+
32
+ const GT_THRESHOLD = 3;
33
+ const BENIGN_THRESHOLD = 20;
34
+ const PACK_TIMEOUT_MS = 30000;
35
+
36
+ const ADVERSARIAL_THRESHOLDS = {
37
+ // Vague 1 (20 samples)
38
+ 'ci-trigger-exfil': 35,
39
+ 'delayed-exfil': 30,
40
+ 'docker-aware': 35,
41
+ 'staged-fetch': 35,
42
+ 'dns-chunk-exfil': 35,
43
+ 'string-concat-obfuscation': 30,
44
+ 'postinstall-download': 30,
45
+ 'dynamic-require': 40,
46
+ 'iife-exfil': 40,
47
+ 'conditional-chain': 30,
48
+ 'template-literal-obfuscation': 30,
49
+ 'proxy-env-intercept': 40,
50
+ 'nested-payload': 30,
51
+ 'dynamic-import': 30,
52
+ 'websocket-exfil': 30,
53
+ 'bun-runtime-evasion': 25,
54
+ 'preinstall-exec': 35,
55
+ 'remote-dynamic-dependency': 35,
56
+ 'github-exfil': 30,
57
+ 'detached-background': 35,
58
+ // Vague 3 (5 samples)
59
+ 'ai-agent-weaponization': 35,
60
+ 'ai-config-injection': 30,
61
+ 'rdd-zero-deps': 35,
62
+ 'discord-webhook-exfil': 30,
63
+ 'preinstall-background-fork': 35,
64
+ // Holdout → promoted (10 samples)
65
+ 'silent-error-swallow': 25,
66
+ 'double-base64-exfil': 30,
67
+ 'crypto-wallet-harvest': 25,
68
+ 'self-hosted-runner-backdoor': 20,
69
+ 'dead-mans-switch': 30,
70
+ 'fake-captcha-fingerprint': 20,
71
+ 'pyinstaller-dropper': 35,
72
+ 'gh-cli-token-steal': 30,
73
+ 'triple-base64-github-push': 30,
74
+ 'browser-api-hook': 20,
75
+ // Audit bypass samples (v2.2.13)
76
+ 'indirect-eval-bypass': 10,
77
+ 'muaddib-ignore-bypass': 25,
78
+ 'mjs-extension-bypass': 100
79
+ };
80
+
81
+ const HOLDOUT_THRESHOLDS = {
82
+ // holdout-v2 (10 samples)
83
+ 'conditional-os-payload': 25, 'env-var-reconstruction': 25,
84
+ 'github-workflow-inject': 20, 'homedir-ssh-key-steal': 25,
85
+ 'npm-cache-poison': 20, 'npm-lifecycle-preinstall-curl': 25,
86
+ 'process-env-proxy-getter': 20, 'readable-stream-hijack': 20,
87
+ 'setTimeout-chain': 25, 'wasm-loader': 20,
88
+ // holdout-v3 (10 samples)
89
+ 'dns-txt-payload': 25, 'electron-rce': 30,
90
+ 'env-file-parse-exfil': 20, 'git-credential-steal': 20,
91
+ 'npm-hook-hijack': 25, 'postinstall-reverse-shell': 35,
92
+ 'require-cache-poison': 20, 'steganography-payload': 15,
93
+ 'symlink-escape': 25, 'timezone-trigger': 30,
94
+ // holdout-v4 (10 samples — deobfuscation)
95
+ 'atob-eval': 20, 'base64-require': 35,
96
+ 'charcode-fetch': 25, 'charcode-spread-homedir': 30,
97
+ 'concat-env-steal': 20, 'double-decode-exfil': 40,
98
+ 'hex-array-exec': 20, 'mixed-obfuscation-stealer': 30,
99
+ 'nested-base64-concat': 25, 'template-literal-hide': 40,
100
+ // holdout-v5 (10 samples — inter-module dataflow)
101
+ 'callback-exfil': 3, 'class-method-exfil': 20,
102
+ 'conditional-split': 25, 'event-emitter-flow': 3,
103
+ 'mixed-inline-split': 20, 'named-export-steal': 20,
104
+ 'reexport-chain': 20, 'split-env-exfil': 20,
105
+ 'split-npmrc-steal': 20, 'three-hop-chain': 20
106
+ };
107
+
108
+ /**
109
+ * Scan a directory silently and return the result
110
+ */
111
+ async function silentScan(dir) {
112
+ try {
113
+ return await run(dir, { _capture: true });
114
+ } catch (err) {
115
+ return { summary: { riskScore: 0, total: 0 }, threats: [], error: err.message };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 1. Ground Truth — scan real-world attack samples
121
+ */
122
+ async function evaluateGroundTruth() {
123
+ const attacksFile = path.join(GT_DIR, 'attacks.json');
124
+ const data = JSON.parse(fs.readFileSync(attacksFile, 'utf8'));
125
+ const attacks = data.attacks.filter(a => a.expected.min_threats > 0);
126
+
127
+ const details = [];
128
+ let detected = 0;
129
+
130
+ for (const attack of attacks) {
131
+ const sampleDir = path.join(GT_DIR, attack.sample_dir);
132
+ const result = await silentScan(sampleDir);
133
+ const score = result.summary.riskScore;
134
+ const isDetected = score >= GT_THRESHOLD;
135
+ if (isDetected) detected++;
136
+ details.push({
137
+ name: attack.name,
138
+ id: attack.id,
139
+ score,
140
+ detected: isDetected,
141
+ threshold: GT_THRESHOLD
142
+ });
143
+ }
144
+
145
+ const total = attacks.length;
146
+ const tpr = total > 0 ? detected / total : 0;
147
+ return { detected, total, tpr, details };
148
+ }
149
+
150
+ // =========================================================================
151
+ // 2. Benign — download real tarballs and scan actual source code
152
+ // =========================================================================
153
+
154
+ /**
155
+ * Convert a package name to a safe cache directory name.
156
+ * @scoped/pkg → _scoped_pkg
157
+ */
158
+ function pkgToCacheName(pkg) {
159
+ return pkg.replace(/\//g, '_').replace(/@/g, '_');
160
+ }
161
+
162
+ /**
163
+ * Extract a .tgz file using Node.js built-in zlib + minimal tar parser.
164
+ * Only extracts regular files (type '0' or NUL).
165
+ */
166
+ function extractTgz(tgzPath, destDir) {
167
+ const compressed = fs.readFileSync(tgzPath);
168
+ const tarData = zlib.gunzipSync(compressed);
169
+
170
+ let offset = 0;
171
+ while (offset + 512 <= tarData.length) {
172
+ const header = tarData.subarray(offset, offset + 512);
173
+
174
+ // Check for end-of-archive (two zero blocks)
175
+ if (header.every(b => b === 0)) break;
176
+
177
+ // Parse tar header
178
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
179
+ const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
180
+ const size = parseInt(sizeOctal, 8) || 0;
181
+ const typeFlag = String.fromCharCode(header[156]);
182
+
183
+ offset += 512; // move past header
184
+
185
+ if (name && (typeFlag === '0' || typeFlag === '\0') && size > 0) {
186
+ // Regular file — extract it
187
+ const filePath = path.join(destDir, name);
188
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
189
+ const fileData = tarData.subarray(offset, offset + size);
190
+ fs.writeFileSync(filePath, fileData);
191
+ }
192
+
193
+ // Advance past data blocks (512-byte aligned)
194
+ offset += Math.ceil(size / 512) * 512;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Download a package tarball via `npm pack` and extract with native Node.js.
200
+ * Returns the path to the extracted package directory, or null on failure.
201
+ * Uses a persistent cache to avoid re-downloading.
202
+ */
203
+ function downloadAndExtract(pkg, options = {}) {
204
+ const cacheName = pkgToCacheName(pkg);
205
+ const pkgCacheDir = path.join(CACHE_DIR, cacheName);
206
+
207
+ // Check cache first (unless refreshing)
208
+ if (!options.refreshBenign && fs.existsSync(pkgCacheDir)) {
209
+ const extractedDir = path.join(pkgCacheDir, 'package');
210
+ if (fs.existsSync(extractedDir)) {
211
+ return extractedDir;
212
+ }
213
+ }
214
+
215
+ // Download via npm pack (cwd approach avoids Windows path issues)
216
+ fs.mkdirSync(pkgCacheDir, { recursive: true });
217
+
218
+ let tgzFilename;
219
+ try {
220
+ const output = execSync(`npm pack ${pkg}`, {
221
+ cwd: pkgCacheDir,
222
+ encoding: 'utf8',
223
+ timeout: PACK_TIMEOUT_MS,
224
+ stdio: ['pipe', 'pipe', 'pipe']
225
+ });
226
+ tgzFilename = output.trim().split(/\r?\n/).pop().trim();
227
+ } catch (err) {
228
+ if (process.env.MUADDIB_DEBUG) {
229
+ console.error(`\n [DEBUG] npm pack ${pkg} failed: ${(err.stderr || err.message || '').slice(0, 200)}`);
230
+ }
231
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
232
+ return null;
233
+ }
234
+
235
+ const tgzPath = path.join(pkgCacheDir, tgzFilename);
236
+ if (!fs.existsSync(tgzPath)) {
237
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
238
+ return null;
239
+ }
240
+
241
+ // Extract tarball using native Node.js (no shell tar dependency)
242
+ try {
243
+ extractTgz(tgzPath, pkgCacheDir);
244
+ } catch (err) {
245
+ if (process.env.MUADDIB_DEBUG) {
246
+ console.error(`\n [DEBUG] extract ${pkg} failed: ${(err.message || '').slice(0, 200)}`);
247
+ }
248
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
249
+ return null;
250
+ }
251
+
252
+ // Clean up tarball to save space
253
+ try { fs.unlinkSync(tgzPath); } catch { /* ignore */ }
254
+
255
+ const extractedDir = path.join(pkgCacheDir, 'package');
256
+ if (!fs.existsSync(extractedDir)) {
257
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
258
+ return null;
259
+ }
260
+
261
+ return extractedDir;
262
+ }
263
+
264
+ /**
265
+ * Evaluate benign packages by downloading real source code and scanning it.
266
+ */
267
+ async function evaluateBenign(options = {}) {
268
+ const listFile = path.join(BENIGN_DIR, 'packages-npm.txt');
269
+ let packages = fs.readFileSync(listFile, 'utf8')
270
+ .split(/\r?\n/)
271
+ .map(l => l.trim())
272
+ .filter(l => l && !l.startsWith('#'));
273
+
274
+ // Apply limit if specified
275
+ const limit = options.benignLimit || 0;
276
+ if (limit > 0) {
277
+ packages = packages.slice(0, limit);
278
+ }
279
+
280
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
281
+
282
+ const details = [];
283
+ let flagged = 0;
284
+ let skipped = 0;
285
+ const total = packages.length;
286
+
287
+ for (let i = 0; i < packages.length; i++) {
288
+ const pkg = packages[i];
289
+ const progress = `[${i + 1}/${total}]`;
290
+
291
+ // Progress indicator (overwrite line)
292
+ if (!options.json && process.stdout.isTTY) {
293
+ process.stdout.write(`\r [2/3] Benign ${progress} ${pkg}${''.padEnd(40)}`);
294
+ }
295
+
296
+ const extractedDir = downloadAndExtract(pkg, options);
297
+ if (!extractedDir) {
298
+ details.push({ name: pkg, score: 0, flagged: false, skipped: true, error: 'download failed' });
299
+ skipped++;
300
+ continue;
301
+ }
302
+
303
+ const result = await silentScan(extractedDir);
304
+ const score = result.summary.riskScore;
305
+ const isFlagged = score > BENIGN_THRESHOLD;
306
+ if (isFlagged) flagged++;
307
+
308
+ const entry = { name: pkg, score, flagged: isFlagged };
309
+
310
+ // Include threat details for flagged packages (for debugging FPs)
311
+ if (isFlagged && result.threats) {
312
+ entry.threats = result.threats.map(t => ({
313
+ type: t.type,
314
+ severity: t.severity,
315
+ message: t.message,
316
+ file: t.file
317
+ }));
318
+ }
319
+
320
+ details.push(entry);
321
+ }
322
+
323
+ // Clear progress line
324
+ if (!options.json && process.stdout.isTTY) {
325
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
326
+ }
327
+
328
+ const scanned = total - skipped;
329
+ const fpr = scanned > 0 ? flagged / scanned : 0;
330
+ return { flagged, total, scanned, skipped, fpr, details };
331
+ }
332
+
333
+ /**
334
+ * 3. Adversarial — scan evasive malicious samples
335
+ */
336
+ async function evaluateAdversarial() {
337
+ const details = [];
338
+ let detected = 0;
339
+
340
+ // --- Adversarial samples (35) ---
341
+ for (const [name, threshold] of Object.entries(ADVERSARIAL_THRESHOLDS)) {
342
+ const sampleDir = path.join(ADVERSARIAL_DIR, name);
343
+ if (!fs.existsSync(sampleDir)) {
344
+ details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'adversarial' });
345
+ continue;
346
+ }
347
+ const result = await silentScan(sampleDir);
348
+ const score = result.summary.riskScore;
349
+ const isDetected = score >= threshold;
350
+ if (isDetected) detected++;
351
+ details.push({ name, score, threshold, detected: isDetected, source: 'adversarial' });
352
+ }
353
+
354
+ // --- Holdout samples (40) ---
355
+ for (const [name, threshold] of Object.entries(HOLDOUT_THRESHOLDS)) {
356
+ let sampleDir = null;
357
+ for (const hDir of HOLDOUT_DIRS) {
358
+ const candidate = path.join(hDir, name);
359
+ if (fs.existsSync(candidate)) { sampleDir = candidate; break; }
360
+ }
361
+ if (!sampleDir) {
362
+ details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'holdout' });
363
+ continue;
364
+ }
365
+ const result = await silentScan(sampleDir);
366
+ const score = result.summary.riskScore;
367
+ const isDetected = score >= threshold;
368
+ if (isDetected) detected++;
369
+ details.push({ name, score, threshold, detected: isDetected, source: 'holdout' });
370
+ }
371
+
372
+ const total = Object.keys(ADVERSARIAL_THRESHOLDS).length + Object.keys(HOLDOUT_THRESHOLDS).length;
373
+ const adr = total > 0 ? detected / total : 0;
374
+ return { detected, total, adr, details };
375
+ }
376
+
377
+ /**
378
+ * Save metrics to metrics/v{version}.json
379
+ */
380
+ function saveMetrics(report) {
381
+ if (!fs.existsSync(METRICS_DIR)) {
382
+ fs.mkdirSync(METRICS_DIR, { recursive: true });
383
+ }
384
+ const filename = `v${report.version}.json`;
385
+ const filepath = path.join(METRICS_DIR, filename);
386
+ fs.writeFileSync(filepath, JSON.stringify(report, null, 2));
387
+ return filepath;
388
+ }
389
+
390
+ /**
391
+ * Main evaluate function
392
+ *
393
+ * Options:
394
+ * json — JSON output mode
395
+ * benignLimit — Only test first N benign packages
396
+ * refreshBenign — Force re-download of all tarballs
397
+ */
398
+ async function evaluate(options = {}) {
399
+ const version = require('../../package.json').version;
400
+ const jsonMode = options.json || false;
401
+
402
+ if (!jsonMode) {
403
+ console.log(`\n MUAD'DIB Evaluation (v${version})\n`);
404
+ console.log(` [1/3] Ground Truth...`);
405
+ }
406
+ const groundTruth = await evaluateGroundTruth();
407
+
408
+ if (!jsonMode) {
409
+ console.log(` [2/3] Benign packages (real source code)...`);
410
+ }
411
+ const benign = await evaluateBenign(options);
412
+
413
+ if (!jsonMode) {
414
+ console.log(` [3/3] Adversarial samples...`);
415
+ }
416
+ const adversarial = await evaluateAdversarial();
417
+
418
+ const report = {
419
+ version,
420
+ date: new Date().toISOString(),
421
+ groundTruth,
422
+ benign,
423
+ adversarial
424
+ };
425
+
426
+ const metricsPath = saveMetrics(report);
427
+
428
+ if (jsonMode) {
429
+ console.log(JSON.stringify(report, null, 2));
430
+ } else {
431
+ const tprPct = (groundTruth.tpr * 100).toFixed(1);
432
+ const fprPct = (benign.fpr * 100).toFixed(1);
433
+ const adrPct = (adversarial.adr * 100).toFixed(1);
434
+
435
+ console.log('');
436
+ console.log(` Ground Truth (TPR): ${groundTruth.detected}/${groundTruth.total} ${tprPct}%`);
437
+ console.log(` Benign (FPR): ${benign.flagged}/${benign.scanned} ${fprPct}% (${benign.skipped} skipped)`);
438
+ console.log(` Adversarial (ADR): ${adversarial.detected}/${adversarial.total} ${adrPct}%`);
439
+ console.log('');
440
+
441
+ // Show failed adversarial samples
442
+ const missed = adversarial.details.filter(d => !d.detected);
443
+ if (missed.length > 0) {
444
+ console.log(' Adversarial misses:');
445
+ for (const m of missed) {
446
+ console.log(` ${m.name}: score ${m.score} < threshold ${m.threshold}`);
447
+ }
448
+ console.log('');
449
+ }
450
+
451
+ // Show false positives with threat details
452
+ const fps = benign.details.filter(d => d.flagged);
453
+ if (fps.length > 0) {
454
+ console.log(' False positives:');
455
+ for (const fp of fps) {
456
+ console.log(` ${fp.name}: score ${fp.score}`);
457
+ if (fp.threats) {
458
+ for (const t of fp.threats) {
459
+ console.log(` [${t.severity}] ${t.type}: ${t.message}${t.file ? ' (' + t.file + ')' : ''}`);
460
+ }
461
+ }
462
+ }
463
+ console.log('');
464
+ }
465
+
466
+ console.log(` Saved: ${path.relative(ROOT, metricsPath)}`);
467
+ console.log('');
468
+ }
469
+
470
+ return report;
471
+ }
472
+
473
+ module.exports = {
474
+ evaluate,
475
+ evaluateGroundTruth,
476
+ evaluateBenign,
477
+ evaluateAdversarial,
478
+ saveMetrics,
479
+ silentScan,
480
+ ADVERSARIAL_THRESHOLDS,
481
+ HOLDOUT_THRESHOLDS,
482
+ GT_THRESHOLD,
483
+ BENIGN_THRESHOLD
484
+ };