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.
- package/.gitattributes +18 -0
- package/README.fr.md +819 -825
- package/README.md +822 -822
- package/bin/muaddib.js +868 -849
- package/docker/Dockerfile +19 -18
- package/docker/sandbox-runner.sh +313 -292
- package/package.json +61 -61
- package/src/commands/evaluate.js +484 -484
- package/src/diff.js +411 -411
- package/src/hooks-init.js +264 -258
- package/src/index.js +439 -437
- package/src/ioc/scraper.js +1293 -1293
- package/src/monitor.js +1817 -1764
- package/src/sandbox.js +631 -620
- package/src/scanner/ast-detectors.js +946 -933
- package/src/scanner/ast.js +96 -96
- package/src/scanner/github-actions.js +96 -96
- package/src/scanner/module-graph.js +2 -2
- package/src/scanner/obfuscation.js +128 -128
- package/src/scanner/shell.js +48 -48
- package/src/shared/download.js +181 -171
- package/src/webhook.js +413 -353
package/src/commands/evaluate.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
};
|