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.
Binary file
@@ -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 };