muaddib-scanner 1.6.18 → 1.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "1.6.18",
3
+ "version": "1.8.0",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/monitor.js ADDED
@@ -0,0 +1,831 @@
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync } = require('child_process');
6
+ const { run } = require('./index.js');
7
+ const { runSandbox, isDockerAvailable } = require('./sandbox.js');
8
+ const { sendWebhook } = require('./webhook.js');
9
+
10
+ const STATE_FILE = path.join(__dirname, '..', 'data', 'monitor-state.json');
11
+ const ALERTS_FILE = path.join(__dirname, '..', 'data', 'monitor-alerts.json');
12
+ const POLL_INTERVAL = 60_000;
13
+ const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
14
+ const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
15
+
16
+ // --- Stats counters ---
17
+
18
+ const stats = {
19
+ scanned: 0,
20
+ clean: 0,
21
+ suspect: 0,
22
+ errors: 0,
23
+ totalTimeMs: 0,
24
+ lastReportTime: Date.now(),
25
+ lastDailyReportTime: Date.now()
26
+ };
27
+
28
+ // Track daily suspects for the daily report (name, version, ecosystem, findingsCount)
29
+ const dailyAlerts = [];
30
+
31
+ // --- Scan queue (FIFO, sequential) ---
32
+
33
+ const scanQueue = [];
34
+
35
+ // --- Sandbox integration ---
36
+
37
+ let sandboxAvailable = false;
38
+
39
+ function isSandboxEnabled() {
40
+ const env = process.env.MUADDIB_MONITOR_SANDBOX;
41
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
42
+ return true;
43
+ }
44
+
45
+ function hasHighOrCritical(result) {
46
+ return result.summary.critical > 0 || result.summary.high > 0;
47
+ }
48
+
49
+ // --- Webhook alerting ---
50
+
51
+ function getWebhookUrl() {
52
+ return process.env.MUADDIB_WEBHOOK_URL || null;
53
+ }
54
+
55
+ function shouldSendWebhook(result, sandboxResult) {
56
+ if (!getWebhookUrl()) return false;
57
+ if (hasHighOrCritical(result)) return true;
58
+ if (sandboxResult && sandboxResult.score > 50) return true;
59
+ return false;
60
+ }
61
+
62
+ function buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult) {
63
+ const payload = {
64
+ event: 'malicious_package',
65
+ package: name,
66
+ version,
67
+ ecosystem,
68
+ timestamp: new Date().toISOString(),
69
+ findings: result.threats.map(t => ({
70
+ rule: t.rule_id || t.type,
71
+ severity: t.severity
72
+ }))
73
+ };
74
+ if (sandboxResult && sandboxResult.score > 0) {
75
+ payload.sandbox = {
76
+ score: sandboxResult.score,
77
+ severity: sandboxResult.severity
78
+ };
79
+ }
80
+ return payload;
81
+ }
82
+
83
+ function computeRiskLevel(summary) {
84
+ if (summary.critical > 0) return 'CRITICAL';
85
+ if (summary.high > 0) return 'HIGH';
86
+ if (summary.medium > 0) return 'MEDIUM';
87
+ if (summary.low > 0) return 'LOW';
88
+ return 'CLEAN';
89
+ }
90
+
91
+ function computeRiskScore(summary) {
92
+ const raw = (summary.critical || 0) * 25
93
+ + (summary.high || 0) * 15
94
+ + (summary.medium || 0) * 5
95
+ + (summary.low || 0) * 1;
96
+ return Math.min(raw, 100);
97
+ }
98
+
99
+ async function trySendWebhook(name, version, ecosystem, result, sandboxResult) {
100
+ if (!shouldSendWebhook(result, sandboxResult)) return;
101
+ const url = getWebhookUrl();
102
+ const payload = buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult);
103
+ const webhookData = {
104
+ target: `${ecosystem}/${name}@${version}`,
105
+ timestamp: payload.timestamp,
106
+ ecosystem,
107
+ summary: {
108
+ ...result.summary,
109
+ riskLevel: computeRiskLevel(result.summary),
110
+ riskScore: computeRiskScore(result.summary)
111
+ },
112
+ threats: result.threats
113
+ };
114
+ if (sandboxResult && sandboxResult.score > 0) {
115
+ webhookData.sandbox = {
116
+ score: sandboxResult.score,
117
+ severity: sandboxResult.severity
118
+ };
119
+ }
120
+ try {
121
+ await sendWebhook(url, webhookData);
122
+ console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
123
+ } catch (err) {
124
+ console.error(`[MONITOR] Webhook failed for ${name}@${version}: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ // --- State persistence ---
129
+
130
+ function loadState() {
131
+ try {
132
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
133
+ const state = JSON.parse(raw);
134
+ return {
135
+ npmLastPackage: typeof state.npmLastPackage === 'string' ? state.npmLastPackage : '',
136
+ pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
137
+ };
138
+ } catch {
139
+ return { npmLastPackage: '', pypiLastPackage: '' };
140
+ }
141
+ }
142
+
143
+ function saveState(state) {
144
+ try {
145
+ const dir = path.dirname(STATE_FILE);
146
+ if (!fs.existsSync(dir)) {
147
+ fs.mkdirSync(dir, { recursive: true });
148
+ }
149
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
150
+ } catch (err) {
151
+ console.error(`[MONITOR] Failed to save state: ${err.message}`);
152
+ }
153
+ }
154
+
155
+ // --- HTTP helpers ---
156
+
157
+ function httpsGet(url, timeoutMs = 30_000) {
158
+ return new Promise((resolve, reject) => {
159
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
160
+ if (res.statusCode === 301 || res.statusCode === 302) {
161
+ res.resume();
162
+ const location = res.headers.location;
163
+ if (!location) return reject(new Error(`Redirect without Location for ${url}`));
164
+ return httpsGet(location, timeoutMs).then(resolve, reject);
165
+ }
166
+ if (res.statusCode < 200 || res.statusCode >= 300) {
167
+ res.resume();
168
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
169
+ }
170
+ const chunks = [];
171
+ res.on('data', (chunk) => chunks.push(chunk));
172
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
173
+ res.on('error', reject);
174
+ });
175
+ req.on('error', reject);
176
+ req.on('timeout', () => {
177
+ req.destroy();
178
+ reject(new Error(`Timeout for ${url}`));
179
+ });
180
+ });
181
+ }
182
+
183
+ // --- Download & extraction helpers ---
184
+
185
+ function downloadToFile(url, destPath, timeoutMs = 30_000) {
186
+ return new Promise((resolve, reject) => {
187
+ const doRequest = (requestUrl) => {
188
+ const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
189
+ if (res.statusCode === 301 || res.statusCode === 302) {
190
+ res.resume();
191
+ const location = res.headers.location;
192
+ if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
193
+ return doRequest(location);
194
+ }
195
+ if (res.statusCode < 200 || res.statusCode >= 300) {
196
+ res.resume();
197
+ return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
198
+ }
199
+ const contentLength = parseInt(res.headers['content-length'], 10);
200
+ if (contentLength && contentLength > MAX_TARBALL_SIZE) {
201
+ res.resume();
202
+ return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
203
+ }
204
+ const fileStream = fs.createWriteStream(destPath);
205
+ let downloadedBytes = 0;
206
+ res.on('data', (chunk) => {
207
+ downloadedBytes += chunk.length;
208
+ if (downloadedBytes > MAX_TARBALL_SIZE) {
209
+ res.destroy();
210
+ fileStream.destroy();
211
+ try { fs.unlinkSync(destPath); } catch {}
212
+ reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
213
+ }
214
+ });
215
+ res.pipe(fileStream);
216
+ fileStream.on('finish', () => resolve(downloadedBytes));
217
+ fileStream.on('error', (err) => {
218
+ try { fs.unlinkSync(destPath); } catch {}
219
+ reject(err);
220
+ });
221
+ res.on('error', (err) => {
222
+ fileStream.destroy();
223
+ try { fs.unlinkSync(destPath); } catch {}
224
+ reject(err);
225
+ });
226
+ });
227
+ req.on('error', reject);
228
+ req.on('timeout', () => {
229
+ req.destroy();
230
+ reject(new Error(`Timeout downloading ${requestUrl}`));
231
+ });
232
+ };
233
+ doRequest(url);
234
+ });
235
+ }
236
+
237
+ function extractTarGz(tgzPath, destDir) {
238
+ // Use --force-local on Windows so tar doesn't interpret C: as a remote host
239
+ const forceLocal = process.platform === 'win32' ? ' --force-local' : '';
240
+ execSync(`tar xzf "${tgzPath}"${forceLocal} -C "${destDir}"`, { timeout: 60_000, stdio: 'pipe' });
241
+ // npm tarballs extract into a package/ subdirectory; detect it
242
+ const packageSubdir = path.join(destDir, 'package');
243
+ if (fs.existsSync(packageSubdir) && fs.statSync(packageSubdir).isDirectory()) {
244
+ return packageSubdir;
245
+ }
246
+ // Otherwise return destDir itself (PyPI sdists vary)
247
+ const entries = fs.readdirSync(destDir);
248
+ if (entries.length === 1) {
249
+ const single = path.join(destDir, entries[0]);
250
+ if (fs.statSync(single).isDirectory()) return single;
251
+ }
252
+ return destDir;
253
+ }
254
+
255
+ // --- Tarball URL helpers ---
256
+
257
+ function getNpmTarballUrl(pkgData) {
258
+ return (pkgData.dist && pkgData.dist.tarball) || null;
259
+ }
260
+
261
+ async function getPyPITarballUrl(packageName) {
262
+ const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
263
+ const body = await httpsGet(url);
264
+ const data = JSON.parse(body);
265
+ const version = (data.info && data.info.version) || '';
266
+ const urls = data.urls || [];
267
+ // Prefer sdist (.tar.gz)
268
+ const sdist = urls.find(u => u.packagetype === 'sdist' && u.url);
269
+ if (sdist) return { url: sdist.url, version };
270
+ // Fallback: any .tar.gz
271
+ const tarGz = urls.find(u => u.url && u.url.endsWith('.tar.gz'));
272
+ if (tarGz) return { url: tarGz.url, version };
273
+ // Fallback: first available file
274
+ if (urls.length > 0 && urls[0].url) return { url: urls[0].url, version };
275
+ return { url: null, version };
276
+ }
277
+
278
+ // --- Alerts persistence ---
279
+
280
+ function appendAlert(alert) {
281
+ try {
282
+ const dir = path.dirname(ALERTS_FILE);
283
+ if (!fs.existsSync(dir)) {
284
+ fs.mkdirSync(dir, { recursive: true });
285
+ }
286
+ let alerts = [];
287
+ try {
288
+ alerts = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
289
+ } catch {}
290
+ alerts.push(alert);
291
+ fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2), 'utf8');
292
+ } catch (err) {
293
+ console.error(`[MONITOR] Failed to save alert: ${err.message}`);
294
+ }
295
+ }
296
+
297
+ // --- Bundled tooling false-positive filter ---
298
+
299
+ const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
300
+
301
+ function isBundledToolingOnly(threats) {
302
+ if (threats.length === 0) return false;
303
+ return threats.every(t => {
304
+ if (!t.file) return false;
305
+ const basename = path.basename(t.file);
306
+ return KNOWN_BUNDLED_FILES.includes(basename);
307
+ });
308
+ }
309
+
310
+ // --- Package scanning ---
311
+
312
+ async function scanPackage(name, version, ecosystem, tarballUrl) {
313
+ const startTime = Date.now();
314
+ const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
315
+ if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
316
+ const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${name.replace(/\//g, '_')}-`));
317
+
318
+ try {
319
+ const tgzPath = path.join(tmpDir, 'package.tar.gz');
320
+ await downloadToFile(tarballUrl, tgzPath);
321
+
322
+ // Check downloaded size
323
+ const fileSize = fs.statSync(tgzPath).size;
324
+ if (fileSize > MAX_TARBALL_SIZE) {
325
+ console.log(`[MONITOR] SKIP: ${name}@${version} — tarball too large (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
326
+ stats.scanned++;
327
+ return;
328
+ }
329
+
330
+ const extractedDir = extractTarGz(tgzPath, tmpDir);
331
+ const result = await run(extractedDir, { _capture: true });
332
+
333
+ if (result.summary.total === 0) {
334
+ stats.scanned++;
335
+ const elapsed = Date.now() - startTime;
336
+ stats.totalTimeMs += elapsed;
337
+ stats.clean++;
338
+ console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
339
+ } else {
340
+ const counts = [];
341
+ if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
342
+ if (result.summary.high > 0) counts.push(`${result.summary.high} HIGH`);
343
+ if (result.summary.medium > 0) counts.push(`${result.summary.medium} MEDIUM`);
344
+ if (result.summary.low > 0) counts.push(`${result.summary.low} LOW`);
345
+
346
+ // Check if all findings come from bundled tooling files
347
+ if (isBundledToolingOnly(result.threats)) {
348
+ stats.scanned++;
349
+ const elapsed = Date.now() - startTime;
350
+ stats.totalTimeMs += elapsed;
351
+ stats.clean++;
352
+ console.log(`[MONITOR] SKIPPED (bundled tooling): ${name}@${version} (${counts.join(', ')})`);
353
+
354
+ const alert = {
355
+ timestamp: new Date().toISOString(),
356
+ name,
357
+ version,
358
+ ecosystem,
359
+ skipped: true,
360
+ findings: result.threats.map(t => ({
361
+ rule: t.rule_id || t.type,
362
+ severity: t.severity,
363
+ file: t.file
364
+ }))
365
+ };
366
+ appendAlert(alert);
367
+ } else {
368
+ stats.suspect++;
369
+ console.log(`[MONITOR] SUSPECT: ${name}@${version} (${counts.join(', ')})`);
370
+
371
+ // Sandbox: run dynamic analysis on HIGH/CRITICAL findings
372
+ let sandboxResult = null;
373
+ if (hasHighOrCritical(result) && isSandboxEnabled() && sandboxAvailable) {
374
+ try {
375
+ console.log(`[MONITOR] SANDBOX: launching for ${name}@${version}...`);
376
+ sandboxResult = await runSandbox(name);
377
+ console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
378
+ } catch (err) {
379
+ console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
380
+ }
381
+ }
382
+
383
+ stats.scanned++;
384
+ const elapsed = Date.now() - startTime;
385
+ stats.totalTimeMs += elapsed;
386
+ console.log(`[MONITOR] ${name}@${version} total time: ${(elapsed / 1000).toFixed(1)}s`);
387
+
388
+ const alert = {
389
+ timestamp: new Date().toISOString(),
390
+ name,
391
+ version,
392
+ ecosystem,
393
+ findings: result.threats.map(t => ({
394
+ rule: t.rule_id || t.type,
395
+ severity: t.severity,
396
+ file: t.file
397
+ }))
398
+ };
399
+
400
+ if (sandboxResult && sandboxResult.score > 0) {
401
+ alert.sandbox = {
402
+ score: sandboxResult.score,
403
+ severity: sandboxResult.severity,
404
+ findings: sandboxResult.findings
405
+ };
406
+ }
407
+
408
+ appendAlert(alert);
409
+ dailyAlerts.push({ name, version, ecosystem, findingsCount: result.summary.total });
410
+ await trySendWebhook(name, version, ecosystem, result, sandboxResult);
411
+ }
412
+ }
413
+ } catch (err) {
414
+ stats.errors++;
415
+ stats.scanned++;
416
+ stats.totalTimeMs += Date.now() - startTime;
417
+ console.error(`[MONITOR] ERROR scanning ${name}@${version}: ${err.message}`);
418
+ } finally {
419
+ // Cleanup temp dir
420
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
421
+ }
422
+ }
423
+
424
+ function timeoutPromise(ms) {
425
+ return new Promise((_, reject) => {
426
+ setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
427
+ });
428
+ }
429
+
430
+ async function processQueue() {
431
+ while (scanQueue.length > 0) {
432
+ const item = scanQueue.shift();
433
+ try {
434
+ await Promise.race([
435
+ resolveTarballAndScan(item),
436
+ timeoutPromise(SCAN_TIMEOUT_MS)
437
+ ]);
438
+ } catch (err) {
439
+ stats.errors++;
440
+ console.error(`[MONITOR] Queue error for ${item.name}: ${err.message}`);
441
+ }
442
+ }
443
+ }
444
+
445
+ // --- Stats reporting ---
446
+
447
+ function reportStats() {
448
+ const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
449
+ console.log(`[MONITOR] Stats: ${stats.scanned} scanned, ${stats.clean} clean, ${stats.suspect} suspect, ${stats.errors} error${stats.errors !== 1 ? 's' : ''}, avg ${avg}s/pkg`);
450
+ stats.lastReportTime = Date.now();
451
+ }
452
+
453
+ const DAILY_REPORT_INTERVAL = 24 * 3600_000; // 24 hours
454
+
455
+ function buildDailyReportEmbed() {
456
+ const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
457
+
458
+ // Top 3 suspects sorted by findings count descending
459
+ const top3 = dailyAlerts
460
+ .slice()
461
+ .sort((a, b) => b.findingsCount - a.findingsCount)
462
+ .slice(0, 3);
463
+
464
+ const top3Text = top3.length > 0
465
+ ? top3.map((a, i) => `${i + 1}. **${a.ecosystem}/${a.name}@${a.version}** — ${a.findingsCount} finding(s)`).join('\n')
466
+ : 'None';
467
+
468
+ const now = new Date();
469
+ const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
470
+
471
+ return {
472
+ embeds: [{
473
+ title: '\uD83D\uDCCA MUAD\'DIB Daily Report',
474
+ color: 0x3498db,
475
+ fields: [
476
+ { name: 'Packages Scanned', value: `${stats.scanned}`, inline: true },
477
+ { name: 'Clean', value: `${stats.clean}`, inline: true },
478
+ { name: 'Suspects', value: `${stats.suspect}`, inline: true },
479
+ { name: 'Errors', value: `${stats.errors}`, inline: true },
480
+ { name: 'Avg Scan Time', value: `${avg}s/pkg`, inline: true },
481
+ { name: 'Top Suspects', value: top3Text, inline: false }
482
+ ],
483
+ footer: {
484
+ text: `MUAD'DIB - Daily summary | ${readableTime}`
485
+ },
486
+ timestamp: now.toISOString()
487
+ }]
488
+ };
489
+ }
490
+
491
+ async function sendDailyReport() {
492
+ const url = getWebhookUrl();
493
+ if (!url) return;
494
+
495
+ const payload = buildDailyReportEmbed();
496
+ try {
497
+ await sendWebhook(url, payload, { rawPayload: true });
498
+ console.log('[MONITOR] Daily report sent');
499
+ } catch (err) {
500
+ console.error(`[MONITOR] Daily report webhook failed: ${err.message}`);
501
+ }
502
+
503
+ // Reset daily counters
504
+ stats.scanned = 0;
505
+ stats.clean = 0;
506
+ stats.suspect = 0;
507
+ stats.errors = 0;
508
+ stats.totalTimeMs = 0;
509
+ dailyAlerts.length = 0;
510
+ stats.lastDailyReportTime = Date.now();
511
+ }
512
+
513
+ // --- npm polling ---
514
+
515
+ /**
516
+ * Parse npm RSS XML (same regex approach as parsePyPIRss).
517
+ * Returns array of package names from <title> tags inside <item>.
518
+ */
519
+ function parseNpmRss(xml) {
520
+ const packages = [];
521
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
522
+ let match;
523
+ while ((match = itemRegex.exec(xml)) !== null) {
524
+ const itemContent = match[1];
525
+ const titleMatch = itemContent.match(/<title>([^<]+)<\/title>/);
526
+ if (titleMatch) {
527
+ const title = titleMatch[1].trim();
528
+ const name = title.split(/\s+/)[0];
529
+ if (name) {
530
+ packages.push(name);
531
+ }
532
+ }
533
+ }
534
+ return packages;
535
+ }
536
+
537
+ /**
538
+ * Fetch latest version metadata for an npm package.
539
+ * Returns { version, tarball } or null on failure.
540
+ */
541
+ async function getNpmLatestTarball(packageName) {
542
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
543
+ const body = await httpsGet(url);
544
+ const data = JSON.parse(body);
545
+ const version = data.version || '';
546
+ const tarball = (data.dist && data.dist.tarball) || null;
547
+ return { version, tarball };
548
+ }
549
+
550
+ async function pollNpm(state) {
551
+ const url = 'https://registry.npmjs.org/-/rss?descending=true&limit=50';
552
+
553
+ try {
554
+ const body = await httpsGet(url);
555
+ const packages = parseNpmRss(body);
556
+
557
+ // Find new packages (those after the last seen one)
558
+ let newPackages;
559
+ if (!state.npmLastPackage) {
560
+ newPackages = packages;
561
+ } else {
562
+ const lastIdx = packages.indexOf(state.npmLastPackage);
563
+ if (lastIdx === -1) {
564
+ newPackages = packages;
565
+ } else {
566
+ newPackages = packages.slice(0, lastIdx);
567
+ }
568
+ }
569
+
570
+ for (const name of newPackages) {
571
+ console.log(`[MONITOR] New npm: ${name}`);
572
+ // Queue npm packages — tarball URL resolved during scan
573
+ scanQueue.push({
574
+ name,
575
+ version: '',
576
+ ecosystem: 'npm',
577
+ tarballUrl: null // resolved lazily via resolveTarballAndScan
578
+ });
579
+ }
580
+
581
+ // Remember the most recent package (first in RSS)
582
+ if (packages.length > 0) {
583
+ state.npmLastPackage = packages[0];
584
+ }
585
+
586
+ return newPackages.length;
587
+ } catch (err) {
588
+ console.error(`[MONITOR] npm poll error: ${err.message}`);
589
+ return 0;
590
+ }
591
+ }
592
+
593
+ // --- PyPI polling ---
594
+
595
+ /**
596
+ * Parse PyPI RSS XML (simple regex, no deps).
597
+ * Returns array of package names from <title> tags inside <item>.
598
+ */
599
+ function parsePyPIRss(xml) {
600
+ const packages = [];
601
+ // Match each <item>...</item> block
602
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
603
+ let match;
604
+ while ((match = itemRegex.exec(xml)) !== null) {
605
+ const itemContent = match[1];
606
+ // Extract <title>...</title> inside item
607
+ const titleMatch = itemContent.match(/<title>([^<]+)<\/title>/);
608
+ if (titleMatch) {
609
+ // Title format is usually "package-name 1.0.0"
610
+ const title = titleMatch[1].trim();
611
+ // Extract just the package name (first word before space or version)
612
+ const name = title.split(/\s+/)[0];
613
+ if (name) {
614
+ packages.push(name);
615
+ }
616
+ }
617
+ }
618
+ return packages;
619
+ }
620
+
621
+ async function pollPyPI(state) {
622
+ const url = 'https://pypi.org/rss/packages.xml';
623
+
624
+ try {
625
+ const body = await httpsGet(url);
626
+ const packages = parsePyPIRss(body);
627
+
628
+ // Find new packages (those after the last seen one)
629
+ let newPackages;
630
+ if (!state.pypiLastPackage) {
631
+ // First run: log all and remember the first one
632
+ newPackages = packages;
633
+ } else {
634
+ const lastIdx = packages.indexOf(state.pypiLastPackage);
635
+ if (lastIdx === -1) {
636
+ // Last seen not in feed — all are new
637
+ newPackages = packages;
638
+ } else {
639
+ // Items before lastIdx are newer (RSS is newest-first)
640
+ newPackages = packages.slice(0, lastIdx);
641
+ }
642
+ }
643
+
644
+ for (const name of newPackages) {
645
+ console.log(`[MONITOR] New pypi: ${name}`);
646
+ // Queue PyPI packages — tarball URL resolved during scan
647
+ scanQueue.push({
648
+ name,
649
+ version: '',
650
+ ecosystem: 'pypi',
651
+ tarballUrl: null // resolved lazily in scanPackage wrapper
652
+ });
653
+ }
654
+
655
+ // Remember the most recent package (first in RSS)
656
+ if (packages.length > 0) {
657
+ state.pypiLastPackage = packages[0];
658
+ }
659
+
660
+ return newPackages.length;
661
+ } catch (err) {
662
+ console.error(`[MONITOR] PyPI poll error: ${err.message}`);
663
+ return 0;
664
+ }
665
+ }
666
+
667
+ // --- Main loop ---
668
+
669
+ async function startMonitor() {
670
+ console.log(`
671
+ ╔════════════════════════════════════════════╗
672
+ ║ MUAD'DIB - Registry Monitor ║
673
+ ║ Scanning npm + PyPI new packages ║
674
+ ╚════════════════════════════════════════════╝
675
+ `);
676
+
677
+ // Check sandbox availability
678
+ if (isSandboxEnabled()) {
679
+ sandboxAvailable = isDockerAvailable();
680
+ if (sandboxAvailable) {
681
+ console.log('[MONITOR] Docker detected — sandbox enabled for HIGH/CRITICAL findings');
682
+ } else {
683
+ console.log('[MONITOR] WARNING: Docker not available — running static analysis only');
684
+ }
685
+ } else {
686
+ console.log('[MONITOR] Sandbox disabled (MUADDIB_MONITOR_SANDBOX=false)');
687
+ }
688
+
689
+ const state = loadState();
690
+ console.log(`[MONITOR] State loaded — npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}`);
691
+ console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
692
+
693
+ let running = true;
694
+
695
+ // SIGINT: save state and exit
696
+ process.on('SIGINT', () => {
697
+ console.log('\n[MONITOR] Stopping — saving state...');
698
+ saveState(state);
699
+ reportStats();
700
+ console.log('[MONITOR] State saved. Bye!');
701
+ running = false;
702
+ process.exit(0);
703
+ });
704
+
705
+ // Initial poll + scan
706
+ await poll(state);
707
+ saveState(state);
708
+ await processQueue();
709
+
710
+ // Interval polling
711
+ while (running) {
712
+ await sleep(POLL_INTERVAL);
713
+ if (!running) break;
714
+ await poll(state);
715
+ saveState(state);
716
+ await processQueue();
717
+
718
+ // Hourly stats report
719
+ if (Date.now() - stats.lastReportTime >= 3600_000) {
720
+ reportStats();
721
+ }
722
+
723
+ // Daily webhook report
724
+ if (Date.now() - stats.lastDailyReportTime >= DAILY_REPORT_INTERVAL) {
725
+ await sendDailyReport();
726
+ }
727
+ }
728
+ }
729
+
730
+ async function poll(state) {
731
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
732
+ console.log(`[MONITOR] ${timestamp} — polling registries...`);
733
+
734
+ const [npmCount, pypiCount] = await Promise.all([
735
+ pollNpm(state),
736
+ pollPyPI(state)
737
+ ]);
738
+
739
+ console.log(`[MONITOR] Found ${npmCount} npm + ${pypiCount} PyPI new packages`);
740
+ }
741
+
742
+ /**
743
+ * Wrapper to resolve PyPI tarball URLs before scanning.
744
+ * For npm packages, tarballUrl is already set from the registry response.
745
+ * For PyPI packages, we need to fetch the JSON API to get the tarball URL.
746
+ */
747
+ async function resolveTarballAndScan(item) {
748
+ if (item.ecosystem === 'npm' && !item.tarballUrl) {
749
+ try {
750
+ const npmInfo = await getNpmLatestTarball(item.name);
751
+ if (!npmInfo.tarball) {
752
+ console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on npm`);
753
+ return;
754
+ }
755
+ item.tarballUrl = npmInfo.tarball;
756
+ if (npmInfo.version) item.version = npmInfo.version;
757
+ } catch (err) {
758
+ console.error(`[MONITOR] ERROR resolving npm tarball for ${item.name}: ${err.message}`);
759
+ stats.errors++;
760
+ return;
761
+ }
762
+ }
763
+ if (item.ecosystem === 'pypi' && !item.tarballUrl) {
764
+ try {
765
+ const pypiInfo = await getPyPITarballUrl(item.name);
766
+ if (!pypiInfo.url) {
767
+ console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on PyPI`);
768
+ return;
769
+ }
770
+ item.tarballUrl = pypiInfo.url;
771
+ if (pypiInfo.version) item.version = pypiInfo.version;
772
+ } catch (err) {
773
+ console.error(`[MONITOR] ERROR resolving PyPI tarball for ${item.name}: ${err.message}`);
774
+ stats.errors++;
775
+ return;
776
+ }
777
+ }
778
+ await scanPackage(item.name, item.version, item.ecosystem, item.tarballUrl);
779
+ }
780
+
781
+ function sleep(ms) {
782
+ return new Promise((resolve) => setTimeout(resolve, ms));
783
+ }
784
+
785
+ module.exports = {
786
+ startMonitor,
787
+ parseNpmRss,
788
+ parsePyPIRss,
789
+ loadState,
790
+ saveState,
791
+ STATE_FILE,
792
+ ALERTS_FILE,
793
+ downloadToFile,
794
+ extractTarGz,
795
+ getNpmTarballUrl,
796
+ getNpmLatestTarball,
797
+ getPyPITarballUrl,
798
+ scanPackage,
799
+ scanQueue,
800
+ processQueue,
801
+ appendAlert,
802
+ timeoutPromise,
803
+ reportStats,
804
+ stats,
805
+ dailyAlerts,
806
+ resolveTarballAndScan,
807
+ MAX_TARBALL_SIZE,
808
+ KNOWN_BUNDLED_FILES,
809
+ isBundledToolingOnly,
810
+ isSandboxEnabled,
811
+ hasHighOrCritical,
812
+ get sandboxAvailable() { return sandboxAvailable; },
813
+ set sandboxAvailable(v) { sandboxAvailable = v; },
814
+ getWebhookUrl,
815
+ shouldSendWebhook,
816
+ buildMonitorWebhookPayload,
817
+ trySendWebhook,
818
+ computeRiskLevel,
819
+ computeRiskScore,
820
+ buildDailyReportEmbed,
821
+ sendDailyReport,
822
+ DAILY_REPORT_INTERVAL
823
+ };
824
+
825
+ // Standalone entry point: node src/monitor.js
826
+ if (require.main === module) {
827
+ startMonitor().catch(err => {
828
+ console.error('[MONITOR] Fatal error:', err.message);
829
+ process.exit(1);
830
+ });
831
+ }
package/src/webhook.js CHANGED
@@ -58,7 +58,7 @@ function validateWebhookUrl(url) {
58
58
  }
59
59
  }
60
60
 
61
- async function sendWebhook(url, results) {
61
+ async function sendWebhook(url, results, options = {}) {
62
62
  // Validate URL before sending
63
63
  const validation = validateWebhookUrl(url);
64
64
  if (!validation.valid) {
@@ -89,6 +89,11 @@ async function sendWebhook(url, results) {
89
89
  throw new Error(`Webhook blocked: DNS resolution failed for ${urlObj.hostname}`);
90
90
  }
91
91
 
92
+ // rawPayload: send the results object directly as the payload (for pre-built embeds)
93
+ if (options.rawPayload) {
94
+ return send(url, results, resolvedAddress);
95
+ }
96
+
92
97
  const isDiscord = url.includes('discord.com');
93
98
  const isSlack = url.includes('hooks.slack.com');
94
99
 
@@ -107,13 +112,18 @@ async function sendWebhook(url, results) {
107
112
 
108
113
  function formatDiscord(results) {
109
114
  const { summary, threats, target } = results;
110
-
115
+
111
116
  const color = summary.riskLevel === 'CRITICAL' ? 0xe74c3c
112
117
  : summary.riskLevel === 'HIGH' ? 0xe67e22
113
118
  : summary.riskLevel === 'MEDIUM' ? 0xf1c40f
114
119
  : summary.riskLevel === 'LOW' ? 0x3498db
115
120
  : 0x2ecc71;
116
121
 
122
+ const emoji = summary.riskLevel === 'CRITICAL' ? '\uD83D\uDD34'
123
+ : summary.riskLevel === 'HIGH' ? '\uD83D\uDFE0'
124
+ : summary.riskLevel === 'MEDIUM' ? '\uD83D\uDFE1'
125
+ : '';
126
+
117
127
  const criticalThreats = threats
118
128
  .filter(t => t.severity === 'CRITICAL')
119
129
  .slice(0, 5)
@@ -138,6 +148,32 @@ function formatDiscord(results) {
138
148
  }
139
149
  ];
140
150
 
151
+ // Add ecosystem field if available
152
+ if (results.ecosystem) {
153
+ fields.push({
154
+ name: 'Ecosystem',
155
+ value: results.ecosystem.toUpperCase(),
156
+ inline: true
157
+ });
158
+ }
159
+
160
+ // Add package link if ecosystem info is available
161
+ if (results.ecosystem && target) {
162
+ // Extract package name from target (format: "ecosystem/name@version")
163
+ const nameMatch = target.match(/^(?:npm|pypi)\/(.+?)(?:@.*)?$/);
164
+ if (nameMatch) {
165
+ const pkgName = nameMatch[1];
166
+ const link = results.ecosystem === 'npm'
167
+ ? `https://www.npmjs.com/package/${pkgName}`
168
+ : `https://pypi.org/project/${pkgName}/`;
169
+ fields.push({
170
+ name: 'Package Link',
171
+ value: `[${pkgName}](${link})`,
172
+ inline: true
173
+ });
174
+ }
175
+ }
176
+
141
177
  // Add critical threats if present
142
178
  if (criticalThreats) {
143
179
  fields.push({
@@ -147,14 +183,27 @@ function formatDiscord(results) {
147
183
  });
148
184
  }
149
185
 
186
+ // Add sandbox field if sandbox results are present
187
+ if (results.sandbox) {
188
+ fields.push({
189
+ name: 'Sandbox',
190
+ value: `Score: **${results.sandbox.score}/100** (${results.sandbox.severity})`,
191
+ inline: false
192
+ });
193
+ }
194
+
195
+ const titlePrefix = emoji ? `${emoji} ` : '';
196
+ const ts = results.timestamp ? new Date(results.timestamp) : new Date();
197
+ const readableTime = ts.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
198
+
150
199
  return {
151
200
  embeds: [{
152
- title: 'MUAD\'DIB Security Scan',
201
+ title: `${titlePrefix}MUAD'DIB Security Scan`,
153
202
  description: `Scan of **${target}**`,
154
203
  color: color,
155
204
  fields: fields,
156
205
  footer: {
157
- text: 'MUAD\'DIB - Supply-chain threat detection'
206
+ text: `MUAD'DIB - Supply-chain threat detection | ${readableTime}`
158
207
  },
159
208
  timestamp: results.timestamp
160
209
  }]