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/src/monitor.js CHANGED
@@ -1,1764 +1,1817 @@
1
- const https = require('https');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
- const { run } = require('./index.js');
6
- const { runSandbox, isDockerAvailable } = require('./sandbox.js');
7
- const { sendWebhook } = require('./webhook.js');
8
- const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
9
- const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
10
- const { detectPublishAnomaly } = require('./publish-anomaly.js');
11
- const { detectMaintainerChange } = require('./maintainer-change.js');
12
- const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
13
- const { MAX_TARBALL_SIZE } = require('./shared/constants.js');
14
-
15
- const STATE_FILE = path.join(__dirname, '..', 'data', 'monitor-state.json');
16
- const ALERTS_FILE = path.join(__dirname, '..', 'data', 'monitor-alerts.json');
17
- const DETECTIONS_FILE = path.join(__dirname, '..', 'data', 'detections.json');
18
- const SCAN_STATS_FILE = path.join(__dirname, '..', 'data', 'scan-stats.json');
19
- const POLL_INTERVAL = 60_000;
20
- const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
21
-
22
- // --- Stats counters ---
23
-
24
- const stats = {
25
- scanned: 0,
26
- clean: 0,
27
- suspect: 0,
28
- errors: 0,
29
- totalTimeMs: 0,
30
- lastReportTime: Date.now(),
31
- lastDailyReportDate: null // YYYY-MM-DD (Paris) of last daily report sent
32
- };
33
-
34
- // Track daily suspects for the daily report (name, version, ecosystem, findingsCount)
35
- const dailyAlerts = [];
36
-
37
- // Deduplication: track recently scanned packages (cleared every 24h with daily report)
38
- const recentlyScanned = new Set();
39
-
40
- // --- Scan queue (FIFO, sequential) ---
41
-
42
- const scanQueue = [];
43
-
44
- // --- Sandbox integration ---
45
-
46
- let sandboxAvailable = false;
47
-
48
- function isCanaryEnabled() {
49
- const env = process.env.MUADDIB_MONITOR_CANARY;
50
- if (env !== undefined && env.toLowerCase() === 'false') return false;
51
- return true;
52
- }
53
-
54
- function buildCanaryExfiltrationWebhookEmbed(packageName, version, exfiltrations) {
55
- const exfilLines = exfiltrations.map(e => {
56
- return `**${e.token}** — ${e.foundIn}`;
57
- }).join('\n');
58
-
59
- const npmLink = `https://www.npmjs.com/package/${packageName}`;
60
-
61
- return {
62
- embeds: [{
63
- title: '\uD83D\uDD34 CANARY EXFILTRATION \u2014 CRITICAL',
64
- color: 0xe74c3c,
65
- fields: [
66
- { name: 'Package', value: `[${packageName}](${npmLink})`, inline: true },
67
- { name: 'Version', value: version || 'N/A', inline: true },
68
- { name: 'Severity', value: 'CRITICAL', inline: true },
69
- { name: 'Exfiltrated Tokens', value: exfilLines || 'None', inline: false },
70
- { name: 'Action', value: 'CONFIRMED MALICIOUS \u2014 Do NOT install, report to npm', inline: false }
71
- ],
72
- footer: {
73
- text: `MUAD'DIB Canary Token Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
74
- },
75
- timestamp: new Date().toISOString()
76
- }]
77
- };
78
- }
79
-
80
- function isSandboxEnabled() {
81
- const env = process.env.MUADDIB_MONITOR_SANDBOX;
82
- if (env !== undefined && env.toLowerCase() === 'false') return false;
83
- return true;
84
- }
85
-
86
- function isTemporalEnabled() {
87
- const env = process.env.MUADDIB_MONITOR_TEMPORAL;
88
- if (env !== undefined && env.toLowerCase() === 'false') return false;
89
- return true;
90
- }
91
-
92
- function hasHighOrCritical(result) {
93
- return result.summary.critical > 0 || result.summary.high > 0;
94
- }
95
-
96
- // --- Verbose mode (--verbose sends ALL alerts including temporal/publish/maintainer) ---
97
-
98
- let verboseMode = false;
99
-
100
- function isVerboseMode() {
101
- if (verboseMode) return true;
102
- const env = process.env.MUADDIB_MONITOR_VERBOSE;
103
- return env !== undefined && env.toLowerCase() === 'true';
104
- }
105
-
106
- function setVerboseMode(value) {
107
- verboseMode = !!value;
108
- }
109
-
110
- // --- IOC match types (these are the only static-analysis types that warrant a webhook) ---
111
-
112
- const IOC_MATCH_TYPES = new Set([
113
- 'known_malicious_package',
114
- 'known_malicious_hash',
115
- 'pypi_malicious_package',
116
- 'shai_hulud_marker',
117
- 'shai_hulud_backdoor'
118
- ]);
119
-
120
- function hasIOCMatch(result) {
121
- if (!result || !result.threats) return false;
122
- return result.threats.some(t => IOC_MATCH_TYPES.has(t.type));
123
- }
124
-
125
- // --- Webhook alerting ---
126
-
127
- function getWebhookUrl() {
128
- return process.env.MUADDIB_WEBHOOK_URL || null;
129
- }
130
-
131
- function shouldSendWebhook(result, sandboxResult) {
132
- if (!getWebhookUrl()) return false;
133
-
134
- // If sandbox ran, it is the final arbiter
135
- if (sandboxResult && sandboxResult.score !== undefined) {
136
- return sandboxResult.score > 0;
137
- }
138
-
139
- // No sandbox — only send webhook for confirmed IOC matches
140
- // (known_malicious_package, known_malicious_hash, pypi_malicious_package, etc.)
141
- if (hasIOCMatch(result)) return true;
142
-
143
- return false;
144
- }
145
-
146
- function buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult) {
147
- const payload = {
148
- event: 'malicious_package',
149
- package: name,
150
- version,
151
- ecosystem,
152
- timestamp: new Date().toISOString(),
153
- findings: result.threats.map(t => ({
154
- rule: t.rule_id || t.type,
155
- severity: t.severity
156
- }))
157
- };
158
- if (sandboxResult && sandboxResult.score > 0) {
159
- payload.sandbox = {
160
- score: sandboxResult.score,
161
- severity: sandboxResult.severity
162
- };
163
- }
164
- return payload;
165
- }
166
-
167
- function computeRiskLevel(summary) {
168
- if (summary.critical > 0) return 'CRITICAL';
169
- if (summary.high > 0) return 'HIGH';
170
- if (summary.medium > 0) return 'MEDIUM';
171
- if (summary.low > 0) return 'LOW';
172
- return 'CLEAN';
173
- }
174
-
175
- function computeRiskScore(summary) {
176
- const raw = (summary.critical || 0) * 25
177
- + (summary.high || 0) * 15
178
- + (summary.medium || 0) * 5
179
- + (summary.low || 0) * 1;
180
- return Math.min(raw, 100);
181
- }
182
-
183
- async function trySendWebhook(name, version, ecosystem, result, sandboxResult) {
184
- if (!shouldSendWebhook(result, sandboxResult)) {
185
- if (sandboxResult && sandboxResult.score === 0) {
186
- console.log(`[MONITOR] FALSE POSITIVE (sandbox clean): ${name}@${version}`);
187
- }
188
- return;
189
- }
190
- const url = getWebhookUrl();
191
- const payload = buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult);
192
- const webhookData = {
193
- target: `${ecosystem}/${name}@${version}`,
194
- timestamp: payload.timestamp,
195
- ecosystem,
196
- summary: {
197
- ...result.summary,
198
- riskLevel: computeRiskLevel(result.summary),
199
- riskScore: computeRiskScore(result.summary)
200
- },
201
- threats: result.threats
202
- };
203
- if (sandboxResult && sandboxResult.score > 0) {
204
- webhookData.sandbox = {
205
- score: sandboxResult.score,
206
- severity: sandboxResult.severity
207
- };
208
- }
209
- try {
210
- await sendWebhook(url, webhookData);
211
- console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
212
- } catch (err) {
213
- console.error(`[MONITOR] Webhook failed for ${name}@${version}: ${err.message}`);
214
- }
215
- }
216
-
217
- // --- Temporal analysis integration ---
218
-
219
- function buildTemporalWebhookEmbed(temporalResult) {
220
- const findings = temporalResult.findings || [];
221
- const topFinding = findings[0] || {};
222
- const severity = topFinding.severity || 'HIGH';
223
- const color = severity === 'CRITICAL' ? 0xe74c3c : 0xe67e22;
224
- const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : '\uD83D\uDFE0';
225
-
226
- const changeLines = findings.map(f => {
227
- const action = f.type === 'lifecycle_added' ? 'ADDED' : 'MODIFIED';
228
- const value = f.type === 'lifecycle_modified' ? f.newValue : f.value;
229
- return `**${f.script}** script ${action}: \`${value}\``;
230
- }).join('\n');
231
-
232
- const pkgName = temporalResult.packageName;
233
- const npmLink = `https://www.npmjs.com/package/${pkgName}`;
234
-
235
- return {
236
- embeds: [{
237
- title: `${emoji} TEMPORAL ANOMALY \u2014 ${severity}`,
238
- color: color,
239
- fields: [
240
- { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
241
- { name: 'Version Change', value: `${temporalResult.previousVersion} \u2192 ${temporalResult.latestVersion}`, inline: true },
242
- { name: 'Severity', value: severity, inline: true },
243
- { name: 'Changes Detected', value: changeLines || 'None', inline: false },
244
- { name: 'Published', value: temporalResult.metadata.latestPublishedAt || 'unknown', inline: true },
245
- { name: 'Action', value: 'DO NOT INSTALL \u2014 Verify changelog before upgrading', inline: false }
246
- ],
247
- footer: {
248
- text: `MUAD'DIB Temporal Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
249
- },
250
- timestamp: new Date().toISOString()
251
- }]
252
- };
253
- }
254
-
255
- async function tryTemporalAlert(temporalResult) {
256
- // Temporal anomalies are logged only — no webhook unless --verbose
257
- console.log(`[MONITOR] ANOMALY (logged only): temporal lifecycle change for ${temporalResult.packageName}`);
258
- if (!isVerboseMode()) return;
259
-
260
- const url = getWebhookUrl();
261
- if (!url) return;
262
-
263
- const payload = buildTemporalWebhookEmbed(temporalResult);
264
- try {
265
- await sendWebhook(url, payload, { rawPayload: true });
266
- console.log(`[MONITOR] Temporal webhook sent for ${temporalResult.packageName} (verbose mode)`);
267
- } catch (err) {
268
- console.error(`[MONITOR] Temporal webhook failed for ${temporalResult.packageName}: ${err.message}`);
269
- }
270
- }
271
-
272
- function isTemporalAstEnabled() {
273
- const env = process.env.MUADDIB_MONITOR_TEMPORAL_AST;
274
- if (env !== undefined && env.toLowerCase() === 'false') return false;
275
- return true;
276
- }
277
-
278
- function buildTemporalAstWebhookEmbed(astResult) {
279
- const findings = astResult.findings || [];
280
- const topFinding = findings[0] || {};
281
- const severity = topFinding.severity || 'HIGH';
282
- const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
283
- const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
284
-
285
- const changeLines = findings.map(f => {
286
- return `**${f.pattern}** — ${f.severity}: ${f.description}`;
287
- }).join('\n');
288
-
289
- const pkgName = astResult.packageName;
290
- const npmLink = `https://www.npmjs.com/package/${pkgName}`;
291
-
292
- return {
293
- embeds: [{
294
- title: `${emoji} AST ANOMALY \u2014 ${severity}`,
295
- color: color,
296
- fields: [
297
- { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
298
- { name: 'Version Change', value: `${astResult.previousVersion} \u2192 ${astResult.latestVersion}`, inline: true },
299
- { name: 'Severity', value: severity, inline: true },
300
- { name: 'New Dangerous APIs', value: changeLines || 'None', inline: false },
301
- { name: 'Published', value: astResult.metadata.latestPublishedAt || 'unknown', inline: true },
302
- { name: 'Action', value: 'DO NOT UPDATE \u2014 Compare sources: npm diff pkg@old pkg@new', inline: false }
303
- ],
304
- footer: {
305
- text: `MUAD'DIB Temporal AST Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
306
- },
307
- timestamp: new Date().toISOString()
308
- }]
309
- };
310
- }
311
-
312
- async function tryTemporalAstAlert(astResult) {
313
- // AST anomalies are logged only — no webhook unless --verbose
314
- console.log(`[MONITOR] ANOMALY (logged only): AST change for ${astResult.packageName}`);
315
- if (!isVerboseMode()) return;
316
-
317
- const url = getWebhookUrl();
318
- if (!url) return;
319
-
320
- const payload = buildTemporalAstWebhookEmbed(astResult);
321
- try {
322
- await sendWebhook(url, payload, { rawPayload: true });
323
- console.log(`[MONITOR] Temporal AST webhook sent for ${astResult.packageName} (verbose mode)`);
324
- } catch (err) {
325
- console.error(`[MONITOR] Temporal AST webhook failed for ${astResult.packageName}: ${err.message}`);
326
- }
327
- }
328
-
329
- async function runTemporalAstCheck(packageName) {
330
- if (!isTemporalAstEnabled()) return null;
331
- try {
332
- const result = await detectSuddenAstChanges(packageName);
333
- if (result.suspicious) {
334
- const findingsStr = result.findings.map(f => {
335
- return `${f.pattern} (${f.severity})`;
336
- }).join(', ');
337
- console.log(`[MONITOR] AST ANOMALY: ${packageName} v${result.previousVersion} → v${result.latestVersion}: ${findingsStr}`);
338
-
339
- appendAlert({
340
- timestamp: new Date().toISOString(),
341
- name: packageName,
342
- version: result.latestVersion,
343
- ecosystem: 'npm',
344
- temporalAst: true,
345
- findings: result.findings.map(f => ({
346
- rule: f.severity === 'CRITICAL' ? 'MUADDIB-TEMPORAL-AST-001'
347
- : f.severity === 'HIGH' ? 'MUADDIB-TEMPORAL-AST-002'
348
- : 'MUADDIB-TEMPORAL-AST-003',
349
- severity: f.severity,
350
- pattern: f.pattern
351
- }))
352
- });
353
-
354
- dailyAlerts.push({
355
- name: packageName,
356
- version: result.latestVersion,
357
- ecosystem: 'npm',
358
- findingsCount: result.findings.length,
359
- temporalAst: true
360
- });
361
-
362
- // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
363
- }
364
- return result;
365
- } catch (err) {
366
- console.error(`[MONITOR] Temporal AST analysis error for ${packageName}: ${err.message}`);
367
- return null;
368
- }
369
- }
370
-
371
- function isTemporalPublishEnabled() {
372
- const env = process.env.MUADDIB_MONITOR_TEMPORAL_PUBLISH;
373
- if (env !== undefined && env.toLowerCase() === 'false') return false;
374
- return true;
375
- }
376
-
377
- function buildPublishAnomalyWebhookEmbed(publishResult) {
378
- const anomalies = publishResult.anomalies || [];
379
- const topAnomaly = anomalies[0] || {};
380
- const severity = topAnomaly.severity || 'HIGH';
381
- const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
382
- const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
383
-
384
- const anomalyLines = anomalies.map(a => {
385
- return `**${a.type}** — ${a.severity}: ${a.description}`;
386
- }).join('\n');
387
-
388
- const pkgName = publishResult.packageName;
389
- const npmLink = `https://www.npmjs.com/package/${pkgName}`;
390
-
391
- return {
392
- embeds: [{
393
- title: `${emoji} PUBLISH ANOMALY \u2014 ${severity}`,
394
- color: color,
395
- fields: [
396
- { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
397
- { name: 'Versions Analyzed', value: `${publishResult.versionCount || 'N/A'}`, inline: true },
398
- { name: 'Severity', value: severity, inline: true },
399
- { name: 'Anomalies Detected', value: anomalyLines || 'None', inline: false },
400
- { name: 'Action', value: 'Verify maintainer activity on npm/GitHub. Check changelogs for each version.', inline: false }
401
- ],
402
- footer: {
403
- text: `MUAD'DIB Publish Frequency Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
404
- },
405
- timestamp: new Date().toISOString()
406
- }]
407
- };
408
- }
409
-
410
- async function tryTemporalPublishAlert(publishResult) {
411
- // Publish anomalies are logged only — no webhook unless --verbose
412
- console.log(`[MONITOR] ANOMALY (logged only): publish frequency for ${publishResult.packageName}`);
413
- if (!isVerboseMode()) return;
414
-
415
- const url = getWebhookUrl();
416
- if (!url) return;
417
-
418
- const payload = buildPublishAnomalyWebhookEmbed(publishResult);
419
- try {
420
- await sendWebhook(url, payload, { rawPayload: true });
421
- console.log(`[MONITOR] Publish anomaly webhook sent for ${publishResult.packageName} (verbose mode)`);
422
- } catch (err) {
423
- console.error(`[MONITOR] Publish anomaly webhook failed for ${publishResult.packageName}: ${err.message}`);
424
- }
425
- }
426
-
427
- async function runTemporalPublishCheck(packageName) {
428
- if (!isTemporalPublishEnabled()) return null;
429
- try {
430
- const result = await detectPublishAnomaly(packageName);
431
- if (result.suspicious) {
432
- const anomalyStr = result.anomalies.map(a => {
433
- return `${a.type} (${a.severity})`;
434
- }).join(', ');
435
- console.log(`[MONITOR] PUBLISH ANOMALY: ${packageName}: ${anomalyStr}`);
436
-
437
- appendAlert({
438
- timestamp: new Date().toISOString(),
439
- name: packageName,
440
- version: 'N/A',
441
- ecosystem: 'npm',
442
- temporalPublish: true,
443
- findings: result.anomalies.map(a => ({
444
- rule: a.type === 'publish_burst' ? 'MUADDIB-PUBLISH-001'
445
- : a.type === 'dormant_spike' ? 'MUADDIB-PUBLISH-002'
446
- : 'MUADDIB-PUBLISH-003',
447
- severity: a.severity,
448
- type: a.type
449
- }))
450
- });
451
-
452
- dailyAlerts.push({
453
- name: packageName,
454
- version: 'N/A',
455
- ecosystem: 'npm',
456
- findingsCount: result.anomalies.length,
457
- temporalPublish: true
458
- });
459
-
460
- // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
461
- }
462
- return result;
463
- } catch (err) {
464
- console.error(`[MONITOR] Publish frequency analysis error for ${packageName}: ${err.message}`);
465
- return null;
466
- }
467
- }
468
-
469
- function isTemporalMaintainerEnabled() {
470
- const env = process.env.MUADDIB_MONITOR_TEMPORAL_MAINTAINER;
471
- if (env !== undefined && env.toLowerCase() === 'false') return false;
472
- return true;
473
- }
474
-
475
- function buildMaintainerChangeWebhookEmbed(maintainerResult) {
476
- const findings = maintainerResult.findings || [];
477
- const topFinding = findings[0] || {};
478
- const severity = topFinding.severity || 'HIGH';
479
- const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
480
- const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
481
-
482
- const findingLines = findings.map(f => {
483
- let detail = `**${f.type}** — ${f.severity}: ${f.description}`;
484
- if (f.riskAssessment && f.riskAssessment.reasons.length > 0) {
485
- detail += `\nRisk: ${f.riskAssessment.reasons.join(', ')}`;
486
- }
487
- return detail;
488
- }).join('\n');
489
-
490
- const pkgName = maintainerResult.packageName;
491
- const npmLink = `https://www.npmjs.com/package/${pkgName}`;
492
-
493
- return {
494
- embeds: [{
495
- title: `${emoji} MAINTAINER CHANGE \u2014 ${severity}`,
496
- color: color,
497
- fields: [
498
- { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
499
- { name: 'Severity', value: severity, inline: true },
500
- { name: 'Findings', value: findingLines || 'None', inline: false },
501
- { name: 'Action', value: 'Verify legitimacy before installing', inline: false }
502
- ],
503
- footer: {
504
- text: `MUAD'DIB Maintainer Change Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
505
- },
506
- timestamp: new Date().toISOString()
507
- }]
508
- };
509
- }
510
-
511
- async function tryTemporalMaintainerAlert(maintainerResult) {
512
- // Maintainer changes are logged only — no webhook unless --verbose
513
- console.log(`[MONITOR] ANOMALY (logged only): maintainer change for ${maintainerResult.packageName}`);
514
- if (!isVerboseMode()) return;
515
-
516
- const url = getWebhookUrl();
517
- if (!url) return;
518
-
519
- const payload = buildMaintainerChangeWebhookEmbed(maintainerResult);
520
- try {
521
- await sendWebhook(url, payload, { rawPayload: true });
522
- console.log(`[MONITOR] Maintainer change webhook sent for ${maintainerResult.packageName} (verbose mode)`);
523
- } catch (err) {
524
- console.error(`[MONITOR] Maintainer change webhook failed for ${maintainerResult.packageName}: ${err.message}`);
525
- }
526
- }
527
-
528
- async function runTemporalMaintainerCheck(packageName) {
529
- if (!isTemporalMaintainerEnabled()) return null;
530
- try {
531
- const result = await detectMaintainerChange(packageName);
532
- if (result.suspicious) {
533
- const findingsStr = result.findings.map(f => {
534
- return `${f.type} (${f.severity})`;
535
- }).join(', ');
536
- console.log(`[MONITOR] MAINTAINER CHANGE: ${packageName}: ${findingsStr}`);
537
-
538
- appendAlert({
539
- timestamp: new Date().toISOString(),
540
- name: packageName,
541
- version: 'N/A',
542
- ecosystem: 'npm',
543
- temporalMaintainer: true,
544
- findings: result.findings.map(f => ({
545
- rule: f.type === 'new_maintainer' ? 'MUADDIB-MAINTAINER-001'
546
- : f.type === 'suspicious_maintainer' ? 'MUADDIB-MAINTAINER-002'
547
- : f.type === 'sole_maintainer_change' ? 'MUADDIB-MAINTAINER-003'
548
- : 'MUADDIB-MAINTAINER-004',
549
- severity: f.severity,
550
- type: f.type
551
- }))
552
- });
553
-
554
- dailyAlerts.push({
555
- name: packageName,
556
- version: 'N/A',
557
- ecosystem: 'npm',
558
- findingsCount: result.findings.length,
559
- temporalMaintainer: true
560
- });
561
-
562
- // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
563
- }
564
- return result;
565
- } catch (err) {
566
- console.error(`[MONITOR] Maintainer change analysis error for ${packageName}: ${err.message}`);
567
- return null;
568
- }
569
- }
570
-
571
- async function runTemporalCheck(packageName) {
572
- if (!isTemporalEnabled()) return null;
573
- try {
574
- const result = await detectSuddenLifecycleChange(packageName);
575
- if (result.suspicious) {
576
- const findingsStr = result.findings.map(f => {
577
- const action = f.type === 'lifecycle_added' ? 'added' : 'modified';
578
- return `${f.script} ${action} (${f.severity})`;
579
- }).join(', ');
580
- console.log(`[MONITOR] TEMPORAL ANOMALY: ${packageName} v${result.previousVersion} → v${result.latestVersion}: ${findingsStr}`);
581
-
582
- appendAlert({
583
- timestamp: new Date().toISOString(),
584
- name: packageName,
585
- version: result.latestVersion,
586
- ecosystem: 'npm',
587
- temporal: true,
588
- findings: result.findings.map(f => ({
589
- rule: f.type === 'lifecycle_added' ? 'MUADDIB-TEMPORAL-001' : 'MUADDIB-TEMPORAL-003',
590
- severity: f.severity,
591
- script: f.script
592
- }))
593
- });
594
-
595
- dailyAlerts.push({
596
- name: packageName,
597
- version: result.latestVersion,
598
- ecosystem: 'npm',
599
- findingsCount: result.findings.length,
600
- temporal: true
601
- });
602
-
603
- // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
604
- }
605
- return result;
606
- } catch (err) {
607
- console.error(`[MONITOR] Temporal analysis error for ${packageName}: ${err.message}`);
608
- return null;
609
- }
610
- }
611
-
612
- // --- State persistence ---
613
-
614
- function loadState() {
615
- try {
616
- const raw = fs.readFileSync(STATE_FILE, 'utf8');
617
- const state = JSON.parse(raw);
618
- // Restore daily report date so it survives restarts (auto-update, crashes)
619
- if (typeof state.lastDailyReportDate === 'string') {
620
- stats.lastDailyReportDate = state.lastDailyReportDate;
621
- }
622
- return {
623
- npmLastPackage: typeof state.npmLastPackage === 'string' ? state.npmLastPackage : '',
624
- pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
625
- };
626
- } catch {
627
- return { npmLastPackage: '', pypiLastPackage: '' };
628
- }
629
- }
630
-
631
- function saveState(state) {
632
- try {
633
- const dir = path.dirname(STATE_FILE);
634
- if (!fs.existsSync(dir)) {
635
- fs.mkdirSync(dir, { recursive: true });
636
- }
637
- // Persist daily report date so it survives restarts
638
- const persistedState = {
639
- ...state,
640
- lastDailyReportDate: stats.lastDailyReportDate
641
- };
642
- fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8');
643
- } catch (err) {
644
- console.error(`[MONITOR] Failed to save state: ${err.message}`);
645
- }
646
- }
647
-
648
- // --- HTTP helpers ---
649
-
650
- function httpsGet(url, timeoutMs = 30_000) {
651
- return new Promise((resolve, reject) => {
652
- const req = https.get(url, { timeout: timeoutMs }, (res) => {
653
- if (res.statusCode === 301 || res.statusCode === 302) {
654
- res.resume();
655
- const location = res.headers.location;
656
- if (!location) return reject(new Error(`Redirect without Location for ${url}`));
657
- return httpsGet(location, timeoutMs).then(resolve, reject);
658
- }
659
- if (res.statusCode < 200 || res.statusCode >= 300) {
660
- res.resume();
661
- return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
662
- }
663
- const chunks = [];
664
- res.on('data', (chunk) => chunks.push(chunk));
665
- res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
666
- res.on('error', reject);
667
- });
668
- req.on('error', reject);
669
- req.on('timeout', () => {
670
- req.destroy();
671
- reject(new Error(`Timeout for ${url}`));
672
- });
673
- });
674
- }
675
-
676
- // --- Tarball URL helpers ---
677
-
678
- function getNpmTarballUrl(pkgData) {
679
- return (pkgData.dist && pkgData.dist.tarball) || null;
680
- }
681
-
682
- async function getPyPITarballUrl(packageName) {
683
- const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
684
- const body = await httpsGet(url);
685
- let data;
686
- try {
687
- data = JSON.parse(body);
688
- } catch (e) {
689
- throw new Error(`Invalid JSON from PyPI for ${packageName}: ${e.message}`);
690
- }
691
- const version = (data.info && data.info.version) || '';
692
- const urls = data.urls || [];
693
- // Prefer sdist (.tar.gz)
694
- const sdist = urls.find(u => u.packagetype === 'sdist' && u.url);
695
- if (sdist) return { url: sdist.url, version };
696
- // Fallback: any .tar.gz
697
- const tarGz = urls.find(u => u.url && u.url.endsWith('.tar.gz'));
698
- if (tarGz) return { url: tarGz.url, version };
699
- // Fallback: first available file
700
- if (urls.length > 0 && urls[0].url) return { url: urls[0].url, version };
701
- return { url: null, version };
702
- }
703
-
704
- // --- Alerts persistence ---
705
-
706
- function appendAlert(alert) {
707
- try {
708
- const dir = path.dirname(ALERTS_FILE);
709
- if (!fs.existsSync(dir)) {
710
- fs.mkdirSync(dir, { recursive: true });
711
- }
712
- let alerts = [];
713
- try {
714
- alerts = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
715
- } catch {}
716
- alerts.push(alert);
717
- fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2), 'utf8');
718
- } catch (err) {
719
- console.error(`[MONITOR] Failed to save alert: ${err.message}`);
720
- }
721
- }
722
-
723
- // --- Detection time logging ---
724
-
725
- function loadDetections() {
726
- try {
727
- const raw = fs.readFileSync(DETECTIONS_FILE, 'utf8');
728
- const data = JSON.parse(raw);
729
- if (data && Array.isArray(data.detections)) return data;
730
- return { detections: [] };
731
- } catch {
732
- return { detections: [] };
733
- }
734
- }
735
-
736
- function appendDetection(name, version, ecosystem, findings, severity) {
737
- try {
738
- const dir = path.dirname(DETECTIONS_FILE);
739
- if (!fs.existsSync(dir)) {
740
- fs.mkdirSync(dir, { recursive: true });
741
- }
742
- const data = loadDetections();
743
- const key = `${name}@${version}`;
744
- if (data.detections.some(d => `${d.package}@${d.version}` === key)) {
745
- return; // dedup
746
- }
747
- data.detections.push({
748
- package: name,
749
- version,
750
- ecosystem,
751
- first_seen_at: new Date().toISOString(),
752
- findings,
753
- severity,
754
- advisory_at: null,
755
- lead_time_hours: null
756
- });
757
- fs.writeFileSync(DETECTIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
758
- } catch (err) {
759
- console.error(`[MONITOR] Failed to save detection: ${err.message}`);
760
- }
761
- }
762
-
763
- function getDetectionStats() {
764
- const data = loadDetections();
765
- const detections = data.detections;
766
- const total = detections.length;
767
-
768
- const bySeverity = {};
769
- const byEcosystem = {};
770
- for (const d of detections) {
771
- bySeverity[d.severity] = (bySeverity[d.severity] || 0) + 1;
772
- byEcosystem[d.ecosystem] = (byEcosystem[d.ecosystem] || 0) + 1;
773
- }
774
-
775
- const withLeadTime = detections.filter(d => d.advisory_at && d.lead_time_hours != null);
776
- let leadTime = null;
777
- if (withLeadTime.length > 0) {
778
- const hours = withLeadTime.map(d => d.lead_time_hours);
779
- leadTime = {
780
- count: withLeadTime.length,
781
- avg: hours.reduce((a, b) => a + b, 0) / hours.length,
782
- min: Math.min(...hours),
783
- max: Math.max(...hours)
784
- };
785
- }
786
-
787
- return { total, bySeverity, byEcosystem, leadTime };
788
- }
789
-
790
- // --- Scan stats (FP rate tracking) ---
791
-
792
- function loadScanStats() {
793
- try {
794
- const raw = fs.readFileSync(SCAN_STATS_FILE, 'utf8');
795
- const data = JSON.parse(raw);
796
- if (data && data.stats && Array.isArray(data.daily)) return data;
797
- return { stats: { total_scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed_malicious: 0 }, daily: [] };
798
- } catch {
799
- return { stats: { total_scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed_malicious: 0 }, daily: [] };
800
- }
801
- }
802
-
803
- function updateScanStats(result) {
804
- const data = loadScanStats();
805
- data.stats.total_scanned++;
806
-
807
- if (result === 'clean') data.stats.clean++;
808
- else if (result === 'suspect') data.stats.suspect++;
809
- else if (result === 'false_positive') data.stats.false_positive++;
810
- else if (result === 'confirmed') data.stats.confirmed_malicious++;
811
-
812
- const today = new Date().toISOString().slice(0, 10);
813
- let dayEntry = data.daily.find(d => d.date === today);
814
- if (!dayEntry) {
815
- dayEntry = { date: today, scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed: 0, fp_rate: 0 };
816
- data.daily.push(dayEntry);
817
- }
818
- dayEntry.scanned++;
819
-
820
- if (result === 'clean') dayEntry.clean++;
821
- else if (result === 'suspect') dayEntry.suspect++;
822
- else if (result === 'false_positive') dayEntry.false_positive++;
823
- else if (result === 'confirmed') dayEntry.confirmed++;
824
-
825
- const denom = dayEntry.false_positive + dayEntry.confirmed;
826
- dayEntry.fp_rate = denom > 0 ? dayEntry.false_positive / denom : 0;
827
-
828
- try {
829
- const dir = path.dirname(SCAN_STATS_FILE);
830
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
831
- fs.writeFileSync(SCAN_STATS_FILE, JSON.stringify(data, null, 2), 'utf8');
832
- } catch (err) {
833
- console.error(`[MONITOR] Failed to save scan stats: ${err.message}`);
834
- }
835
- }
836
-
837
- // --- Bundled tooling false-positive filter ---
838
-
839
- const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
840
- const KNOWN_BUNDLED_PATHS = ['_next/static/chunks/', '.next/static/chunks/'];
841
-
842
- function isBundledToolingOnly(threats) {
843
- if (threats.length === 0) return false;
844
- return threats.every(t => {
845
- if (!t.file) return false;
846
- const basename = path.basename(t.file);
847
- if (KNOWN_BUNDLED_FILES.includes(basename)) return true;
848
- const normalized = t.file.replace(/\\/g, '/');
849
- return KNOWN_BUNDLED_PATHS.some(p => normalized.includes(p));
850
- });
851
- }
852
-
853
- // --- Package scanning ---
854
-
855
- async function scanPackage(name, version, ecosystem, tarballUrl) {
856
- const startTime = Date.now();
857
- const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
858
- if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
859
- const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${sanitizePackageName(name)}-`));
860
-
861
- try {
862
- const tgzPath = path.join(tmpDir, 'package.tar.gz');
863
- await downloadToFile(tarballUrl, tgzPath);
864
-
865
- // Check downloaded size
866
- const fileSize = fs.statSync(tgzPath).size;
867
- if (fileSize > MAX_TARBALL_SIZE) {
868
- console.log(`[MONITOR] SKIP: ${name}@${version} — tarball too large (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
869
- stats.scanned++;
870
- return;
871
- }
872
-
873
- const extractedDir = extractTarGz(tgzPath, tmpDir);
874
- const result = await run(extractedDir, { _capture: true });
875
-
876
- if (result.summary.total === 0) {
877
- stats.scanned++;
878
- const elapsed = Date.now() - startTime;
879
- stats.totalTimeMs += elapsed;
880
- stats.clean++;
881
- console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
882
- updateScanStats('clean');
883
- return { sandboxResult: null, staticClean: true };
884
- } else {
885
- const counts = [];
886
- if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
887
- if (result.summary.high > 0) counts.push(`${result.summary.high} HIGH`);
888
- if (result.summary.medium > 0) counts.push(`${result.summary.medium} MEDIUM`);
889
- if (result.summary.low > 0) counts.push(`${result.summary.low} LOW`);
890
-
891
- // Check if all findings come from bundled tooling files
892
- if (isBundledToolingOnly(result.threats)) {
893
- stats.scanned++;
894
- const elapsed = Date.now() - startTime;
895
- stats.totalTimeMs += elapsed;
896
- stats.clean++;
897
- console.log(`[MONITOR] SKIPPED (bundled tooling): ${name}@${version} (${counts.join(', ')})`);
898
-
899
- const alert = {
900
- timestamp: new Date().toISOString(),
901
- name,
902
- version,
903
- ecosystem,
904
- skipped: true,
905
- findings: result.threats.map(t => ({
906
- rule: t.rule_id || t.type,
907
- severity: t.severity,
908
- file: t.file
909
- }))
910
- };
911
- appendAlert(alert);
912
- updateScanStats('clean');
913
- return { sandboxResult: null, staticClean: true };
914
- } else {
915
- stats.suspect++;
916
- console.log(`[MONITOR] SUSPECT: ${name}@${version} (${counts.join(', ')})`);
917
-
918
- // Sandbox: run dynamic analysis on HIGH/CRITICAL findings
919
- let sandboxResult = null;
920
- if (hasHighOrCritical(result) && isSandboxEnabled() && sandboxAvailable) {
921
- try {
922
- const canary = isCanaryEnabled();
923
- console.log(`[MONITOR] SANDBOX: launching for ${name}@${version}${canary ? ' (canary: on)' : ''}...`);
924
- sandboxResult = await runSandbox(name, { canary });
925
- console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
926
-
927
- // Check for canary exfiltration findings and send dedicated alert
928
- const canaryFindings = (sandboxResult.findings || []).filter(f => f.type === 'canary_exfiltration');
929
- if (canaryFindings.length > 0) {
930
- console.log(`[MONITOR] CANARY EXFILTRATION: ${name}@${version} ${canaryFindings.length} token(s) stolen!`);
931
- const url = getWebhookUrl();
932
- if (url) {
933
- const exfiltrations = canaryFindings.map(f => ({
934
- token: f.detail.match(/exfiltrate (\S+)/)?.[1] || 'UNKNOWN',
935
- foundIn: f.detail
936
- }));
937
- const payload = buildCanaryExfiltrationWebhookEmbed(name, version, exfiltrations);
938
- try {
939
- await sendWebhook(url, payload, { rawPayload: true });
940
- console.log(`[MONITOR] Canary exfiltration webhook sent for ${name}@${version}`);
941
- } catch (webhookErr) {
942
- console.error(`[MONITOR] Canary webhook failed for ${name}@${version}: ${webhookErr.message}`);
943
- }
944
- }
945
- }
946
- } catch (err) {
947
- console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
948
- }
949
- }
950
-
951
- stats.scanned++;
952
- const elapsed = Date.now() - startTime;
953
- stats.totalTimeMs += elapsed;
954
- console.log(`[MONITOR] ${name}@${version} total time: ${(elapsed / 1000).toFixed(1)}s`);
955
-
956
- const alert = {
957
- timestamp: new Date().toISOString(),
958
- name,
959
- version,
960
- ecosystem,
961
- findings: result.threats.map(t => ({
962
- rule: t.rule_id || t.type,
963
- severity: t.severity,
964
- file: t.file
965
- }))
966
- };
967
-
968
- if (sandboxResult && sandboxResult.score > 0) {
969
- alert.sandbox = {
970
- score: sandboxResult.score,
971
- severity: sandboxResult.severity,
972
- findings: sandboxResult.findings
973
- };
974
- }
975
-
976
- appendAlert(alert);
977
-
978
- const findingTypes = [...new Set(result.threats.map(t => t.type))];
979
- const maxSeverity = result.summary.critical > 0 ? 'CRITICAL'
980
- : result.summary.high > 0 ? 'HIGH'
981
- : result.summary.medium > 0 ? 'MEDIUM' : 'LOW';
982
- appendDetection(name, version, ecosystem, findingTypes, maxSeverity);
983
-
984
- dailyAlerts.push({ name, version, ecosystem, findingsCount: result.summary.total });
985
- await trySendWebhook(name, version, ecosystem, result, sandboxResult);
986
- return { sandboxResult, staticClean: false };
987
- }
988
- }
989
- } catch (err) {
990
- stats.errors++;
991
- stats.scanned++;
992
- stats.totalTimeMs += Date.now() - startTime;
993
- console.error(`[MONITOR] ERROR scanning ${name}@${version}: ${err.message}`);
994
- return { sandboxResult: null, staticClean: false };
995
- } finally {
996
- // Cleanup temp dir
997
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
998
- }
999
- }
1000
-
1001
- function timeoutPromise(ms) {
1002
- return new Promise((_, reject) => {
1003
- setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
1004
- });
1005
- }
1006
-
1007
- async function processQueue() {
1008
- while (scanQueue.length > 0) {
1009
- const item = scanQueue.shift();
1010
- try {
1011
- await Promise.race([
1012
- resolveTarballAndScan(item),
1013
- timeoutPromise(SCAN_TIMEOUT_MS)
1014
- ]);
1015
- } catch (err) {
1016
- stats.errors++;
1017
- console.error(`[MONITOR] Queue error for ${item.name}: ${err.message}`);
1018
- }
1019
- }
1020
- }
1021
-
1022
- // --- Stats reporting ---
1023
-
1024
- function reportStats() {
1025
- const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
1026
- console.log(`[MONITOR] Stats: ${stats.scanned} scanned, ${stats.clean} clean, ${stats.suspect} suspect, ${stats.errors} error${stats.errors !== 1 ? 's' : ''}, avg ${avg}s/pkg`);
1027
- stats.lastReportTime = Date.now();
1028
- }
1029
-
1030
- const DAILY_REPORT_HOUR = 8; // 08:00 Paris time (Europe/Paris)
1031
-
1032
- /**
1033
- * Returns the current hour in Europe/Paris timezone (0-23).
1034
- */
1035
- function getParisHour() {
1036
- const formatter = new Intl.DateTimeFormat('en-GB', {
1037
- timeZone: 'Europe/Paris',
1038
- hour: 'numeric',
1039
- hour12: false
1040
- });
1041
- return parseInt(formatter.format(new Date()), 10);
1042
- }
1043
-
1044
- /**
1045
- * Returns today's date string in Europe/Paris timezone (YYYY-MM-DD).
1046
- */
1047
- function getParisDateString() {
1048
- const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' });
1049
- return formatter.format(new Date());
1050
- }
1051
-
1052
- /**
1053
- * Check if the daily report is due: Paris hour matches DAILY_REPORT_HOUR
1054
- * and we haven't already sent one today.
1055
- */
1056
- function isDailyReportDue() {
1057
- const parisHour = getParisHour();
1058
- if (parisHour !== DAILY_REPORT_HOUR) return false;
1059
- const today = getParisDateString();
1060
- return stats.lastDailyReportDate !== today;
1061
- }
1062
-
1063
- function buildDailyReportEmbed() {
1064
- // Use disk-based daily entries filtered by lastDailyReportDate for accurate delta
1065
- const { agg, top3: diskTop3 } = buildReportFromDisk();
1066
-
1067
- // Prefer in-memory dailyAlerts for top suspects (richer data), fallback to disk
1068
- const top3 = dailyAlerts.length > 0
1069
- ? dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 3)
1070
- : diskTop3;
1071
-
1072
- const top3Text = top3.length > 0
1073
- ? top3.map((a, i) => {
1074
- const name = a.ecosystem ? `${a.ecosystem}/${a.name || a.package}` : (a.name || a.package);
1075
- const version = a.version || 'N/A';
1076
- const count = a.findingsCount || (a.findings ? a.findings.length : 0);
1077
- return `${i + 1}. **${name}@${version}** ${count} finding(s)`;
1078
- }).join('\n')
1079
- : 'None';
1080
-
1081
- // Avg scan time from in-memory stats (not available on disk)
1082
- const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
1083
-
1084
- const now = new Date();
1085
- const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
1086
-
1087
- return {
1088
- embeds: [{
1089
- title: '\uD83D\uDCCA MUAD\'DIB Daily Report',
1090
- color: 0x3498db,
1091
- fields: [
1092
- { name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
1093
- { name: 'Clean', value: `${agg.clean}`, inline: true },
1094
- { name: 'Suspects', value: `${agg.suspect}`, inline: true },
1095
- { name: 'Errors', value: `${stats.errors}`, inline: true },
1096
- { name: 'Avg Scan Time', value: `${avg}s/pkg`, inline: true },
1097
- { name: 'Top Suspects', value: top3Text, inline: false }
1098
- ],
1099
- footer: {
1100
- text: `MUAD'DIB - Daily summary | ${readableTime}`
1101
- },
1102
- timestamp: now.toISOString()
1103
- }]
1104
- };
1105
- }
1106
-
1107
- async function sendDailyReport() {
1108
- const url = getWebhookUrl();
1109
- if (!url) return;
1110
-
1111
- const payload = buildDailyReportEmbed();
1112
- try {
1113
- await sendWebhook(url, payload, { rawPayload: true });
1114
- console.log('[MONITOR] Daily report sent');
1115
- } catch (err) {
1116
- console.error(`[MONITOR] Daily report webhook failed: ${err.message}`);
1117
- }
1118
-
1119
- // Reset daily counters
1120
- stats.scanned = 0;
1121
- stats.clean = 0;
1122
- stats.suspect = 0;
1123
- stats.errors = 0;
1124
- stats.totalTimeMs = 0;
1125
- dailyAlerts.length = 0;
1126
- recentlyScanned.clear();
1127
- stats.lastDailyReportDate = getParisDateString();
1128
- }
1129
-
1130
- // --- CLI report helpers (muaddib report --now / --status) ---
1131
-
1132
- /**
1133
- * Read raw state file (without restoring into stats).
1134
- */
1135
- function loadStateRaw() {
1136
- try {
1137
- const raw = fs.readFileSync(STATE_FILE, 'utf8');
1138
- return JSON.parse(raw);
1139
- } catch {
1140
- return {};
1141
- }
1142
- }
1143
-
1144
- /**
1145
- * Reconstruct daily report data from persisted files (no in-memory stats needed).
1146
- * Used by `muaddib report --now` to send a report from a separate CLI process.
1147
- */
1148
- function buildReportFromDisk() {
1149
- const scanData = loadScanStats();
1150
- const stateRaw = loadStateRaw();
1151
- const lastDate = stateRaw.lastDailyReportDate || null;
1152
-
1153
- // If no report ever sent (null), include ALL daily entries (first report = full history).
1154
- // After first send, lastDailyReportDate is set and subsequent reports show delta only.
1155
- const sinceDays = lastDate
1156
- ? scanData.daily.filter(d => d.date > lastDate)
1157
- : scanData.daily;
1158
-
1159
- // Aggregate counters
1160
- const agg = { scanned: 0, clean: 0, suspect: 0 };
1161
- for (const d of sinceDays) {
1162
- agg.scanned += d.scanned || 0;
1163
- agg.clean += d.clean || 0;
1164
- agg.suspect += d.suspect || 0;
1165
- }
1166
-
1167
- // Load detections since last report for top suspects
1168
- const detections = loadDetections();
1169
- const recentDetections = lastDate
1170
- ? detections.detections.filter(d => d.first_seen_at && d.first_seen_at.slice(0, 10) > lastDate)
1171
- : detections.detections;
1172
-
1173
- const top3 = recentDetections
1174
- .slice()
1175
- .sort((a, b) => (b.findings ? b.findings.length : 0) - (a.findings ? a.findings.length : 0))
1176
- .slice(0, 3);
1177
-
1178
- return { agg, top3, hasData: agg.scanned > 0 };
1179
- }
1180
-
1181
- /**
1182
- * Build a Discord embed from disk data (same format as buildDailyReportEmbed).
1183
- */
1184
- function buildReportEmbedFromDisk() {
1185
- const { agg, top3, hasData } = buildReportFromDisk();
1186
- if (!hasData) return null;
1187
-
1188
- const top3Text = top3.length > 0
1189
- ? top3.map((a, i) => `${i + 1}. **${a.ecosystem}/${a.package}@${a.version}** — ${a.findings ? a.findings.length : 0} finding(s)`).join('\n')
1190
- : 'None';
1191
-
1192
- const now = new Date();
1193
- const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
1194
-
1195
- return {
1196
- embeds: [{
1197
- title: '\uD83D\uDCCA MUAD\'DIB Daily Report (manual)',
1198
- color: 0x3498db,
1199
- fields: [
1200
- { name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
1201
- { name: 'Clean', value: `${agg.clean}`, inline: true },
1202
- { name: 'Suspects', value: `${agg.suspect}`, inline: true },
1203
- { name: 'Top Suspects', value: top3Text, inline: false }
1204
- ],
1205
- footer: {
1206
- text: `MUAD'DIB - Manual report | ${readableTime}`
1207
- },
1208
- timestamp: now.toISOString()
1209
- }]
1210
- };
1211
- }
1212
-
1213
- /**
1214
- * Force send a daily report from persisted data.
1215
- * Returns { sent: boolean, message: string }.
1216
- */
1217
- async function sendReportNow() {
1218
- const url = getWebhookUrl();
1219
- if (!url) {
1220
- return { sent: false, message: 'MUADDIB_WEBHOOK_URL not configured' };
1221
- }
1222
-
1223
- const payload = buildReportEmbedFromDisk();
1224
- if (!payload) {
1225
- return { sent: false, message: 'No data to report' };
1226
- }
1227
-
1228
- try {
1229
- await sendWebhook(url, payload, { rawPayload: true });
1230
- } catch (err) {
1231
- return { sent: false, message: `Webhook failed: ${err.message}` };
1232
- }
1233
-
1234
- // Update lastDailyReportDate on disk
1235
- const stateRaw = loadStateRaw();
1236
- const state = {
1237
- npmLastPackage: stateRaw.npmLastPackage || '',
1238
- pypiLastPackage: stateRaw.pypiLastPackage || ''
1239
- };
1240
- stats.lastDailyReportDate = getParisDateString();
1241
- saveState(state);
1242
-
1243
- return { sent: true, message: 'Daily report sent' };
1244
- }
1245
-
1246
- /**
1247
- * Get report status for `muaddib report --status`.
1248
- */
1249
- function getReportStatus() {
1250
- const stateRaw = loadStateRaw();
1251
- const lastDate = stateRaw.lastDailyReportDate || null;
1252
-
1253
- // Count packages scanned since last report (all history if never sent)
1254
- const scanData = loadScanStats();
1255
- const sinceDays = lastDate
1256
- ? scanData.daily.filter(d => d.date > lastDate)
1257
- : scanData.daily;
1258
-
1259
- let scannedSince = 0;
1260
- for (const d of sinceDays) {
1261
- scannedSince += d.scanned || 0;
1262
- }
1263
-
1264
- // Compute next report time
1265
- const today = getParisDateString();
1266
- const parisHour = getParisHour();
1267
- let nextReport;
1268
- if (lastDate === today || (lastDate !== today && parisHour >= DAILY_REPORT_HOUR)) {
1269
- // Already sent today OR past 08:00 but not sent (will fire soon if monitor runs)
1270
- if (lastDate === today) {
1271
- nextReport = 'Tomorrow 08:00 (Europe/Paris)';
1272
- } else {
1273
- nextReport = 'Today 08:00 (Europe/Paris) — pending, monitor must be running';
1274
- }
1275
- } else {
1276
- nextReport = 'Today 08:00 (Europe/Paris)';
1277
- }
1278
-
1279
- return { lastDailyReportDate: lastDate, scannedSince, nextReport };
1280
- }
1281
-
1282
- // --- npm polling ---
1283
-
1284
- /**
1285
- * Parse npm RSS XML (same regex approach as parsePyPIRss).
1286
- * Returns array of package names from <title> tags inside <item>.
1287
- */
1288
- function parseNpmRss(xml) {
1289
- const packages = [];
1290
- const itemRegex = /<item>([\s\S]*?)<\/item>/g;
1291
- let match;
1292
- while ((match = itemRegex.exec(xml)) !== null) {
1293
- const itemContent = match[1];
1294
- const titleMatch = itemContent.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
1295
- if (titleMatch) {
1296
- const title = titleMatch[1].trim();
1297
- const name = title.split(/\s+/)[0];
1298
- if (name) {
1299
- packages.push(name);
1300
- }
1301
- }
1302
- }
1303
- return packages;
1304
- }
1305
-
1306
- /**
1307
- * Fetch latest version metadata for an npm package.
1308
- * Returns { version, tarball } or null on failure.
1309
- */
1310
- async function getNpmLatestTarball(packageName) {
1311
- const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
1312
- const body = await httpsGet(url);
1313
- let data;
1314
- try {
1315
- data = JSON.parse(body);
1316
- } catch (e) {
1317
- throw new Error(`Invalid JSON from npm registry for ${packageName}: ${e.message}`);
1318
- }
1319
- const version = data.version || '';
1320
- const tarball = (data.dist && data.dist.tarball) || null;
1321
- return { version, tarball };
1322
- }
1323
-
1324
- async function pollNpm(state) {
1325
- const url = 'https://registry.npmjs.org/-/rss?descending=true&limit=50';
1326
-
1327
- try {
1328
- const body = await httpsGet(url);
1329
- const packages = parseNpmRss(body);
1330
-
1331
- // Find new packages (those after the last seen one)
1332
- let newPackages;
1333
- if (!state.npmLastPackage) {
1334
- newPackages = packages;
1335
- } else {
1336
- const lastIdx = packages.indexOf(state.npmLastPackage);
1337
- if (lastIdx === -1) {
1338
- newPackages = packages;
1339
- } else {
1340
- newPackages = packages.slice(0, lastIdx);
1341
- }
1342
- }
1343
-
1344
- for (const name of newPackages) {
1345
- console.log(`[MONITOR] New npm: ${name}`);
1346
- // Queue npm packages — tarball URL resolved during scan
1347
- scanQueue.push({
1348
- name,
1349
- version: '',
1350
- ecosystem: 'npm',
1351
- tarballUrl: null // resolved lazily via resolveTarballAndScan
1352
- });
1353
- }
1354
-
1355
- // Remember the most recent package (first in RSS)
1356
- if (packages.length > 0) {
1357
- state.npmLastPackage = packages[0];
1358
- }
1359
-
1360
- return newPackages.length;
1361
- } catch (err) {
1362
- console.error(`[MONITOR] npm poll error: ${err.message}`);
1363
- return 0;
1364
- }
1365
- }
1366
-
1367
- // --- PyPI polling ---
1368
-
1369
- /**
1370
- * Parse PyPI RSS XML (simple regex, no deps).
1371
- * Returns array of package names from <title> tags inside <item>.
1372
- */
1373
- function parsePyPIRss(xml) {
1374
- const packages = [];
1375
- // Match each <item>...</item> block
1376
- const itemRegex = /<item>([\s\S]*?)<\/item>/g;
1377
- let match;
1378
- while ((match = itemRegex.exec(xml)) !== null) {
1379
- const itemContent = match[1];
1380
- // Extract <title>...</title> inside item (handles CDATA)
1381
- const titleMatch = itemContent.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
1382
- if (titleMatch) {
1383
- // Title format is usually "package-name 1.0.0"
1384
- const title = titleMatch[1].trim();
1385
- // Extract just the package name (first word before space or version)
1386
- const name = title.split(/\s+/)[0];
1387
- if (name) {
1388
- packages.push(name);
1389
- }
1390
- }
1391
- }
1392
- return packages;
1393
- }
1394
-
1395
- async function pollPyPI(state) {
1396
- const url = 'https://pypi.org/rss/packages.xml';
1397
-
1398
- try {
1399
- const body = await httpsGet(url);
1400
- const packages = parsePyPIRss(body);
1401
-
1402
- // Find new packages (those after the last seen one)
1403
- let newPackages;
1404
- if (!state.pypiLastPackage) {
1405
- // First run: log all and remember the first one
1406
- newPackages = packages;
1407
- } else {
1408
- const lastIdx = packages.indexOf(state.pypiLastPackage);
1409
- if (lastIdx === -1) {
1410
- // Last seen not in feed — all are new
1411
- newPackages = packages;
1412
- } else {
1413
- // Items before lastIdx are newer (RSS is newest-first)
1414
- newPackages = packages.slice(0, lastIdx);
1415
- }
1416
- }
1417
-
1418
- for (const name of newPackages) {
1419
- console.log(`[MONITOR] New pypi: ${name}`);
1420
- // Queue PyPI packages — tarball URL resolved during scan
1421
- scanQueue.push({
1422
- name,
1423
- version: '',
1424
- ecosystem: 'pypi',
1425
- tarballUrl: null // resolved lazily in scanPackage wrapper
1426
- });
1427
- }
1428
-
1429
- // Remember the most recent package (first in RSS)
1430
- if (packages.length > 0) {
1431
- state.pypiLastPackage = packages[0];
1432
- }
1433
-
1434
- return newPackages.length;
1435
- } catch (err) {
1436
- console.error(`[MONITOR] PyPI poll error: ${err.message}`);
1437
- return 0;
1438
- }
1439
- }
1440
-
1441
- // --- Main loop ---
1442
-
1443
- async function startMonitor(options) {
1444
- if (options && options.verbose) {
1445
- setVerboseMode(true);
1446
- }
1447
-
1448
- console.log(`
1449
- ╔════════════════════════════════════════════╗
1450
- ║ MUAD'DIB - Registry Monitor ║
1451
- ║ Scanning npm + PyPI new packages ║
1452
- ╚════════════════════════════════════════════╝
1453
- `);
1454
-
1455
- // Check sandbox availability
1456
- if (isSandboxEnabled()) {
1457
- sandboxAvailable = isDockerAvailable();
1458
- if (sandboxAvailable) {
1459
- console.log('[MONITOR] Docker detected — sandbox enabled for HIGH/CRITICAL findings');
1460
- } else {
1461
- console.log('[MONITOR] WARNING: Docker not available — running static analysis only');
1462
- }
1463
- } else {
1464
- console.log('[MONITOR] Sandbox disabled (MUADDIB_MONITOR_SANDBOX=false)');
1465
- }
1466
-
1467
- // Canary tokens status
1468
- if (isCanaryEnabled()) {
1469
- console.log('[MONITOR] Canary tokens enabled — honey tokens injected in sandbox for exfiltration detection');
1470
- } else {
1471
- console.log('[MONITOR] Canary tokens disabled (MUADDIB_MONITOR_CANARY=false)');
1472
- }
1473
-
1474
- // Temporal analysis status
1475
- if (isTemporalEnabled()) {
1476
- console.log('[MONITOR] Temporal lifecycle analysis enabled — detecting sudden lifecycle script changes');
1477
- } else {
1478
- console.log('[MONITOR] Temporal lifecycle analysis disabled (MUADDIB_MONITOR_TEMPORAL=false)');
1479
- }
1480
-
1481
- if (isTemporalAstEnabled()) {
1482
- console.log('[MONITOR] Temporal AST analysis enabled — detecting sudden dangerous API additions');
1483
- } else {
1484
- console.log('[MONITOR] Temporal AST analysis disabled (MUADDIB_MONITOR_TEMPORAL_AST=false)');
1485
- }
1486
-
1487
- if (isTemporalPublishEnabled()) {
1488
- console.log('[MONITOR] Publish frequency analysis enabled — detecting publish bursts, dormant spikes');
1489
- } else {
1490
- console.log('[MONITOR] Publish frequency analysis disabled (MUADDIB_MONITOR_TEMPORAL_PUBLISH=false)');
1491
- }
1492
-
1493
- if (isTemporalMaintainerEnabled()) {
1494
- console.log('[MONITOR] Maintainer change analysis enableddetecting maintainer changes, account takeovers');
1495
- } else {
1496
- console.log('[MONITOR] Maintainer change analysis disabled (MUADDIB_MONITOR_TEMPORAL_MAINTAINER=false)');
1497
- }
1498
-
1499
- // Webhook filtering mode
1500
- if (isVerboseMode()) {
1501
- console.log('[MONITOR] Verbose mode ON — ALL anomalies sent as webhooks (temporal, publish, maintainer, AST)');
1502
- } else {
1503
- console.log('[MONITOR] Strict webhook mode — only IOC matches, sandbox confirmations, and canary exfiltrations trigger webhooks');
1504
- console.log('[MONITOR] Temporal/publish/maintainer anomalies are logged but NOT sent as webhooks');
1505
- console.log('[MONITOR] Use --verbose to send all anomalies as webhooks');
1506
- }
1507
-
1508
- const state = loadState();
1509
- console.log(`[MONITOR] State loaded npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}`);
1510
- console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
1511
-
1512
- let running = true;
1513
-
1514
- // SIGINT: send pending daily report, save state and exit
1515
- process.on('SIGINT', async () => {
1516
- console.log('\n[MONITOR] Stopping — sending pending daily report...');
1517
- if (stats.scanned > 0) {
1518
- await sendDailyReport();
1519
- }
1520
- saveState(state);
1521
- reportStats();
1522
- console.log('[MONITOR] State saved. Bye!');
1523
- running = false;
1524
- process.exit(0);
1525
- });
1526
-
1527
- // Initial poll + scan
1528
- await poll(state);
1529
- saveState(state);
1530
- await processQueue();
1531
-
1532
- // Interval polling
1533
- while (running) {
1534
- await sleep(POLL_INTERVAL);
1535
- if (!running) break;
1536
- await poll(state);
1537
- saveState(state);
1538
- await processQueue();
1539
-
1540
- // Hourly stats report
1541
- if (Date.now() - stats.lastReportTime >= 3600_000) {
1542
- reportStats();
1543
- }
1544
-
1545
- // Daily webhook report at 08:00 Paris time
1546
- if (isDailyReportDue()) {
1547
- await sendDailyReport();
1548
- }
1549
- }
1550
- }
1551
-
1552
- async function poll(state) {
1553
- const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
1554
- console.log(`[MONITOR] ${timestamp} — polling registries...`);
1555
-
1556
- const [npmCount, pypiCount] = await Promise.all([
1557
- pollNpm(state),
1558
- pollPyPI(state)
1559
- ]);
1560
-
1561
- console.log(`[MONITOR] Found ${npmCount} npm + ${pypiCount} PyPI new packages`);
1562
- }
1563
-
1564
- /**
1565
- * Returns true if publish_anomaly is the ONLY suspicious temporal result.
1566
- * publish_anomaly alone is too noisy for webhooks — only alert when combined
1567
- * with another anomaly (lifecycle, AST, maintainer).
1568
- */
1569
- function isPublishAnomalyOnly(temporalResult, astResult, publishResult, maintainerResult) {
1570
- const hasLifecycle = temporalResult && temporalResult.suspicious;
1571
- const hasAst = astResult && astResult.suspicious;
1572
- const hasPublish = publishResult && publishResult.suspicious;
1573
- const hasMaintainer = maintainerResult && maintainerResult.suspicious;
1574
-
1575
- return !!(hasPublish && !hasLifecycle && !hasAst && !hasMaintainer);
1576
- }
1577
-
1578
- /**
1579
- * Wrapper to resolve PyPI tarball URLs before scanning.
1580
- * For npm packages, tarballUrl is already set from the registry response.
1581
- * For PyPI packages, we need to fetch the JSON API to get the tarball URL.
1582
- */
1583
- async function resolveTarballAndScan(item) {
1584
- if (item.ecosystem === 'npm' && !item.tarballUrl) {
1585
- try {
1586
- const npmInfo = await getNpmLatestTarball(item.name);
1587
- if (!npmInfo.tarball) {
1588
- console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on npm`);
1589
- return;
1590
- }
1591
- item.tarballUrl = npmInfo.tarball;
1592
- if (npmInfo.version) item.version = npmInfo.version;
1593
- } catch (err) {
1594
- console.error(`[MONITOR] ERROR resolving npm tarball for ${item.name}: ${err.message}`);
1595
- stats.errors++;
1596
- return;
1597
- }
1598
- }
1599
- if (item.ecosystem === 'pypi' && !item.tarballUrl) {
1600
- try {
1601
- const pypiInfo = await getPyPITarballUrl(item.name);
1602
- if (!pypiInfo.url) {
1603
- console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on PyPI`);
1604
- return;
1605
- }
1606
- item.tarballUrl = pypiInfo.url;
1607
- if (pypiInfo.version) item.version = pypiInfo.version;
1608
- } catch (err) {
1609
- console.error(`[MONITOR] ERROR resolving PyPI tarball for ${item.name}: ${err.message}`);
1610
- stats.errors++;
1611
- return;
1612
- }
1613
- }
1614
- // Deduplication: skip if already scanned in the last 24h
1615
- const dedupeKey = `${item.ecosystem}/${item.name}@${item.version}`;
1616
- if (recentlyScanned.has(dedupeKey)) {
1617
- console.log(`[MONITOR] SKIP (already scanned): ${item.name}@${item.version}`);
1618
- return;
1619
- }
1620
- recentlyScanned.add(dedupeKey);
1621
-
1622
- // Temporal analysis: check for sudden lifecycle script changes (npm only)
1623
- // Webhooks are deferred until after sandbox confirms the threat
1624
- let temporalResult = null;
1625
- let astResult = null;
1626
- let publishResult = null;
1627
- let maintainerResult = null;
1628
-
1629
- if (item.ecosystem === 'npm') {
1630
- temporalResult = await runTemporalCheck(item.name);
1631
- astResult = await runTemporalAstCheck(item.name);
1632
- publishResult = await runTemporalPublishCheck(item.name);
1633
- maintainerResult = await runTemporalMaintainerCheck(item.name);
1634
- }
1635
-
1636
- const scanResult = await scanPackage(item.name, item.version, item.ecosystem, item.tarballUrl);
1637
- const sandboxResult = scanResult && scanResult.sandboxResult;
1638
- const staticClean = scanResult && scanResult.staticClean;
1639
-
1640
- // FP rate tracking
1641
- if (scanResult) {
1642
- if (!staticClean) {
1643
- if (sandboxResult && sandboxResult.score === 0) {
1644
- updateScanStats('false_positive');
1645
- } else if (sandboxResult && sandboxResult.score > 0) {
1646
- updateScanStats('confirmed');
1647
- } else {
1648
- updateScanStats('suspect');
1649
- }
1650
- }
1651
- }
1652
-
1653
- // Send temporal webhooks only if the package is confirmed suspicious
1654
- const hasSuspiciousTemporal = (temporalResult && temporalResult.suspicious)
1655
- || (astResult && astResult.suspicious)
1656
- || (publishResult && publishResult.suspicious)
1657
- || (maintainerResult && maintainerResult.suspicious);
1658
-
1659
- if (hasSuspiciousTemporal) {
1660
- // Sandbox ran and package is CLEAN → suppress temporal webhooks
1661
- if (sandboxResult && sandboxResult.score === 0) {
1662
- console.log(`[MONITOR] FALSE POSITIVE (sandbox clean, no alert): ${item.name}@${item.version}`);
1663
- // Static scan is CLEAN (0 findings) and no sandbox ran → suppress temporal webhooks
1664
- } else if (staticClean && !sandboxResult) {
1665
- console.log(`[MONITOR] FALSE POSITIVE (static clean, no alert): ${item.name}@${item.version}`);
1666
- // publish_anomaly alone → no webhook (too noisy, not actionable alone)
1667
- } else if (isPublishAnomalyOnly(temporalResult, astResult, publishResult, maintainerResult)) {
1668
- console.log(`[MONITOR] PUBLISH ANOMALY (alone, no alert): ${item.name}@${item.version}`);
1669
- } else {
1670
- // Sandbox confirmed threat (score > 0) OR static scan found threats → send webhooks
1671
- if (temporalResult && temporalResult.suspicious) await tryTemporalAlert(temporalResult);
1672
- if (astResult && astResult.suspicious) await tryTemporalAstAlert(astResult);
1673
- if (publishResult && publishResult.suspicious) await tryTemporalPublishAlert(publishResult);
1674
- if (maintainerResult && maintainerResult.suspicious) await tryTemporalMaintainerAlert(maintainerResult);
1675
- }
1676
- }
1677
- }
1678
-
1679
- function sleep(ms) {
1680
- return new Promise((resolve) => setTimeout(resolve, ms));
1681
- }
1682
-
1683
- module.exports = {
1684
- startMonitor,
1685
- parseNpmRss,
1686
- parsePyPIRss,
1687
- loadState,
1688
- saveState,
1689
- STATE_FILE,
1690
- ALERTS_FILE,
1691
- downloadToFile,
1692
- extractTarGz,
1693
- getNpmTarballUrl,
1694
- getNpmLatestTarball,
1695
- getPyPITarballUrl,
1696
- scanPackage,
1697
- scanQueue,
1698
- processQueue,
1699
- appendAlert,
1700
- timeoutPromise,
1701
- reportStats,
1702
- stats,
1703
- dailyAlerts,
1704
- recentlyScanned,
1705
- resolveTarballAndScan,
1706
- MAX_TARBALL_SIZE,
1707
- KNOWN_BUNDLED_FILES,
1708
- KNOWN_BUNDLED_PATHS,
1709
- isBundledToolingOnly,
1710
- isSandboxEnabled,
1711
- hasHighOrCritical,
1712
- get sandboxAvailable() { return sandboxAvailable; },
1713
- set sandboxAvailable(v) { sandboxAvailable = v; },
1714
- getWebhookUrl,
1715
- shouldSendWebhook,
1716
- buildMonitorWebhookPayload,
1717
- trySendWebhook,
1718
- computeRiskLevel,
1719
- computeRiskScore,
1720
- buildDailyReportEmbed,
1721
- sendDailyReport,
1722
- DAILY_REPORT_HOUR,
1723
- isDailyReportDue,
1724
- getParisHour,
1725
- getParisDateString,
1726
- isTemporalEnabled,
1727
- buildTemporalWebhookEmbed,
1728
- runTemporalCheck,
1729
- isTemporalAstEnabled,
1730
- buildTemporalAstWebhookEmbed,
1731
- runTemporalAstCheck,
1732
- isTemporalPublishEnabled,
1733
- buildPublishAnomalyWebhookEmbed,
1734
- runTemporalPublishCheck,
1735
- isTemporalMaintainerEnabled,
1736
- buildMaintainerChangeWebhookEmbed,
1737
- runTemporalMaintainerCheck,
1738
- isCanaryEnabled,
1739
- buildCanaryExfiltrationWebhookEmbed,
1740
- isPublishAnomalyOnly,
1741
- isVerboseMode,
1742
- setVerboseMode,
1743
- hasIOCMatch,
1744
- IOC_MATCH_TYPES,
1745
- DETECTIONS_FILE,
1746
- appendDetection,
1747
- loadDetections,
1748
- getDetectionStats,
1749
- SCAN_STATS_FILE,
1750
- loadScanStats,
1751
- updateScanStats,
1752
- buildReportFromDisk,
1753
- buildReportEmbedFromDisk,
1754
- sendReportNow,
1755
- getReportStatus
1756
- };
1757
-
1758
- // Standalone entry point: node src/monitor.js
1759
- if (require.main === module) {
1760
- startMonitor().catch(err => {
1761
- console.error('[MONITOR] Fatal error:', err.message);
1762
- process.exit(1);
1763
- });
1764
- }
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { run } = require('./index.js');
6
+ const { runSandbox, isDockerAvailable } = require('./sandbox.js');
7
+ const { sendWebhook } = require('./webhook.js');
8
+ const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
9
+ const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
10
+ const { detectPublishAnomaly } = require('./publish-anomaly.js');
11
+ const { detectMaintainerChange } = require('./maintainer-change.js');
12
+ const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
13
+ const { MAX_TARBALL_SIZE } = require('./shared/constants.js');
14
+
15
+ // Prevent unhandled promise rejections from crashing the monitor process
16
+ process.on('unhandledRejection', (reason, promise) => {
17
+ console.error('[MONITOR] Unhandled rejection:', reason);
18
+ });
19
+
20
+ const STATE_FILE = path.join(__dirname, '..', 'data', 'monitor-state.json');
21
+ const ALERTS_FILE = path.join(__dirname, '..', 'data', 'monitor-alerts.json');
22
+ const DETECTIONS_FILE = path.join(__dirname, '..', 'data', 'detections.json');
23
+ const SCAN_STATS_FILE = path.join(__dirname, '..', 'data', 'scan-stats.json');
24
+ const POLL_INTERVAL = 60_000;
25
+ const POLL_MAX_BACKOFF = 960_000; // 16 minutes max backoff
26
+ const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
27
+
28
+ // --- Stats counters ---
29
+
30
+ const stats = {
31
+ scanned: 0,
32
+ clean: 0,
33
+ suspect: 0,
34
+ errors: 0,
35
+ totalTimeMs: 0,
36
+ lastReportTime: Date.now(),
37
+ lastDailyReportDate: null // YYYY-MM-DD (Paris) of last daily report sent
38
+ };
39
+
40
+ // Track daily suspects for the daily report (name, version, ecosystem, findingsCount)
41
+ const dailyAlerts = [];
42
+
43
+ // Deduplication: track recently scanned packages (cleared every 24h with daily report)
44
+ const recentlyScanned = new Set();
45
+
46
+ // Consecutive poll error tracking for exponential backoff
47
+ let consecutivePollErrors = 0;
48
+
49
+ // --- Scan queue (FIFO, sequential) ---
50
+
51
+ const scanQueue = [];
52
+
53
+ // --- Sandbox integration ---
54
+
55
+ let sandboxAvailable = false;
56
+
57
+ function isCanaryEnabled() {
58
+ const env = process.env.MUADDIB_MONITOR_CANARY;
59
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
60
+ return true;
61
+ }
62
+
63
+ function buildCanaryExfiltrationWebhookEmbed(packageName, version, exfiltrations) {
64
+ const exfilLines = exfiltrations.map(e => {
65
+ return `**${e.token}** — ${e.foundIn}`;
66
+ }).join('\n');
67
+
68
+ const npmLink = `https://www.npmjs.com/package/${packageName}`;
69
+
70
+ return {
71
+ embeds: [{
72
+ title: '\uD83D\uDD34 CANARY EXFILTRATION \u2014 CRITICAL',
73
+ color: 0xe74c3c,
74
+ fields: [
75
+ { name: 'Package', value: `[${packageName}](${npmLink})`, inline: true },
76
+ { name: 'Version', value: version || 'N/A', inline: true },
77
+ { name: 'Severity', value: 'CRITICAL', inline: true },
78
+ { name: 'Exfiltrated Tokens', value: exfilLines || 'None', inline: false },
79
+ { name: 'Action', value: 'CONFIRMED MALICIOUS \u2014 Do NOT install, report to npm', inline: false }
80
+ ],
81
+ footer: {
82
+ text: `MUAD'DIB Canary Token Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
83
+ },
84
+ timestamp: new Date().toISOString()
85
+ }]
86
+ };
87
+ }
88
+
89
+ function isSandboxEnabled() {
90
+ const env = process.env.MUADDIB_MONITOR_SANDBOX;
91
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
92
+ return true;
93
+ }
94
+
95
+ function isTemporalEnabled() {
96
+ const env = process.env.MUADDIB_MONITOR_TEMPORAL;
97
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
98
+ return true;
99
+ }
100
+
101
+ function hasHighOrCritical(result) {
102
+ return result.summary.critical > 0 || result.summary.high > 0;
103
+ }
104
+
105
+ // --- Verbose mode (--verbose sends ALL alerts including temporal/publish/maintainer) ---
106
+
107
+ let verboseMode = false;
108
+
109
+ function isVerboseMode() {
110
+ if (verboseMode) return true;
111
+ const env = process.env.MUADDIB_MONITOR_VERBOSE;
112
+ return env !== undefined && env.toLowerCase() === 'true';
113
+ }
114
+
115
+ function setVerboseMode(value) {
116
+ verboseMode = !!value;
117
+ }
118
+
119
+ // --- IOC match types (these are the only static-analysis types that warrant a webhook) ---
120
+
121
+ const IOC_MATCH_TYPES = new Set([
122
+ 'known_malicious_package',
123
+ 'known_malicious_hash',
124
+ 'pypi_malicious_package',
125
+ 'shai_hulud_marker',
126
+ 'shai_hulud_backdoor'
127
+ ]);
128
+
129
+ function hasIOCMatch(result) {
130
+ if (!result || !result.threats) return false;
131
+ return result.threats.some(t => IOC_MATCH_TYPES.has(t.type));
132
+ }
133
+
134
+ // --- Webhook alerting ---
135
+
136
+ function getWebhookUrl() {
137
+ return process.env.MUADDIB_WEBHOOK_URL || null;
138
+ }
139
+
140
+ function shouldSendWebhook(result, sandboxResult) {
141
+ if (!getWebhookUrl()) return false;
142
+
143
+ // If sandbox ran, it is the final arbiter
144
+ if (sandboxResult && sandboxResult.score !== undefined) {
145
+ return sandboxResult.score > 0;
146
+ }
147
+
148
+ // No sandbox — only send webhook for confirmed IOC matches
149
+ // (known_malicious_package, known_malicious_hash, pypi_malicious_package, etc.)
150
+ if (hasIOCMatch(result)) return true;
151
+
152
+ return false;
153
+ }
154
+
155
+ function buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult) {
156
+ const payload = {
157
+ event: 'malicious_package',
158
+ package: name,
159
+ version,
160
+ ecosystem,
161
+ timestamp: new Date().toISOString(),
162
+ findings: result.threats.map(t => ({
163
+ rule: t.rule_id || t.type,
164
+ severity: t.severity
165
+ }))
166
+ };
167
+ if (sandboxResult && sandboxResult.score > 0) {
168
+ payload.sandbox = {
169
+ score: sandboxResult.score,
170
+ severity: sandboxResult.severity
171
+ };
172
+ }
173
+ return payload;
174
+ }
175
+
176
+ function computeRiskLevel(summary) {
177
+ if (summary.critical > 0) return 'CRITICAL';
178
+ if (summary.high > 0) return 'HIGH';
179
+ if (summary.medium > 0) return 'MEDIUM';
180
+ if (summary.low > 0) return 'LOW';
181
+ return 'CLEAN';
182
+ }
183
+
184
+ function computeRiskScore(summary) {
185
+ const raw = (summary.critical || 0) * 25
186
+ + (summary.high || 0) * 15
187
+ + (summary.medium || 0) * 5
188
+ + (summary.low || 0) * 1;
189
+ return Math.min(raw, 100);
190
+ }
191
+
192
+ async function trySendWebhook(name, version, ecosystem, result, sandboxResult) {
193
+ if (!shouldSendWebhook(result, sandboxResult)) {
194
+ if (sandboxResult && sandboxResult.score === 0) {
195
+ console.log(`[MONITOR] FALSE POSITIVE (sandbox clean): ${name}@${version}`);
196
+ }
197
+ return;
198
+ }
199
+ const url = getWebhookUrl();
200
+ const payload = buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult);
201
+ const webhookData = {
202
+ target: `${ecosystem}/${name}@${version}`,
203
+ timestamp: payload.timestamp,
204
+ ecosystem,
205
+ summary: {
206
+ ...result.summary,
207
+ riskLevel: computeRiskLevel(result.summary),
208
+ riskScore: computeRiskScore(result.summary)
209
+ },
210
+ threats: result.threats
211
+ };
212
+ if (sandboxResult && sandboxResult.score > 0) {
213
+ webhookData.sandbox = {
214
+ score: sandboxResult.score,
215
+ severity: sandboxResult.severity
216
+ };
217
+ }
218
+ try {
219
+ await sendWebhook(url, webhookData);
220
+ console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
221
+ } catch (err) {
222
+ console.error(`[MONITOR] Webhook failed for ${name}@${version}: ${err.message}`);
223
+ }
224
+ }
225
+
226
+ // --- Temporal analysis integration ---
227
+
228
+ function buildTemporalWebhookEmbed(temporalResult) {
229
+ const findings = temporalResult.findings || [];
230
+ const topFinding = findings[0] || {};
231
+ const severity = topFinding.severity || 'HIGH';
232
+ const color = severity === 'CRITICAL' ? 0xe74c3c : 0xe67e22;
233
+ const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : '\uD83D\uDFE0';
234
+
235
+ const changeLines = findings.map(f => {
236
+ const action = f.type === 'lifecycle_added' ? 'ADDED' : 'MODIFIED';
237
+ const value = f.type === 'lifecycle_modified' ? f.newValue : f.value;
238
+ return `**${f.script}** script ${action}: \`${value}\``;
239
+ }).join('\n');
240
+
241
+ const pkgName = temporalResult.packageName;
242
+ const npmLink = `https://www.npmjs.com/package/${pkgName}`;
243
+
244
+ return {
245
+ embeds: [{
246
+ title: `${emoji} TEMPORAL ANOMALY \u2014 ${severity}`,
247
+ color: color,
248
+ fields: [
249
+ { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
250
+ { name: 'Version Change', value: `${temporalResult.previousVersion} \u2192 ${temporalResult.latestVersion}`, inline: true },
251
+ { name: 'Severity', value: severity, inline: true },
252
+ { name: 'Changes Detected', value: changeLines || 'None', inline: false },
253
+ { name: 'Published', value: temporalResult.metadata.latestPublishedAt || 'unknown', inline: true },
254
+ { name: 'Action', value: 'DO NOT INSTALL \u2014 Verify changelog before upgrading', inline: false }
255
+ ],
256
+ footer: {
257
+ text: `MUAD'DIB Temporal Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
258
+ },
259
+ timestamp: new Date().toISOString()
260
+ }]
261
+ };
262
+ }
263
+
264
+ async function tryTemporalAlert(temporalResult) {
265
+ // Temporal anomalies are logged only — no webhook unless --verbose
266
+ console.log(`[MONITOR] ANOMALY (logged only): temporal lifecycle change for ${temporalResult.packageName}`);
267
+ if (!isVerboseMode()) return;
268
+
269
+ const url = getWebhookUrl();
270
+ if (!url) return;
271
+
272
+ const payload = buildTemporalWebhookEmbed(temporalResult);
273
+ try {
274
+ await sendWebhook(url, payload, { rawPayload: true });
275
+ console.log(`[MONITOR] Temporal webhook sent for ${temporalResult.packageName} (verbose mode)`);
276
+ } catch (err) {
277
+ console.error(`[MONITOR] Temporal webhook failed for ${temporalResult.packageName}: ${err.message}`);
278
+ }
279
+ }
280
+
281
+ function isTemporalAstEnabled() {
282
+ const env = process.env.MUADDIB_MONITOR_TEMPORAL_AST;
283
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
284
+ return true;
285
+ }
286
+
287
+ function buildTemporalAstWebhookEmbed(astResult) {
288
+ const findings = astResult.findings || [];
289
+ const topFinding = findings[0] || {};
290
+ const severity = topFinding.severity || 'HIGH';
291
+ const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
292
+ const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
293
+
294
+ const changeLines = findings.map(f => {
295
+ return `**${f.pattern}** — ${f.severity}: ${f.description}`;
296
+ }).join('\n');
297
+
298
+ const pkgName = astResult.packageName;
299
+ const npmLink = `https://www.npmjs.com/package/${pkgName}`;
300
+
301
+ return {
302
+ embeds: [{
303
+ title: `${emoji} AST ANOMALY \u2014 ${severity}`,
304
+ color: color,
305
+ fields: [
306
+ { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
307
+ { name: 'Version Change', value: `${astResult.previousVersion} \u2192 ${astResult.latestVersion}`, inline: true },
308
+ { name: 'Severity', value: severity, inline: true },
309
+ { name: 'New Dangerous APIs', value: changeLines || 'None', inline: false },
310
+ { name: 'Published', value: astResult.metadata.latestPublishedAt || 'unknown', inline: true },
311
+ { name: 'Action', value: 'DO NOT UPDATE \u2014 Compare sources: npm diff pkg@old pkg@new', inline: false }
312
+ ],
313
+ footer: {
314
+ text: `MUAD'DIB Temporal AST Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
315
+ },
316
+ timestamp: new Date().toISOString()
317
+ }]
318
+ };
319
+ }
320
+
321
+ async function tryTemporalAstAlert(astResult) {
322
+ // AST anomalies are logged only — no webhook unless --verbose
323
+ console.log(`[MONITOR] ANOMALY (logged only): AST change for ${astResult.packageName}`);
324
+ if (!isVerboseMode()) return;
325
+
326
+ const url = getWebhookUrl();
327
+ if (!url) return;
328
+
329
+ const payload = buildTemporalAstWebhookEmbed(astResult);
330
+ try {
331
+ await sendWebhook(url, payload, { rawPayload: true });
332
+ console.log(`[MONITOR] Temporal AST webhook sent for ${astResult.packageName} (verbose mode)`);
333
+ } catch (err) {
334
+ console.error(`[MONITOR] Temporal AST webhook failed for ${astResult.packageName}: ${err.message}`);
335
+ }
336
+ }
337
+
338
+ async function runTemporalAstCheck(packageName) {
339
+ if (!isTemporalAstEnabled()) return null;
340
+ try {
341
+ const result = await detectSuddenAstChanges(packageName);
342
+ if (result.suspicious) {
343
+ const findingsStr = result.findings.map(f => {
344
+ return `${f.pattern} (${f.severity})`;
345
+ }).join(', ');
346
+ console.log(`[MONITOR] AST ANOMALY: ${packageName} v${result.previousVersion} v${result.latestVersion}: ${findingsStr}`);
347
+
348
+ appendAlert({
349
+ timestamp: new Date().toISOString(),
350
+ name: packageName,
351
+ version: result.latestVersion,
352
+ ecosystem: 'npm',
353
+ temporalAst: true,
354
+ findings: result.findings.map(f => ({
355
+ rule: f.severity === 'CRITICAL' ? 'MUADDIB-TEMPORAL-AST-001'
356
+ : f.severity === 'HIGH' ? 'MUADDIB-TEMPORAL-AST-002'
357
+ : 'MUADDIB-TEMPORAL-AST-003',
358
+ severity: f.severity,
359
+ pattern: f.pattern
360
+ }))
361
+ });
362
+
363
+ dailyAlerts.push({
364
+ name: packageName,
365
+ version: result.latestVersion,
366
+ ecosystem: 'npm',
367
+ findingsCount: result.findings.length,
368
+ temporalAst: true
369
+ });
370
+
371
+ // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
372
+ }
373
+ return result;
374
+ } catch (err) {
375
+ console.error(`[MONITOR] Temporal AST analysis error for ${packageName}: ${err.message}`);
376
+ return null;
377
+ }
378
+ }
379
+
380
+ function isTemporalPublishEnabled() {
381
+ const env = process.env.MUADDIB_MONITOR_TEMPORAL_PUBLISH;
382
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
383
+ return true;
384
+ }
385
+
386
+ function buildPublishAnomalyWebhookEmbed(publishResult) {
387
+ const anomalies = publishResult.anomalies || [];
388
+ const topAnomaly = anomalies[0] || {};
389
+ const severity = topAnomaly.severity || 'HIGH';
390
+ const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
391
+ const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
392
+
393
+ const anomalyLines = anomalies.map(a => {
394
+ return `**${a.type}** — ${a.severity}: ${a.description}`;
395
+ }).join('\n');
396
+
397
+ const pkgName = publishResult.packageName;
398
+ const npmLink = `https://www.npmjs.com/package/${pkgName}`;
399
+
400
+ return {
401
+ embeds: [{
402
+ title: `${emoji} PUBLISH ANOMALY \u2014 ${severity}`,
403
+ color: color,
404
+ fields: [
405
+ { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
406
+ { name: 'Versions Analyzed', value: `${publishResult.versionCount || 'N/A'}`, inline: true },
407
+ { name: 'Severity', value: severity, inline: true },
408
+ { name: 'Anomalies Detected', value: anomalyLines || 'None', inline: false },
409
+ { name: 'Action', value: 'Verify maintainer activity on npm/GitHub. Check changelogs for each version.', inline: false }
410
+ ],
411
+ footer: {
412
+ text: `MUAD'DIB Publish Frequency Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
413
+ },
414
+ timestamp: new Date().toISOString()
415
+ }]
416
+ };
417
+ }
418
+
419
+ async function tryTemporalPublishAlert(publishResult) {
420
+ // Publish anomalies are logged only — no webhook unless --verbose
421
+ console.log(`[MONITOR] ANOMALY (logged only): publish frequency for ${publishResult.packageName}`);
422
+ if (!isVerboseMode()) return;
423
+
424
+ const url = getWebhookUrl();
425
+ if (!url) return;
426
+
427
+ const payload = buildPublishAnomalyWebhookEmbed(publishResult);
428
+ try {
429
+ await sendWebhook(url, payload, { rawPayload: true });
430
+ console.log(`[MONITOR] Publish anomaly webhook sent for ${publishResult.packageName} (verbose mode)`);
431
+ } catch (err) {
432
+ console.error(`[MONITOR] Publish anomaly webhook failed for ${publishResult.packageName}: ${err.message}`);
433
+ }
434
+ }
435
+
436
+ async function runTemporalPublishCheck(packageName) {
437
+ if (!isTemporalPublishEnabled()) return null;
438
+ try {
439
+ const result = await detectPublishAnomaly(packageName);
440
+ if (result.suspicious) {
441
+ const anomalyStr = result.anomalies.map(a => {
442
+ return `${a.type} (${a.severity})`;
443
+ }).join(', ');
444
+ console.log(`[MONITOR] PUBLISH ANOMALY: ${packageName}: ${anomalyStr}`);
445
+
446
+ appendAlert({
447
+ timestamp: new Date().toISOString(),
448
+ name: packageName,
449
+ version: 'N/A',
450
+ ecosystem: 'npm',
451
+ temporalPublish: true,
452
+ findings: result.anomalies.map(a => ({
453
+ rule: a.type === 'publish_burst' ? 'MUADDIB-PUBLISH-001'
454
+ : a.type === 'dormant_spike' ? 'MUADDIB-PUBLISH-002'
455
+ : 'MUADDIB-PUBLISH-003',
456
+ severity: a.severity,
457
+ type: a.type
458
+ }))
459
+ });
460
+
461
+ dailyAlerts.push({
462
+ name: packageName,
463
+ version: 'N/A',
464
+ ecosystem: 'npm',
465
+ findingsCount: result.anomalies.length,
466
+ temporalPublish: true
467
+ });
468
+
469
+ // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
470
+ }
471
+ return result;
472
+ } catch (err) {
473
+ console.error(`[MONITOR] Publish frequency analysis error for ${packageName}: ${err.message}`);
474
+ return null;
475
+ }
476
+ }
477
+
478
+ function isTemporalMaintainerEnabled() {
479
+ const env = process.env.MUADDIB_MONITOR_TEMPORAL_MAINTAINER;
480
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
481
+ return true;
482
+ }
483
+
484
+ function buildMaintainerChangeWebhookEmbed(maintainerResult) {
485
+ const findings = maintainerResult.findings || [];
486
+ const topFinding = findings[0] || {};
487
+ const severity = topFinding.severity || 'HIGH';
488
+ const color = severity === 'CRITICAL' ? 0xe74c3c : severity === 'HIGH' ? 0xe67e22 : 0xf1c40f;
489
+ const emoji = severity === 'CRITICAL' ? '\uD83D\uDD34' : severity === 'HIGH' ? '\uD83D\uDFE0' : '\uD83D\uDFE1';
490
+
491
+ const findingLines = findings.map(f => {
492
+ let detail = `**${f.type}** — ${f.severity}: ${f.description}`;
493
+ if (f.riskAssessment && f.riskAssessment.reasons.length > 0) {
494
+ detail += `\nRisk: ${f.riskAssessment.reasons.join(', ')}`;
495
+ }
496
+ return detail;
497
+ }).join('\n');
498
+
499
+ const pkgName = maintainerResult.packageName;
500
+ const npmLink = `https://www.npmjs.com/package/${pkgName}`;
501
+
502
+ return {
503
+ embeds: [{
504
+ title: `${emoji} MAINTAINER CHANGE \u2014 ${severity}`,
505
+ color: color,
506
+ fields: [
507
+ { name: 'Package', value: `[${pkgName}](${npmLink})`, inline: true },
508
+ { name: 'Severity', value: severity, inline: true },
509
+ { name: 'Findings', value: findingLines || 'None', inline: false },
510
+ { name: 'Action', value: 'Verify legitimacy before installing', inline: false }
511
+ ],
512
+ footer: {
513
+ text: `MUAD'DIB Maintainer Change Analysis | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
514
+ },
515
+ timestamp: new Date().toISOString()
516
+ }]
517
+ };
518
+ }
519
+
520
+ async function tryTemporalMaintainerAlert(maintainerResult) {
521
+ // Maintainer changes are logged only — no webhook unless --verbose
522
+ console.log(`[MONITOR] ANOMALY (logged only): maintainer change for ${maintainerResult.packageName}`);
523
+ if (!isVerboseMode()) return;
524
+
525
+ const url = getWebhookUrl();
526
+ if (!url) return;
527
+
528
+ const payload = buildMaintainerChangeWebhookEmbed(maintainerResult);
529
+ try {
530
+ await sendWebhook(url, payload, { rawPayload: true });
531
+ console.log(`[MONITOR] Maintainer change webhook sent for ${maintainerResult.packageName} (verbose mode)`);
532
+ } catch (err) {
533
+ console.error(`[MONITOR] Maintainer change webhook failed for ${maintainerResult.packageName}: ${err.message}`);
534
+ }
535
+ }
536
+
537
+ async function runTemporalMaintainerCheck(packageName) {
538
+ if (!isTemporalMaintainerEnabled()) return null;
539
+ try {
540
+ const result = await detectMaintainerChange(packageName);
541
+ if (result.suspicious) {
542
+ const findingsStr = result.findings.map(f => {
543
+ return `${f.type} (${f.severity})`;
544
+ }).join(', ');
545
+ console.log(`[MONITOR] MAINTAINER CHANGE: ${packageName}: ${findingsStr}`);
546
+
547
+ appendAlert({
548
+ timestamp: new Date().toISOString(),
549
+ name: packageName,
550
+ version: 'N/A',
551
+ ecosystem: 'npm',
552
+ temporalMaintainer: true,
553
+ findings: result.findings.map(f => ({
554
+ rule: f.type === 'new_maintainer' ? 'MUADDIB-MAINTAINER-001'
555
+ : f.type === 'suspicious_maintainer' ? 'MUADDIB-MAINTAINER-002'
556
+ : f.type === 'sole_maintainer_change' ? 'MUADDIB-MAINTAINER-003'
557
+ : 'MUADDIB-MAINTAINER-004',
558
+ severity: f.severity,
559
+ type: f.type
560
+ }))
561
+ });
562
+
563
+ dailyAlerts.push({
564
+ name: packageName,
565
+ version: 'N/A',
566
+ ecosystem: 'npm',
567
+ findingsCount: result.findings.length,
568
+ temporalMaintainer: true
569
+ });
570
+
571
+ // Webhook deferred — sent after sandbox confirms (see resolveTarballAndScan)
572
+ }
573
+ return result;
574
+ } catch (err) {
575
+ console.error(`[MONITOR] Maintainer change analysis error for ${packageName}: ${err.message}`);
576
+ return null;
577
+ }
578
+ }
579
+
580
+ async function runTemporalCheck(packageName) {
581
+ if (!isTemporalEnabled()) return null;
582
+ try {
583
+ const result = await detectSuddenLifecycleChange(packageName);
584
+ if (result.suspicious) {
585
+ const findingsStr = result.findings.map(f => {
586
+ const action = f.type === 'lifecycle_added' ? 'added' : 'modified';
587
+ return `${f.script} ${action} (${f.severity})`;
588
+ }).join(', ');
589
+ console.log(`[MONITOR] TEMPORAL ANOMALY: ${packageName} v${result.previousVersion} → v${result.latestVersion}: ${findingsStr}`);
590
+
591
+ appendAlert({
592
+ timestamp: new Date().toISOString(),
593
+ name: packageName,
594
+ version: result.latestVersion,
595
+ ecosystem: 'npm',
596
+ temporal: true,
597
+ findings: result.findings.map(f => ({
598
+ rule: f.type === 'lifecycle_added' ? 'MUADDIB-TEMPORAL-001' : 'MUADDIB-TEMPORAL-003',
599
+ severity: f.severity,
600
+ script: f.script
601
+ }))
602
+ });
603
+
604
+ dailyAlerts.push({
605
+ name: packageName,
606
+ version: result.latestVersion,
607
+ ecosystem: 'npm',
608
+ findingsCount: result.findings.length,
609
+ temporal: true
610
+ });
611
+
612
+ // Webhook deferred sent after sandbox confirms (see resolveTarballAndScan)
613
+ }
614
+ return result;
615
+ } catch (err) {
616
+ console.error(`[MONITOR] Temporal analysis error for ${packageName}: ${err.message}`);
617
+ return null;
618
+ }
619
+ }
620
+
621
+ // --- State persistence ---
622
+
623
+ function loadState() {
624
+ try {
625
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
626
+ const state = JSON.parse(raw);
627
+ // Restore daily report date so it survives restarts (auto-update, crashes)
628
+ if (typeof state.lastDailyReportDate === 'string') {
629
+ stats.lastDailyReportDate = state.lastDailyReportDate;
630
+ }
631
+ return {
632
+ npmLastPackage: typeof state.npmLastPackage === 'string' ? state.npmLastPackage : '',
633
+ pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
634
+ };
635
+ } catch {
636
+ return { npmLastPackage: '', pypiLastPackage: '' };
637
+ }
638
+ }
639
+
640
+ function saveState(state) {
641
+ try {
642
+ const dir = path.dirname(STATE_FILE);
643
+ if (!fs.existsSync(dir)) {
644
+ fs.mkdirSync(dir, { recursive: true });
645
+ }
646
+ // Persist daily report date so it survives restarts
647
+ const persistedState = {
648
+ ...state,
649
+ lastDailyReportDate: stats.lastDailyReportDate
650
+ };
651
+ // Atomic write: write to .tmp then rename (crash-safe)
652
+ const tmpFile = STATE_FILE + '.tmp';
653
+ fs.writeFileSync(tmpFile, JSON.stringify(persistedState, null, 2), 'utf8');
654
+ fs.renameSync(tmpFile, STATE_FILE);
655
+ } catch (err) {
656
+ console.error(`[MONITOR] Failed to save state: ${err.message}`);
657
+ }
658
+ }
659
+
660
+ // --- HTTP helpers ---
661
+
662
+ function httpsGet(url, timeoutMs = 30_000) {
663
+ return new Promise((resolve, reject) => {
664
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
665
+ if (res.statusCode === 301 || res.statusCode === 302) {
666
+ res.resume();
667
+ const location = res.headers.location;
668
+ if (!location) return reject(new Error(`Redirect without Location for ${url}`));
669
+ return httpsGet(location, timeoutMs).then(resolve, reject);
670
+ }
671
+ if (res.statusCode < 200 || res.statusCode >= 300) {
672
+ res.resume();
673
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
674
+ }
675
+ const chunks = [];
676
+ res.on('data', (chunk) => chunks.push(chunk));
677
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
678
+ res.on('error', reject);
679
+ });
680
+ req.on('error', reject);
681
+ req.on('timeout', () => {
682
+ req.destroy();
683
+ reject(new Error(`Timeout for ${url}`));
684
+ });
685
+ });
686
+ }
687
+
688
+ // --- Tarball URL helpers ---
689
+
690
+ function getNpmTarballUrl(pkgData) {
691
+ return (pkgData.dist && pkgData.dist.tarball) || null;
692
+ }
693
+
694
+ async function getPyPITarballUrl(packageName) {
695
+ const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
696
+ const body = await httpsGet(url);
697
+ let data;
698
+ try {
699
+ data = JSON.parse(body);
700
+ } catch (e) {
701
+ throw new Error(`Invalid JSON from PyPI for ${packageName}: ${e.message}`);
702
+ }
703
+ const version = (data.info && data.info.version) || '';
704
+ const urls = data.urls || [];
705
+ // Prefer sdist (.tar.gz)
706
+ const sdist = urls.find(u => u.packagetype === 'sdist' && u.url);
707
+ if (sdist) return { url: sdist.url, version };
708
+ // Fallback: any .tar.gz
709
+ const tarGz = urls.find(u => u.url && u.url.endsWith('.tar.gz'));
710
+ if (tarGz) return { url: tarGz.url, version };
711
+ // Fallback: first available file
712
+ if (urls.length > 0 && urls[0].url) return { url: urls[0].url, version };
713
+ return { url: null, version };
714
+ }
715
+
716
+ // --- Alerts persistence ---
717
+
718
+ function appendAlert(alert) {
719
+ try {
720
+ const dir = path.dirname(ALERTS_FILE);
721
+ if (!fs.existsSync(dir)) {
722
+ fs.mkdirSync(dir, { recursive: true });
723
+ }
724
+ let alerts = [];
725
+ try {
726
+ alerts = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
727
+ } catch {}
728
+ alerts.push(alert);
729
+ fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2), 'utf8');
730
+ } catch (err) {
731
+ console.error(`[MONITOR] Failed to save alert: ${err.message}`);
732
+ }
733
+ }
734
+
735
+ // --- Detection time logging ---
736
+
737
+ function loadDetections() {
738
+ try {
739
+ const raw = fs.readFileSync(DETECTIONS_FILE, 'utf8');
740
+ const data = JSON.parse(raw);
741
+ if (data && Array.isArray(data.detections)) return data;
742
+ return { detections: [] };
743
+ } catch {
744
+ return { detections: [] };
745
+ }
746
+ }
747
+
748
+ function appendDetection(name, version, ecosystem, findings, severity) {
749
+ try {
750
+ const dir = path.dirname(DETECTIONS_FILE);
751
+ if (!fs.existsSync(dir)) {
752
+ fs.mkdirSync(dir, { recursive: true });
753
+ }
754
+ const data = loadDetections();
755
+ const key = `${name}@${version}`;
756
+ if (data.detections.some(d => `${d.package}@${d.version}` === key)) {
757
+ return; // dedup
758
+ }
759
+ data.detections.push({
760
+ package: name,
761
+ version,
762
+ ecosystem,
763
+ first_seen_at: new Date().toISOString(),
764
+ findings,
765
+ severity,
766
+ advisory_at: null,
767
+ lead_time_hours: null
768
+ });
769
+ fs.writeFileSync(DETECTIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
770
+ } catch (err) {
771
+ console.error(`[MONITOR] Failed to save detection: ${err.message}`);
772
+ }
773
+ }
774
+
775
+ function getDetectionStats() {
776
+ const data = loadDetections();
777
+ const detections = data.detections;
778
+ const total = detections.length;
779
+
780
+ const bySeverity = {};
781
+ const byEcosystem = {};
782
+ for (const d of detections) {
783
+ bySeverity[d.severity] = (bySeverity[d.severity] || 0) + 1;
784
+ byEcosystem[d.ecosystem] = (byEcosystem[d.ecosystem] || 0) + 1;
785
+ }
786
+
787
+ const withLeadTime = detections.filter(d => d.advisory_at && d.lead_time_hours != null);
788
+ let leadTime = null;
789
+ if (withLeadTime.length > 0) {
790
+ const hours = withLeadTime.map(d => d.lead_time_hours);
791
+ leadTime = {
792
+ count: withLeadTime.length,
793
+ avg: hours.reduce((a, b) => a + b, 0) / hours.length,
794
+ min: Math.min(...hours),
795
+ max: Math.max(...hours)
796
+ };
797
+ }
798
+
799
+ return { total, bySeverity, byEcosystem, leadTime };
800
+ }
801
+
802
+ // --- Scan stats (FP rate tracking) ---
803
+
804
+ function loadScanStats() {
805
+ try {
806
+ const raw = fs.readFileSync(SCAN_STATS_FILE, 'utf8');
807
+ const data = JSON.parse(raw);
808
+ if (data && data.stats && Array.isArray(data.daily)) return data;
809
+ return { stats: { total_scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed_malicious: 0 }, daily: [] };
810
+ } catch {
811
+ return { stats: { total_scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed_malicious: 0 }, daily: [] };
812
+ }
813
+ }
814
+
815
+ function updateScanStats(result) {
816
+ const data = loadScanStats();
817
+ data.stats.total_scanned++;
818
+
819
+ if (result === 'clean') data.stats.clean++;
820
+ else if (result === 'suspect') data.stats.suspect++;
821
+ else if (result === 'false_positive') data.stats.false_positive++;
822
+ else if (result === 'confirmed') data.stats.confirmed_malicious++;
823
+
824
+ const today = new Date().toISOString().slice(0, 10);
825
+ let dayEntry = data.daily.find(d => d.date === today);
826
+ if (!dayEntry) {
827
+ dayEntry = { date: today, scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed: 0, fp_rate: 0 };
828
+ data.daily.push(dayEntry);
829
+ }
830
+ dayEntry.scanned++;
831
+
832
+ if (result === 'clean') dayEntry.clean++;
833
+ else if (result === 'suspect') dayEntry.suspect++;
834
+ else if (result === 'false_positive') dayEntry.false_positive++;
835
+ else if (result === 'confirmed') dayEntry.confirmed++;
836
+
837
+ const denom = dayEntry.false_positive + dayEntry.confirmed;
838
+ dayEntry.fp_rate = denom > 0 ? dayEntry.false_positive / denom : 0;
839
+
840
+ try {
841
+ const dir = path.dirname(SCAN_STATS_FILE);
842
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
843
+ fs.writeFileSync(SCAN_STATS_FILE, JSON.stringify(data, null, 2), 'utf8');
844
+ } catch (err) {
845
+ console.error(`[MONITOR] Failed to save scan stats: ${err.message}`);
846
+ }
847
+ }
848
+
849
+ // --- Bundled tooling false-positive filter ---
850
+
851
+ const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
852
+ const KNOWN_BUNDLED_PATHS = ['_next/static/chunks/', '.next/static/chunks/'];
853
+
854
+ function isBundledToolingOnly(threats) {
855
+ if (threats.length === 0) return false;
856
+ return threats.every(t => {
857
+ if (!t.file) return false;
858
+ const basename = path.basename(t.file);
859
+ if (KNOWN_BUNDLED_FILES.includes(basename)) return true;
860
+ const normalized = t.file.replace(/\\/g, '/');
861
+ return KNOWN_BUNDLED_PATHS.some(p => normalized.includes(p));
862
+ });
863
+ }
864
+
865
+ // --- Package scanning ---
866
+
867
+ async function scanPackage(name, version, ecosystem, tarballUrl) {
868
+ const startTime = Date.now();
869
+ const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
870
+ if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
871
+ const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${sanitizePackageName(name)}-`));
872
+
873
+ try {
874
+ const tgzPath = path.join(tmpDir, 'package.tar.gz');
875
+ await downloadToFile(tarballUrl, tgzPath);
876
+
877
+ // Check downloaded size
878
+ const fileSize = fs.statSync(tgzPath).size;
879
+ if (fileSize > MAX_TARBALL_SIZE) {
880
+ console.log(`[MONITOR] SKIP: ${name}@${version} — tarball too large (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
881
+ stats.scanned++;
882
+ return;
883
+ }
884
+
885
+ const extractedDir = extractTarGz(tgzPath, tmpDir);
886
+ const result = await run(extractedDir, { _capture: true });
887
+
888
+ if (result.summary.total === 0) {
889
+ stats.scanned++;
890
+ const elapsed = Date.now() - startTime;
891
+ stats.totalTimeMs += elapsed;
892
+ stats.clean++;
893
+ console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
894
+ updateScanStats('clean');
895
+ return { sandboxResult: null, staticClean: true };
896
+ } else {
897
+ const counts = [];
898
+ if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
899
+ if (result.summary.high > 0) counts.push(`${result.summary.high} HIGH`);
900
+ if (result.summary.medium > 0) counts.push(`${result.summary.medium} MEDIUM`);
901
+ if (result.summary.low > 0) counts.push(`${result.summary.low} LOW`);
902
+
903
+ // Check if all findings come from bundled tooling files
904
+ if (isBundledToolingOnly(result.threats)) {
905
+ stats.scanned++;
906
+ const elapsed = Date.now() - startTime;
907
+ stats.totalTimeMs += elapsed;
908
+ stats.clean++;
909
+ console.log(`[MONITOR] SKIPPED (bundled tooling): ${name}@${version} (${counts.join(', ')})`);
910
+
911
+ const alert = {
912
+ timestamp: new Date().toISOString(),
913
+ name,
914
+ version,
915
+ ecosystem,
916
+ skipped: true,
917
+ findings: result.threats.map(t => ({
918
+ rule: t.rule_id || t.type,
919
+ severity: t.severity,
920
+ file: t.file
921
+ }))
922
+ };
923
+ appendAlert(alert);
924
+ updateScanStats('clean');
925
+ return { sandboxResult: null, staticClean: true };
926
+ } else {
927
+ stats.suspect++;
928
+ console.log(`[MONITOR] SUSPECT: ${name}@${version} (${counts.join(', ')})`);
929
+
930
+ // Sandbox: run dynamic analysis on HIGH/CRITICAL findings
931
+ let sandboxResult = null;
932
+ if (hasHighOrCritical(result) && isSandboxEnabled() && sandboxAvailable) {
933
+ try {
934
+ const canary = isCanaryEnabled();
935
+ console.log(`[MONITOR] SANDBOX: launching for ${name}@${version}${canary ? ' (canary: on)' : ''}...`);
936
+ sandboxResult = await runSandbox(name, { canary });
937
+ console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
938
+
939
+ // Check for canary exfiltration findings and send dedicated alert
940
+ const canaryFindings = (sandboxResult.findings || []).filter(f => f.type === 'canary_exfiltration');
941
+ if (canaryFindings.length > 0) {
942
+ console.log(`[MONITOR] CANARY EXFILTRATION: ${name}@${version} ${canaryFindings.length} token(s) stolen!`);
943
+ const url = getWebhookUrl();
944
+ if (url) {
945
+ const exfiltrations = canaryFindings.map(f => ({
946
+ token: f.detail.match(/exfiltrate (\S+)/)?.[1] || 'UNKNOWN',
947
+ foundIn: f.detail
948
+ }));
949
+ const payload = buildCanaryExfiltrationWebhookEmbed(name, version, exfiltrations);
950
+ try {
951
+ await sendWebhook(url, payload, { rawPayload: true });
952
+ console.log(`[MONITOR] Canary exfiltration webhook sent for ${name}@${version}`);
953
+ } catch (webhookErr) {
954
+ console.error(`[MONITOR] Canary webhook failed for ${name}@${version}: ${webhookErr.message}`);
955
+ }
956
+ }
957
+ }
958
+ } catch (err) {
959
+ console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
960
+ }
961
+ }
962
+
963
+ stats.scanned++;
964
+ const elapsed = Date.now() - startTime;
965
+ stats.totalTimeMs += elapsed;
966
+ console.log(`[MONITOR] ${name}@${version} total time: ${(elapsed / 1000).toFixed(1)}s`);
967
+
968
+ const alert = {
969
+ timestamp: new Date().toISOString(),
970
+ name,
971
+ version,
972
+ ecosystem,
973
+ findings: result.threats.map(t => ({
974
+ rule: t.rule_id || t.type,
975
+ severity: t.severity,
976
+ file: t.file
977
+ }))
978
+ };
979
+
980
+ if (sandboxResult && sandboxResult.score > 0) {
981
+ alert.sandbox = {
982
+ score: sandboxResult.score,
983
+ severity: sandboxResult.severity,
984
+ findings: sandboxResult.findings
985
+ };
986
+ }
987
+
988
+ appendAlert(alert);
989
+
990
+ const findingTypes = [...new Set(result.threats.map(t => t.type))];
991
+ const maxSeverity = result.summary.critical > 0 ? 'CRITICAL'
992
+ : result.summary.high > 0 ? 'HIGH'
993
+ : result.summary.medium > 0 ? 'MEDIUM' : 'LOW';
994
+ appendDetection(name, version, ecosystem, findingTypes, maxSeverity);
995
+
996
+ dailyAlerts.push({ name, version, ecosystem, findingsCount: result.summary.total });
997
+ await trySendWebhook(name, version, ecosystem, result, sandboxResult);
998
+ return { sandboxResult, staticClean: false };
999
+ }
1000
+ }
1001
+ } catch (err) {
1002
+ stats.errors++;
1003
+ stats.scanned++;
1004
+ stats.totalTimeMs += Date.now() - startTime;
1005
+ console.error(`[MONITOR] ERROR scanning ${name}@${version}: ${err.message}`);
1006
+ return { sandboxResult: null, staticClean: false };
1007
+ } finally {
1008
+ // Cleanup temp dir
1009
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1010
+ }
1011
+ }
1012
+
1013
+ function timeoutPromise(ms) {
1014
+ return new Promise((_, reject) => {
1015
+ setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
1016
+ });
1017
+ }
1018
+
1019
+ async function processQueue() {
1020
+ while (scanQueue.length > 0) {
1021
+ const item = scanQueue.shift();
1022
+ try {
1023
+ await Promise.race([
1024
+ resolveTarballAndScan(item),
1025
+ timeoutPromise(SCAN_TIMEOUT_MS)
1026
+ ]);
1027
+ } catch (err) {
1028
+ stats.errors++;
1029
+ console.error(`[MONITOR] Queue error for ${item.name}: ${err.message}`);
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // --- Stats reporting ---
1035
+
1036
+ function reportStats() {
1037
+ const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
1038
+ console.log(`[MONITOR] Stats: ${stats.scanned} scanned, ${stats.clean} clean, ${stats.suspect} suspect, ${stats.errors} error${stats.errors !== 1 ? 's' : ''}, avg ${avg}s/pkg`);
1039
+ stats.lastReportTime = Date.now();
1040
+ }
1041
+
1042
+ const DAILY_REPORT_HOUR = 8; // 08:00 Paris time (Europe/Paris)
1043
+
1044
+ /**
1045
+ * Returns the current hour in Europe/Paris timezone (0-23).
1046
+ */
1047
+ function getParisHour() {
1048
+ const formatter = new Intl.DateTimeFormat('en-GB', {
1049
+ timeZone: 'Europe/Paris',
1050
+ hour: 'numeric',
1051
+ hour12: false
1052
+ });
1053
+ return parseInt(formatter.format(new Date()), 10);
1054
+ }
1055
+
1056
+ /**
1057
+ * Returns today's date string in Europe/Paris timezone (YYYY-MM-DD).
1058
+ */
1059
+ function getParisDateString() {
1060
+ const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' });
1061
+ return formatter.format(new Date());
1062
+ }
1063
+
1064
+ /**
1065
+ * Check if the daily report is due: Paris hour matches DAILY_REPORT_HOUR
1066
+ * and we haven't already sent one today.
1067
+ */
1068
+ function isDailyReportDue() {
1069
+ const parisHour = getParisHour();
1070
+ if (parisHour !== DAILY_REPORT_HOUR) return false;
1071
+ const today = getParisDateString();
1072
+ return stats.lastDailyReportDate !== today;
1073
+ }
1074
+
1075
+ function buildDailyReportEmbed() {
1076
+ // Use disk-based daily entries filtered by lastDailyReportDate for accurate delta
1077
+ const { agg, top3: diskTop3 } = buildReportFromDisk();
1078
+
1079
+ // Prefer in-memory dailyAlerts for top suspects (richer data), fallback to disk
1080
+ const top3 = dailyAlerts.length > 0
1081
+ ? dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 3)
1082
+ : diskTop3;
1083
+
1084
+ const top3Text = top3.length > 0
1085
+ ? top3.map((a, i) => {
1086
+ const name = a.ecosystem ? `${a.ecosystem}/${a.name || a.package}` : (a.name || a.package);
1087
+ const version = a.version || 'N/A';
1088
+ const count = a.findingsCount || (a.findings ? a.findings.length : 0);
1089
+ return `${i + 1}. **${name}@${version}** — ${count} finding(s)`;
1090
+ }).join('\n')
1091
+ : 'None';
1092
+
1093
+ // Avg scan time from in-memory stats (not available on disk)
1094
+ const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
1095
+
1096
+ const now = new Date();
1097
+ const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
1098
+
1099
+ return {
1100
+ embeds: [{
1101
+ title: '\uD83D\uDCCA MUAD\'DIB Daily Report',
1102
+ color: 0x3498db,
1103
+ fields: [
1104
+ { name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
1105
+ { name: 'Clean', value: `${agg.clean}`, inline: true },
1106
+ { name: 'Suspects', value: `${agg.suspect}`, inline: true },
1107
+ { name: 'Errors', value: `${stats.errors}`, inline: true },
1108
+ { name: 'Avg Scan Time', value: `${avg}s/pkg`, inline: true },
1109
+ { name: 'Top Suspects', value: top3Text, inline: false }
1110
+ ],
1111
+ footer: {
1112
+ text: `MUAD'DIB - Daily summary | ${readableTime}`
1113
+ },
1114
+ timestamp: now.toISOString()
1115
+ }]
1116
+ };
1117
+ }
1118
+
1119
+ async function sendDailyReport() {
1120
+ const url = getWebhookUrl();
1121
+ if (!url) return;
1122
+
1123
+ const payload = buildDailyReportEmbed();
1124
+ try {
1125
+ await sendWebhook(url, payload, { rawPayload: true });
1126
+ console.log('[MONITOR] Daily report sent');
1127
+ } catch (err) {
1128
+ console.error(`[MONITOR] Daily report webhook failed: ${err.message}`);
1129
+ }
1130
+
1131
+ // Reset daily counters
1132
+ stats.scanned = 0;
1133
+ stats.clean = 0;
1134
+ stats.suspect = 0;
1135
+ stats.errors = 0;
1136
+ stats.totalTimeMs = 0;
1137
+ dailyAlerts.length = 0;
1138
+ recentlyScanned.clear();
1139
+ stats.lastDailyReportDate = getParisDateString();
1140
+ }
1141
+
1142
+ // --- CLI report helpers (muaddib report --now / --status) ---
1143
+
1144
+ /**
1145
+ * Read raw state file (without restoring into stats).
1146
+ */
1147
+ function loadStateRaw() {
1148
+ try {
1149
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
1150
+ return JSON.parse(raw);
1151
+ } catch {
1152
+ return {};
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Reconstruct daily report data from persisted files (no in-memory stats needed).
1158
+ * Used by `muaddib report --now` to send a report from a separate CLI process.
1159
+ */
1160
+ function buildReportFromDisk() {
1161
+ const scanData = loadScanStats();
1162
+ const stateRaw = loadStateRaw();
1163
+ const lastDate = stateRaw.lastDailyReportDate || null;
1164
+
1165
+ // First report (null): show today only (>= today).
1166
+ // Subsequent reports: show days after last report (> lastDate).
1167
+ const today = getParisDateString();
1168
+ const sinceDays = lastDate
1169
+ ? scanData.daily.filter(d => d.date > lastDate)
1170
+ : scanData.daily.filter(d => d.date >= today);
1171
+
1172
+ // Aggregate counters
1173
+ const agg = { scanned: 0, clean: 0, suspect: 0 };
1174
+ for (const d of sinceDays) {
1175
+ agg.scanned += d.scanned || 0;
1176
+ agg.clean += d.clean || 0;
1177
+ agg.suspect += d.suspect || 0;
1178
+ }
1179
+
1180
+ // Load detections since last report for top suspects
1181
+ const detections = loadDetections();
1182
+ const recentDetections = lastDate
1183
+ ? detections.detections.filter(d => d.first_seen_at && d.first_seen_at.slice(0, 10) > lastDate)
1184
+ : detections.detections.filter(d => d.first_seen_at && d.first_seen_at.slice(0, 10) >= today);
1185
+
1186
+ const top3 = recentDetections
1187
+ .slice()
1188
+ .sort((a, b) => (b.findings ? b.findings.length : 0) - (a.findings ? a.findings.length : 0))
1189
+ .slice(0, 3);
1190
+
1191
+ return { agg, top3, hasData: agg.scanned > 0 };
1192
+ }
1193
+
1194
+ /**
1195
+ * Build a Discord embed from disk data (same format as buildDailyReportEmbed).
1196
+ */
1197
+ function buildReportEmbedFromDisk() {
1198
+ const { agg, top3, hasData } = buildReportFromDisk();
1199
+ if (!hasData) return null;
1200
+
1201
+ const top3Text = top3.length > 0
1202
+ ? top3.map((a, i) => `${i + 1}. **${a.ecosystem}/${a.package}@${a.version}** — ${a.findings ? a.findings.length : 0} finding(s)`).join('\n')
1203
+ : 'None';
1204
+
1205
+ const now = new Date();
1206
+ const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
1207
+
1208
+ return {
1209
+ embeds: [{
1210
+ title: '\uD83D\uDCCA MUAD\'DIB Daily Report (manual)',
1211
+ color: 0x3498db,
1212
+ fields: [
1213
+ { name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
1214
+ { name: 'Clean', value: `${agg.clean}`, inline: true },
1215
+ { name: 'Suspects', value: `${agg.suspect}`, inline: true },
1216
+ { name: 'Top Suspects', value: top3Text, inline: false }
1217
+ ],
1218
+ footer: {
1219
+ text: `MUAD'DIB - Manual report | ${readableTime}`
1220
+ },
1221
+ timestamp: now.toISOString()
1222
+ }]
1223
+ };
1224
+ }
1225
+
1226
+ /**
1227
+ * Force send a daily report from persisted data.
1228
+ * Returns { sent: boolean, message: string }.
1229
+ */
1230
+ async function sendReportNow() {
1231
+ const url = getWebhookUrl();
1232
+ if (!url) {
1233
+ return { sent: false, message: 'MUADDIB_WEBHOOK_URL not configured' };
1234
+ }
1235
+
1236
+ const payload = buildReportEmbedFromDisk();
1237
+ if (!payload) {
1238
+ return { sent: false, message: 'No data to report' };
1239
+ }
1240
+
1241
+ try {
1242
+ await sendWebhook(url, payload, { rawPayload: true });
1243
+ } catch (err) {
1244
+ return { sent: false, message: `Webhook failed: ${err.message}` };
1245
+ }
1246
+
1247
+ // Update lastDailyReportDate on disk
1248
+ const stateRaw = loadStateRaw();
1249
+ const state = {
1250
+ npmLastPackage: stateRaw.npmLastPackage || '',
1251
+ pypiLastPackage: stateRaw.pypiLastPackage || ''
1252
+ };
1253
+ stats.lastDailyReportDate = getParisDateString();
1254
+ saveState(state);
1255
+
1256
+ return { sent: true, message: 'Daily report sent' };
1257
+ }
1258
+
1259
+ /**
1260
+ * Get report status for `muaddib report --status`.
1261
+ */
1262
+ function getReportStatus() {
1263
+ const stateRaw = loadStateRaw();
1264
+ const lastDate = stateRaw.lastDailyReportDate || null;
1265
+
1266
+ // Count packages scanned since last report (today only if never sent)
1267
+ const scanData = loadScanStats();
1268
+ const today = getParisDateString();
1269
+ const sinceDays = lastDate
1270
+ ? scanData.daily.filter(d => d.date > lastDate)
1271
+ : scanData.daily.filter(d => d.date >= today);
1272
+
1273
+ let scannedSince = 0;
1274
+ for (const d of sinceDays) {
1275
+ scannedSince += d.scanned || 0;
1276
+ }
1277
+
1278
+ // Compute next report time
1279
+ const parisHour = getParisHour();
1280
+ let nextReport;
1281
+ if (lastDate === today || (lastDate !== today && parisHour >= DAILY_REPORT_HOUR)) {
1282
+ // Already sent today OR past 08:00 but not sent (will fire soon if monitor runs)
1283
+ if (lastDate === today) {
1284
+ nextReport = 'Tomorrow 08:00 (Europe/Paris)';
1285
+ } else {
1286
+ nextReport = 'Today 08:00 (Europe/Paris) pending, monitor must be running';
1287
+ }
1288
+ } else {
1289
+ nextReport = 'Today 08:00 (Europe/Paris)';
1290
+ }
1291
+
1292
+ return { lastDailyReportDate: lastDate, scannedSince, nextReport };
1293
+ }
1294
+
1295
+ // --- npm polling ---
1296
+
1297
+ /**
1298
+ * Parse npm RSS XML (same regex approach as parsePyPIRss).
1299
+ * Returns array of package names from <title> tags inside <item>.
1300
+ */
1301
+ function parseNpmRss(xml) {
1302
+ const packages = [];
1303
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
1304
+ let match;
1305
+ while ((match = itemRegex.exec(xml)) !== null) {
1306
+ const itemContent = match[1];
1307
+ const titleMatch = itemContent.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
1308
+ if (titleMatch) {
1309
+ const title = titleMatch[1].trim();
1310
+ const name = title.split(/\s+/)[0];
1311
+ if (name) {
1312
+ packages.push(name);
1313
+ }
1314
+ }
1315
+ }
1316
+ return packages;
1317
+ }
1318
+
1319
+ /**
1320
+ * Fetch latest version metadata for an npm package.
1321
+ * Returns { version, tarball } or null on failure.
1322
+ */
1323
+ async function getNpmLatestTarball(packageName) {
1324
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
1325
+ const body = await httpsGet(url);
1326
+ let data;
1327
+ try {
1328
+ data = JSON.parse(body);
1329
+ } catch (e) {
1330
+ throw new Error(`Invalid JSON from npm registry for ${packageName}: ${e.message}`);
1331
+ }
1332
+ const version = data.version || '';
1333
+ const tarball = (data.dist && data.dist.tarball) || null;
1334
+ return { version, tarball };
1335
+ }
1336
+
1337
+ async function pollNpm(state) {
1338
+ const url = 'https://registry.npmjs.org/-/rss?descending=true&limit=50';
1339
+
1340
+ try {
1341
+ const body = await httpsGet(url);
1342
+ const packages = parseNpmRss(body);
1343
+
1344
+ // Find new packages (those after the last seen one)
1345
+ let newPackages;
1346
+ if (!state.npmLastPackage) {
1347
+ newPackages = packages;
1348
+ } else {
1349
+ const lastIdx = packages.indexOf(state.npmLastPackage);
1350
+ if (lastIdx === -1) {
1351
+ newPackages = packages;
1352
+ } else {
1353
+ newPackages = packages.slice(0, lastIdx);
1354
+ }
1355
+ }
1356
+
1357
+ for (const name of newPackages) {
1358
+ console.log(`[MONITOR] New npm: ${name}`);
1359
+ // Queue npm packages — tarball URL resolved during scan
1360
+ scanQueue.push({
1361
+ name,
1362
+ version: '',
1363
+ ecosystem: 'npm',
1364
+ tarballUrl: null // resolved lazily via resolveTarballAndScan
1365
+ });
1366
+ }
1367
+
1368
+ // Remember the most recent package (first in RSS)
1369
+ if (packages.length > 0) {
1370
+ state.npmLastPackage = packages[0];
1371
+ }
1372
+
1373
+ return newPackages.length;
1374
+ } catch (err) {
1375
+ console.error(`[MONITOR] npm poll error: ${err.message}`);
1376
+ return -1;
1377
+ }
1378
+ }
1379
+
1380
+ // --- PyPI polling ---
1381
+
1382
+ /**
1383
+ * Parse PyPI RSS XML (simple regex, no deps).
1384
+ * Returns array of package names from <title> tags inside <item>.
1385
+ */
1386
+ function parsePyPIRss(xml) {
1387
+ const packages = [];
1388
+ // Match each <item>...</item> block
1389
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
1390
+ let match;
1391
+ while ((match = itemRegex.exec(xml)) !== null) {
1392
+ const itemContent = match[1];
1393
+ // Extract <title>...</title> inside item (handles CDATA)
1394
+ const titleMatch = itemContent.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
1395
+ if (titleMatch) {
1396
+ // Title format is usually "package-name 1.0.0"
1397
+ const title = titleMatch[1].trim();
1398
+ // Extract just the package name (first word before space or version)
1399
+ const name = title.split(/\s+/)[0];
1400
+ if (name) {
1401
+ packages.push(name);
1402
+ }
1403
+ }
1404
+ }
1405
+ return packages;
1406
+ }
1407
+
1408
+ async function pollPyPI(state) {
1409
+ const url = 'https://pypi.org/rss/packages.xml';
1410
+
1411
+ try {
1412
+ const body = await httpsGet(url);
1413
+ const packages = parsePyPIRss(body);
1414
+
1415
+ // Find new packages (those after the last seen one)
1416
+ let newPackages;
1417
+ if (!state.pypiLastPackage) {
1418
+ // First run: log all and remember the first one
1419
+ newPackages = packages;
1420
+ } else {
1421
+ const lastIdx = packages.indexOf(state.pypiLastPackage);
1422
+ if (lastIdx === -1) {
1423
+ // Last seen not in feed — all are new
1424
+ newPackages = packages;
1425
+ } else {
1426
+ // Items before lastIdx are newer (RSS is newest-first)
1427
+ newPackages = packages.slice(0, lastIdx);
1428
+ }
1429
+ }
1430
+
1431
+ for (const name of newPackages) {
1432
+ console.log(`[MONITOR] New pypi: ${name}`);
1433
+ // Queue PyPI packages — tarball URL resolved during scan
1434
+ scanQueue.push({
1435
+ name,
1436
+ version: '',
1437
+ ecosystem: 'pypi',
1438
+ tarballUrl: null // resolved lazily in scanPackage wrapper
1439
+ });
1440
+ }
1441
+
1442
+ // Remember the most recent package (first in RSS)
1443
+ if (packages.length > 0) {
1444
+ state.pypiLastPackage = packages[0];
1445
+ }
1446
+
1447
+ return newPackages.length;
1448
+ } catch (err) {
1449
+ console.error(`[MONITOR] PyPI poll error: ${err.message}`);
1450
+ return -1;
1451
+ }
1452
+ }
1453
+
1454
+ // --- Main loop ---
1455
+
1456
+ function cleanupOrphanTmpDirs() {
1457
+ const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
1458
+ try {
1459
+ if (!fs.existsSync(tmpBase)) return;
1460
+ const entries = fs.readdirSync(tmpBase);
1461
+ for (const entry of entries) {
1462
+ const fullPath = path.join(tmpBase, entry);
1463
+ try {
1464
+ fs.rmSync(fullPath, { recursive: true, force: true });
1465
+ } catch {}
1466
+ }
1467
+ if (entries.length > 0) {
1468
+ console.log(`[MONITOR] Cleaned up ${entries.length} orphan temp dir(s)`);
1469
+ }
1470
+ } catch {}
1471
+ }
1472
+
1473
+ async function startMonitor(options) {
1474
+ if (options && options.verbose) {
1475
+ setVerboseMode(true);
1476
+ }
1477
+
1478
+ // Cleanup temp dirs from previous runs (SIGTERM/crash may leave orphans)
1479
+ cleanupOrphanTmpDirs();
1480
+
1481
+ console.log(`
1482
+ ╔════════════════════════════════════════════╗
1483
+ ║ MUAD'DIB - Registry Monitor ║
1484
+ ║ Scanning npm + PyPI new packages ║
1485
+ ╚════════════════════════════════════════════╝
1486
+ `);
1487
+
1488
+ // Check sandbox availability
1489
+ if (isSandboxEnabled()) {
1490
+ sandboxAvailable = isDockerAvailable();
1491
+ if (sandboxAvailable) {
1492
+ console.log('[MONITOR] Docker detected — sandbox enabled for HIGH/CRITICAL findings');
1493
+ } else {
1494
+ console.log('[MONITOR] WARNING: Docker not availablerunning static analysis only');
1495
+ }
1496
+ } else {
1497
+ console.log('[MONITOR] Sandbox disabled (MUADDIB_MONITOR_SANDBOX=false)');
1498
+ }
1499
+
1500
+ // Canary tokens status
1501
+ if (isCanaryEnabled()) {
1502
+ console.log('[MONITOR] Canary tokens enabled — honey tokens injected in sandbox for exfiltration detection');
1503
+ } else {
1504
+ console.log('[MONITOR] Canary tokens disabled (MUADDIB_MONITOR_CANARY=false)');
1505
+ }
1506
+
1507
+ // Temporal analysis status
1508
+ if (isTemporalEnabled()) {
1509
+ console.log('[MONITOR] Temporal lifecycle analysis enabled detecting sudden lifecycle script changes');
1510
+ } else {
1511
+ console.log('[MONITOR] Temporal lifecycle analysis disabled (MUADDIB_MONITOR_TEMPORAL=false)');
1512
+ }
1513
+
1514
+ if (isTemporalAstEnabled()) {
1515
+ console.log('[MONITOR] Temporal AST analysis enabled — detecting sudden dangerous API additions');
1516
+ } else {
1517
+ console.log('[MONITOR] Temporal AST analysis disabled (MUADDIB_MONITOR_TEMPORAL_AST=false)');
1518
+ }
1519
+
1520
+ if (isTemporalPublishEnabled()) {
1521
+ console.log('[MONITOR] Publish frequency analysis enabled — detecting publish bursts, dormant spikes');
1522
+ } else {
1523
+ console.log('[MONITOR] Publish frequency analysis disabled (MUADDIB_MONITOR_TEMPORAL_PUBLISH=false)');
1524
+ }
1525
+
1526
+ if (isTemporalMaintainerEnabled()) {
1527
+ console.log('[MONITOR] Maintainer change analysis enabled — detecting maintainer changes, account takeovers');
1528
+ } else {
1529
+ console.log('[MONITOR] Maintainer change analysis disabled (MUADDIB_MONITOR_TEMPORAL_MAINTAINER=false)');
1530
+ }
1531
+
1532
+ // Webhook filtering mode
1533
+ if (isVerboseMode()) {
1534
+ console.log('[MONITOR] Verbose mode ON — ALL anomalies sent as webhooks (temporal, publish, maintainer, AST)');
1535
+ } else {
1536
+ console.log('[MONITOR] Strict webhook mode — only IOC matches, sandbox confirmations, and canary exfiltrations trigger webhooks');
1537
+ console.log('[MONITOR] Temporal/publish/maintainer anomalies are logged but NOT sent as webhooks');
1538
+ console.log('[MONITOR] Use --verbose to send all anomalies as webhooks');
1539
+ }
1540
+
1541
+ const state = loadState();
1542
+ console.log(`[MONITOR] State loaded — npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}`);
1543
+ console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
1544
+
1545
+ let running = true;
1546
+
1547
+ // Graceful shutdown handler (shared by SIGINT and SIGTERM)
1548
+ async function gracefulShutdown(signal) {
1549
+ console.log(`\n[MONITOR] Received ${signal} — sending pending daily report...`);
1550
+ if (stats.scanned > 0) {
1551
+ await sendDailyReport();
1552
+ }
1553
+ saveState(state);
1554
+ reportStats();
1555
+ console.log('[MONITOR] State saved. Bye!');
1556
+ running = false;
1557
+ process.exit(0);
1558
+ }
1559
+
1560
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1561
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1562
+
1563
+ // Initial poll + scan
1564
+ await poll(state);
1565
+ saveState(state);
1566
+ await processQueue();
1567
+
1568
+ // Interval polling
1569
+ while (running) {
1570
+ await sleep(POLL_INTERVAL);
1571
+ if (!running) break;
1572
+ await poll(state);
1573
+ saveState(state);
1574
+ await processQueue();
1575
+
1576
+ // Hourly stats report
1577
+ if (Date.now() - stats.lastReportTime >= 3600_000) {
1578
+ reportStats();
1579
+ }
1580
+
1581
+ // Daily webhook report at 08:00 Paris time
1582
+ if (isDailyReportDue()) {
1583
+ await sendDailyReport();
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ async function poll(state) {
1589
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
1590
+ console.log(`[MONITOR] ${timestamp} — polling registries...`);
1591
+
1592
+ const [npmCount, pypiCount] = await Promise.all([
1593
+ pollNpm(state),
1594
+ pollPyPI(state)
1595
+ ]);
1596
+
1597
+ // Track consecutive poll failures for backoff
1598
+ if (npmCount === -1 && pypiCount === -1) {
1599
+ consecutivePollErrors++;
1600
+ if (consecutivePollErrors > 1) {
1601
+ const backoff = Math.min(POLL_INTERVAL * Math.pow(2, consecutivePollErrors - 1), POLL_MAX_BACKOFF);
1602
+ console.log(`[MONITOR] Both registries failed (${consecutivePollErrors}x) — backing off ${(backoff / 1000).toFixed(0)}s`);
1603
+ await sleep(backoff);
1604
+ }
1605
+ } else {
1606
+ consecutivePollErrors = 0;
1607
+ }
1608
+
1609
+ const npmDisplay = npmCount === -1 ? 'error' : npmCount;
1610
+ const pypiDisplay = pypiCount === -1 ? 'error' : pypiCount;
1611
+ console.log(`[MONITOR] Found ${npmDisplay} npm + ${pypiDisplay} PyPI new packages`);
1612
+ }
1613
+
1614
+ /**
1615
+ * Returns true if publish_anomaly is the ONLY suspicious temporal result.
1616
+ * publish_anomaly alone is too noisy for webhooks — only alert when combined
1617
+ * with another anomaly (lifecycle, AST, maintainer).
1618
+ */
1619
+ function isPublishAnomalyOnly(temporalResult, astResult, publishResult, maintainerResult) {
1620
+ const hasLifecycle = temporalResult && temporalResult.suspicious;
1621
+ const hasAst = astResult && astResult.suspicious;
1622
+ const hasPublish = publishResult && publishResult.suspicious;
1623
+ const hasMaintainer = maintainerResult && maintainerResult.suspicious;
1624
+
1625
+ return !!(hasPublish && !hasLifecycle && !hasAst && !hasMaintainer);
1626
+ }
1627
+
1628
+ /**
1629
+ * Wrapper to resolve PyPI tarball URLs before scanning.
1630
+ * For npm packages, tarballUrl is already set from the registry response.
1631
+ * For PyPI packages, we need to fetch the JSON API to get the tarball URL.
1632
+ */
1633
+ async function resolveTarballAndScan(item) {
1634
+ if (item.ecosystem === 'npm' && !item.tarballUrl) {
1635
+ try {
1636
+ const npmInfo = await getNpmLatestTarball(item.name);
1637
+ if (!npmInfo.tarball) {
1638
+ console.log(`[MONITOR] SKIP: ${item.name} no tarball URL found on npm`);
1639
+ return;
1640
+ }
1641
+ item.tarballUrl = npmInfo.tarball;
1642
+ if (npmInfo.version) item.version = npmInfo.version;
1643
+ } catch (err) {
1644
+ console.error(`[MONITOR] ERROR resolving npm tarball for ${item.name}: ${err.message}`);
1645
+ stats.errors++;
1646
+ return;
1647
+ }
1648
+ }
1649
+ if (item.ecosystem === 'pypi' && !item.tarballUrl) {
1650
+ try {
1651
+ const pypiInfo = await getPyPITarballUrl(item.name);
1652
+ if (!pypiInfo.url) {
1653
+ console.log(`[MONITOR] SKIP: ${item.name} no tarball URL found on PyPI`);
1654
+ return;
1655
+ }
1656
+ item.tarballUrl = pypiInfo.url;
1657
+ if (pypiInfo.version) item.version = pypiInfo.version;
1658
+ } catch (err) {
1659
+ console.error(`[MONITOR] ERROR resolving PyPI tarball for ${item.name}: ${err.message}`);
1660
+ stats.errors++;
1661
+ return;
1662
+ }
1663
+ }
1664
+ // Deduplication: skip if already scanned in the last 24h
1665
+ const dedupeKey = `${item.ecosystem}/${item.name}@${item.version}`;
1666
+ if (recentlyScanned.has(dedupeKey)) {
1667
+ console.log(`[MONITOR] SKIP (already scanned): ${item.name}@${item.version}`);
1668
+ return;
1669
+ }
1670
+ recentlyScanned.add(dedupeKey);
1671
+
1672
+ // Temporal analysis: check for sudden lifecycle script changes (npm only)
1673
+ // Webhooks are deferred until after sandbox confirms the threat
1674
+ let temporalResult = null;
1675
+ let astResult = null;
1676
+ let publishResult = null;
1677
+ let maintainerResult = null;
1678
+
1679
+ if (item.ecosystem === 'npm') {
1680
+ temporalResult = await runTemporalCheck(item.name);
1681
+ astResult = await runTemporalAstCheck(item.name);
1682
+ publishResult = await runTemporalPublishCheck(item.name);
1683
+ maintainerResult = await runTemporalMaintainerCheck(item.name);
1684
+ }
1685
+
1686
+ const scanResult = await scanPackage(item.name, item.version, item.ecosystem, item.tarballUrl);
1687
+ const sandboxResult = scanResult && scanResult.sandboxResult;
1688
+ const staticClean = scanResult && scanResult.staticClean;
1689
+
1690
+ // FP rate tracking
1691
+ if (scanResult) {
1692
+ if (!staticClean) {
1693
+ if (sandboxResult && sandboxResult.score === 0) {
1694
+ updateScanStats('false_positive');
1695
+ } else if (sandboxResult && sandboxResult.score > 0) {
1696
+ updateScanStats('confirmed');
1697
+ } else {
1698
+ updateScanStats('suspect');
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ // Send temporal webhooks only if the package is confirmed suspicious
1704
+ const hasSuspiciousTemporal = (temporalResult && temporalResult.suspicious)
1705
+ || (astResult && astResult.suspicious)
1706
+ || (publishResult && publishResult.suspicious)
1707
+ || (maintainerResult && maintainerResult.suspicious);
1708
+
1709
+ if (hasSuspiciousTemporal) {
1710
+ // Sandbox ran and package is CLEAN → suppress temporal webhooks
1711
+ if (sandboxResult && sandboxResult.score === 0) {
1712
+ console.log(`[MONITOR] FALSE POSITIVE (sandbox clean, no alert): ${item.name}@${item.version}`);
1713
+ // Static scan is CLEAN (0 findings) and no sandbox ran → suppress temporal webhooks
1714
+ } else if (staticClean && !sandboxResult) {
1715
+ console.log(`[MONITOR] FALSE POSITIVE (static clean, no alert): ${item.name}@${item.version}`);
1716
+ // publish_anomaly alone → no webhook (too noisy, not actionable alone)
1717
+ } else if (isPublishAnomalyOnly(temporalResult, astResult, publishResult, maintainerResult)) {
1718
+ console.log(`[MONITOR] PUBLISH ANOMALY (alone, no alert): ${item.name}@${item.version}`);
1719
+ } else {
1720
+ // Sandbox confirmed threat (score > 0) OR static scan found threats → send webhooks
1721
+ if (temporalResult && temporalResult.suspicious) await tryTemporalAlert(temporalResult);
1722
+ if (astResult && astResult.suspicious) await tryTemporalAstAlert(astResult);
1723
+ if (publishResult && publishResult.suspicious) await tryTemporalPublishAlert(publishResult);
1724
+ if (maintainerResult && maintainerResult.suspicious) await tryTemporalMaintainerAlert(maintainerResult);
1725
+ }
1726
+ }
1727
+ }
1728
+
1729
+ function sleep(ms) {
1730
+ return new Promise((resolve) => setTimeout(resolve, ms));
1731
+ }
1732
+
1733
+ module.exports = {
1734
+ startMonitor,
1735
+ parseNpmRss,
1736
+ parsePyPIRss,
1737
+ loadState,
1738
+ saveState,
1739
+ STATE_FILE,
1740
+ ALERTS_FILE,
1741
+ downloadToFile,
1742
+ extractTarGz,
1743
+ getNpmTarballUrl,
1744
+ getNpmLatestTarball,
1745
+ getPyPITarballUrl,
1746
+ scanPackage,
1747
+ scanQueue,
1748
+ processQueue,
1749
+ appendAlert,
1750
+ timeoutPromise,
1751
+ reportStats,
1752
+ stats,
1753
+ dailyAlerts,
1754
+ recentlyScanned,
1755
+ resolveTarballAndScan,
1756
+ MAX_TARBALL_SIZE,
1757
+ KNOWN_BUNDLED_FILES,
1758
+ KNOWN_BUNDLED_PATHS,
1759
+ isBundledToolingOnly,
1760
+ isSandboxEnabled,
1761
+ hasHighOrCritical,
1762
+ get sandboxAvailable() { return sandboxAvailable; },
1763
+ set sandboxAvailable(v) { sandboxAvailable = v; },
1764
+ getWebhookUrl,
1765
+ shouldSendWebhook,
1766
+ buildMonitorWebhookPayload,
1767
+ trySendWebhook,
1768
+ computeRiskLevel,
1769
+ computeRiskScore,
1770
+ buildDailyReportEmbed,
1771
+ sendDailyReport,
1772
+ DAILY_REPORT_HOUR,
1773
+ isDailyReportDue,
1774
+ getParisHour,
1775
+ getParisDateString,
1776
+ isTemporalEnabled,
1777
+ buildTemporalWebhookEmbed,
1778
+ runTemporalCheck,
1779
+ isTemporalAstEnabled,
1780
+ buildTemporalAstWebhookEmbed,
1781
+ runTemporalAstCheck,
1782
+ isTemporalPublishEnabled,
1783
+ buildPublishAnomalyWebhookEmbed,
1784
+ runTemporalPublishCheck,
1785
+ isTemporalMaintainerEnabled,
1786
+ buildMaintainerChangeWebhookEmbed,
1787
+ runTemporalMaintainerCheck,
1788
+ isCanaryEnabled,
1789
+ buildCanaryExfiltrationWebhookEmbed,
1790
+ isPublishAnomalyOnly,
1791
+ isVerboseMode,
1792
+ setVerboseMode,
1793
+ hasIOCMatch,
1794
+ IOC_MATCH_TYPES,
1795
+ DETECTIONS_FILE,
1796
+ appendDetection,
1797
+ loadDetections,
1798
+ getDetectionStats,
1799
+ SCAN_STATS_FILE,
1800
+ loadScanStats,
1801
+ updateScanStats,
1802
+ buildReportFromDisk,
1803
+ buildReportEmbedFromDisk,
1804
+ sendReportNow,
1805
+ getReportStatus,
1806
+ cleanupOrphanTmpDirs,
1807
+ consecutivePollErrors: { get() { return consecutivePollErrors; }, set(v) { consecutivePollErrors = v; } },
1808
+ POLL_MAX_BACKOFF
1809
+ };
1810
+
1811
+ // Standalone entry point: node src/monitor.js
1812
+ if (require.main === module) {
1813
+ startMonitor().catch(err => {
1814
+ console.error('[MONITOR] Fatal error:', err.message);
1815
+ process.exit(1);
1816
+ });
1817
+ }