muaddib-scanner 2.3.2 → 2.3.3
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 +1 -1
- package/bin/muaddib.js +2 -1
- package/package.json +1 -1
- package/README.fr.md +0 -819
- package/assets/muaddibLogo.png +0 -0
- package/src/commands/evaluate.js +0 -484
- package/src/daemon.js +0 -178
- package/src/monitor.js +0 -1880
- package/src/serve.js +0 -58
- package/src/threat-feed.js +0 -95
- package/src/webhook.js +0 -413
package/assets/muaddibLogo.png
DELETED
|
Binary file
|
package/src/commands/evaluate.js
DELETED
|
@@ -1,484 +0,0 @@
|
|
|
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': 20, '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
|
-
};
|
package/src/daemon.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { run } = require('./index.js');
|
|
4
|
-
|
|
5
|
-
let webhookUrl = null;
|
|
6
|
-
|
|
7
|
-
async function startDaemon(options = {}) {
|
|
8
|
-
webhookUrl = options.webhook || null;
|
|
9
|
-
|
|
10
|
-
console.log(`
|
|
11
|
-
╔════════════════════════════════════════════╗
|
|
12
|
-
║ MUAD'DIB Security Daemon ║
|
|
13
|
-
║ Surveillance npm install active ║
|
|
14
|
-
╚════════════════════════════════════════════╝
|
|
15
|
-
`);
|
|
16
|
-
|
|
17
|
-
console.log('[DAEMON] Demarrage...');
|
|
18
|
-
console.log(`[DAEMON] Webhook: ${webhookUrl ? 'Configure' : 'Non configure'}`);
|
|
19
|
-
console.log('[DAEMON] Ctrl+C pour arreter\n');
|
|
20
|
-
|
|
21
|
-
// Surveille le dossier courant
|
|
22
|
-
const cwd = process.cwd();
|
|
23
|
-
const watchers = watchDirectory(cwd);
|
|
24
|
-
|
|
25
|
-
// Cleanup function to close all watchers
|
|
26
|
-
function cleanup() {
|
|
27
|
-
for (const w of watchers) {
|
|
28
|
-
try { w.close(); } catch { /* ignore */ }
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Keep process alive until SIGINT
|
|
33
|
-
await new Promise((resolve) => {
|
|
34
|
-
process.once('SIGINT', () => {
|
|
35
|
-
console.log('\n[DAEMON] Arret...');
|
|
36
|
-
cleanup();
|
|
37
|
-
resolve();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
process.exit(0);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function watchDirectory(dir) {
|
|
45
|
-
const watchers = [];
|
|
46
|
-
const nodeModulesPath = path.join(dir, 'node_modules');
|
|
47
|
-
const packageLockPath = path.join(dir, 'package-lock.json');
|
|
48
|
-
const yarnLockPath = path.join(dir, 'yarn.lock');
|
|
49
|
-
|
|
50
|
-
console.log(`[DAEMON] Surveillance de ${dir}`);
|
|
51
|
-
|
|
52
|
-
// Surveille package-lock.json
|
|
53
|
-
if (fs.existsSync(packageLockPath)) {
|
|
54
|
-
const w = watchFile(packageLockPath, dir);
|
|
55
|
-
if (w) watchers.push(w);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Surveille yarn.lock
|
|
59
|
-
if (fs.existsSync(yarnLockPath)) {
|
|
60
|
-
const w = watchFile(yarnLockPath, dir);
|
|
61
|
-
if (w) watchers.push(w);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Surveille node_modules
|
|
65
|
-
if (fs.existsSync(nodeModulesPath)) {
|
|
66
|
-
watchers.push(watchNodeModules(nodeModulesPath, dir));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Surveille la creation de node_modules
|
|
70
|
-
if (process.platform === 'linux') {
|
|
71
|
-
console.log('[DAEMON] Note: recursive fs.watch may not work on Linux');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const dirWatcher = fs.watch(dir, (eventType, filename) => {
|
|
75
|
-
if (filename === 'node_modules' && eventType === 'rename') {
|
|
76
|
-
const nmPath = path.join(dir, 'node_modules');
|
|
77
|
-
if (fs.existsSync(nmPath)) {
|
|
78
|
-
console.log('[DAEMON] node_modules detecte, scan en cours...');
|
|
79
|
-
triggerScan(dir);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (filename === 'package-lock.json' || filename === 'yarn.lock') {
|
|
83
|
-
console.log(`[DAEMON] ${filename} modifie, scan en cours...`);
|
|
84
|
-
triggerScan(dir);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
dirWatcher.on('error', (err) => {
|
|
88
|
-
console.log(`[DAEMON] Watcher error on ${dir}: ${err.message}`);
|
|
89
|
-
});
|
|
90
|
-
watchers.push(dirWatcher);
|
|
91
|
-
|
|
92
|
-
return watchers;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function watchFile(filePath, projectDir) {
|
|
96
|
-
let lastMtime;
|
|
97
|
-
try {
|
|
98
|
-
lastMtime = fs.statSync(filePath).mtime.getTime();
|
|
99
|
-
} catch {
|
|
100
|
-
return null; // File deleted between existsSync and statSync
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const watcher = fs.watch(filePath, (eventType) => {
|
|
104
|
-
if (eventType === 'change') {
|
|
105
|
-
try {
|
|
106
|
-
const currentMtime = fs.statSync(filePath).mtime.getTime();
|
|
107
|
-
if (currentMtime !== lastMtime) {
|
|
108
|
-
lastMtime = currentMtime;
|
|
109
|
-
console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
|
|
110
|
-
triggerScan(projectDir);
|
|
111
|
-
}
|
|
112
|
-
} catch {
|
|
113
|
-
// File may have been deleted between watch trigger and stat
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
watcher.on('error', (err) => {
|
|
118
|
-
console.log(`[DAEMON] Watcher error on ${filePath}: ${err.message}`);
|
|
119
|
-
});
|
|
120
|
-
return watcher;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function watchNodeModules(nodeModulesPath, projectDir) {
|
|
124
|
-
const watcher = fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
|
|
125
|
-
if (filename && filename.includes('package.json')) {
|
|
126
|
-
console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
|
|
127
|
-
triggerScan(projectDir);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
watcher.on('error', (err) => {
|
|
131
|
-
console.log(`[DAEMON] Watcher error on ${nodeModulesPath}: ${err.message}`);
|
|
132
|
-
});
|
|
133
|
-
return watcher;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Per-directory scan state to prevent cross-directory scan suppression
|
|
137
|
-
const scanState = new Map();
|
|
138
|
-
|
|
139
|
-
function getScanState(dir) {
|
|
140
|
-
if (!scanState.has(dir)) {
|
|
141
|
-
scanState.set(dir, { timeout: null, lastScanTime: 0 });
|
|
142
|
-
}
|
|
143
|
-
return scanState.get(dir);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function triggerScan(dir) {
|
|
147
|
-
const now = Date.now();
|
|
148
|
-
const state = getScanState(dir);
|
|
149
|
-
|
|
150
|
-
// Debounce: attend 3 secondes avant de scanner
|
|
151
|
-
if (state.timeout) {
|
|
152
|
-
clearTimeout(state.timeout);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Evite les scans trop frequents (minimum 10 secondes entre chaque)
|
|
156
|
-
if (now - state.lastScanTime < 10000) {
|
|
157
|
-
state.timeout = setTimeout(() => triggerScan(dir), 10000 - (now - state.lastScanTime));
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
state.timeout = setTimeout(async () => {
|
|
162
|
-
state.lastScanTime = Date.now();
|
|
163
|
-
console.log(`\n[DAEMON] ========== SCAN AUTOMATIQUE ==========`);
|
|
164
|
-
console.log(`[DAEMON] Cible: ${dir}`);
|
|
165
|
-
console.log(`[DAEMON] Heure: ${new Date().toLocaleTimeString()}\n`);
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
await run(dir, { webhook: webhookUrl });
|
|
169
|
-
} catch (err) {
|
|
170
|
-
console.log(`[DAEMON] Erreur scan: ${err.message}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
console.log(`\n[DAEMON] ======================================\n`);
|
|
174
|
-
console.log('[DAEMON] En attente de modifications...');
|
|
175
|
-
}, 3000);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
module.exports = { startDaemon, watchDirectory, watchFile, watchNodeModules, triggerScan, getScanState };
|