muaddib-scanner 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.fr.md +8 -2
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/ioc/scraper.js +1 -1
- package/src/monitor.js +259 -93
- package/src/response/playbooks.js +7 -0
- package/src/rules/index.js +27 -0
- package/src/webhook.js +53 -4
- package/.pre-commit-hooks.yaml +0 -41
- package/action.yml +0 -97
- package/assets/logo.png +0 -0
- package/assets/logo2removebg.png +0 -0
- package/data/iocs.json +0 -11915
- package/data/static-iocs.json +0 -60
- package/eslint.config.mjs +0 -41
- package/hooks/husky.js +0 -49
- package/hooks/pre-commit +0 -22
- package/hooks/pre-commit-diff +0 -22
- package/logo2removebg.png +0 -0
- package/src/ioc/data/.ossf-tree-sha +0 -1
- package/src/ioc/data/iocs-compact.json +0 -1
- package/test-output.txt +0 -369
package/README.fr.md
CHANGED
|
@@ -283,7 +283,7 @@ Ajoutez à `.pre-commit-config.yaml` :
|
|
|
283
283
|
```yaml
|
|
284
284
|
repos:
|
|
285
285
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
286
|
-
rev: v1.
|
|
286
|
+
rev: v1.8.0
|
|
287
287
|
hooks:
|
|
288
288
|
- id: muaddib-scan # Scanner toutes les menaces
|
|
289
289
|
# - id: muaddib-diff # Ou: seulement les nouvelles
|
|
@@ -313,6 +313,10 @@ muaddib init-hooks --type git
|
|
|
313
313
|
# Crée .git/hooks/pre-commit
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
+
### Moniteur Zero-Day
|
|
317
|
+
|
|
318
|
+
MUAD'DIB intègre un moniteur zero-day capable de scanner chaque nouveau package npm et PyPI en temps réel avec analyse sandbox Docker et alertes webhook.
|
|
319
|
+
|
|
316
320
|
### Version check
|
|
317
321
|
|
|
318
322
|
MUAD'DIB vérifie automatiquement les nouvelles versions au démarrage et vous notifie si une mise à jour est disponible.
|
|
@@ -504,6 +508,7 @@ MUAD'DIB Scanner
|
|
|
504
508
|
+-- GitHub Actions Scanner
|
|
505
509
|
+-- Paranoid Mode (ultra-strict)
|
|
506
510
|
+-- Docker Sandbox (behavioral analysis, network capture)
|
|
511
|
+
+-- Moniteur Zero-Day (polling RSS npm + PyPI, alertes Discord, rapport quotidien)
|
|
507
512
|
|
|
|
508
513
|
v
|
|
509
514
|
Dataflow Analysis (credential read -> network send)
|
|
@@ -547,9 +552,10 @@ npm test
|
|
|
547
552
|
|
|
548
553
|
### Tests
|
|
549
554
|
|
|
550
|
-
- **
|
|
555
|
+
- **326 tests unitaires/intégration** - 80% coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
|
|
551
556
|
- **56 tests de fuzzing** - YAML malformé, JSON invalide, fichiers binaires, ReDoS, unicode, inputs 10MB
|
|
552
557
|
- **15 tests adversariaux** - Packages malveillants simulés, taux de détection 15/15
|
|
558
|
+
- **8 tests multi-facteur typosquat** - Cas limites et comportement cache
|
|
553
559
|
- **Audit ESLint sécurité** - `eslint-plugin-security` avec 14 règles activées
|
|
554
560
|
|
|
555
561
|
---
|
package/README.md
CHANGED
|
@@ -283,7 +283,7 @@ Add to `.pre-commit-config.yaml`:
|
|
|
283
283
|
```yaml
|
|
284
284
|
repos:
|
|
285
285
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
286
|
-
rev: v1.
|
|
286
|
+
rev: v1.8.0
|
|
287
287
|
hooks:
|
|
288
288
|
- id: muaddib-scan # Scan all threats
|
|
289
289
|
# - id: muaddib-diff # Or: only new threats
|
|
@@ -313,6 +313,10 @@ muaddib init-hooks --type git
|
|
|
313
313
|
# Creates .git/hooks/pre-commit
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
+
### Zero-Day Monitor
|
|
317
|
+
|
|
318
|
+
MUAD'DIB includes a continuous zero-day monitor capable of scanning every new npm and PyPI package in real-time with Docker sandbox analysis and webhook alerting.
|
|
319
|
+
|
|
316
320
|
### Version check
|
|
317
321
|
|
|
318
322
|
MUAD'DIB automatically checks for new versions on startup and notifies you if an update is available.
|
|
@@ -510,6 +514,7 @@ MUAD'DIB Scanner
|
|
|
510
514
|
|
|
|
511
515
|
+-- Paranoid Mode (ultra-strict)
|
|
512
516
|
+-- Docker Sandbox (behavioral analysis, network capture)
|
|
517
|
+
+-- Zero-Day Monitor (npm + PyPI RSS polling, Discord alerts, daily report)
|
|
513
518
|
|
|
|
514
519
|
v
|
|
515
520
|
Dataflow Analysis (credential read -> network send)
|
|
@@ -553,9 +558,10 @@ npm test
|
|
|
553
558
|
|
|
554
559
|
### Testing
|
|
555
560
|
|
|
556
|
-
- **
|
|
561
|
+
- **326 unit/integration tests** - 80% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
|
|
557
562
|
- **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
|
|
558
563
|
- **15 adversarial tests** - Simulated malicious packages, 15/15 detection rate
|
|
564
|
+
- **8 multi-factor typosquat tests** - Edge cases and cache behavior
|
|
559
565
|
- **False positive validation** - 0 false positives on express, lodash, axios, react
|
|
560
566
|
- **ESLint security audit** - `eslint-plugin-security` with 14 rules enabled
|
|
561
567
|
|
package/package.json
CHANGED
package/src/ioc/scraper.js
CHANGED
|
@@ -5,7 +5,7 @@ const AdmZip = require('adm-zip');
|
|
|
5
5
|
|
|
6
6
|
const IOC_FILE = path.join(__dirname, 'data/iocs.json');
|
|
7
7
|
const COMPACT_IOC_FILE = path.join(__dirname, 'data/iocs-compact.json');
|
|
8
|
-
const STATIC_IOCS_FILE = path.join(__dirname, 'data/static-iocs.json');
|
|
8
|
+
const STATIC_IOCS_FILE = path.join(__dirname, '../../data/static-iocs.json');
|
|
9
9
|
const { generateCompactIOCs } = require('./updater.js');
|
|
10
10
|
const { Spinner } = require('../utils.js');
|
|
11
11
|
|
package/src/monitor.js
CHANGED
|
@@ -21,9 +21,13 @@ const stats = {
|
|
|
21
21
|
suspect: 0,
|
|
22
22
|
errors: 0,
|
|
23
23
|
totalTimeMs: 0,
|
|
24
|
-
lastReportTime: Date.now()
|
|
24
|
+
lastReportTime: Date.now(),
|
|
25
|
+
lastDailyReportTime: Date.now()
|
|
25
26
|
};
|
|
26
27
|
|
|
28
|
+
// Track daily suspects for the daily report (name, version, ecosystem, findingsCount)
|
|
29
|
+
const dailyAlerts = [];
|
|
30
|
+
|
|
27
31
|
// --- Scan queue (FIFO, sequential) ---
|
|
28
32
|
|
|
29
33
|
const scanQueue = [];
|
|
@@ -76,17 +80,43 @@ function buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxRes
|
|
|
76
80
|
return payload;
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
function computeRiskLevel(summary) {
|
|
84
|
+
if (summary.critical > 0) return 'CRITICAL';
|
|
85
|
+
if (summary.high > 0) return 'HIGH';
|
|
86
|
+
if (summary.medium > 0) return 'MEDIUM';
|
|
87
|
+
if (summary.low > 0) return 'LOW';
|
|
88
|
+
return 'CLEAN';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function computeRiskScore(summary) {
|
|
92
|
+
const raw = (summary.critical || 0) * 25
|
|
93
|
+
+ (summary.high || 0) * 15
|
|
94
|
+
+ (summary.medium || 0) * 5
|
|
95
|
+
+ (summary.low || 0) * 1;
|
|
96
|
+
return Math.min(raw, 100);
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
async function trySendWebhook(name, version, ecosystem, result, sandboxResult) {
|
|
80
100
|
if (!shouldSendWebhook(result, sandboxResult)) return;
|
|
81
101
|
const url = getWebhookUrl();
|
|
82
102
|
const payload = buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult);
|
|
83
|
-
// sendWebhook expects a results-like object; wrap payload for formatGeneric
|
|
84
103
|
const webhookData = {
|
|
85
104
|
target: `${ecosystem}/${name}@${version}`,
|
|
86
105
|
timestamp: payload.timestamp,
|
|
87
|
-
|
|
106
|
+
ecosystem,
|
|
107
|
+
summary: {
|
|
108
|
+
...result.summary,
|
|
109
|
+
riskLevel: computeRiskLevel(result.summary),
|
|
110
|
+
riskScore: computeRiskScore(result.summary)
|
|
111
|
+
},
|
|
88
112
|
threats: result.threats
|
|
89
113
|
};
|
|
114
|
+
if (sandboxResult && sandboxResult.score > 0) {
|
|
115
|
+
webhookData.sandbox = {
|
|
116
|
+
score: sandboxResult.score,
|
|
117
|
+
severity: sandboxResult.severity
|
|
118
|
+
};
|
|
119
|
+
}
|
|
90
120
|
try {
|
|
91
121
|
await sendWebhook(url, webhookData);
|
|
92
122
|
console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
|
|
@@ -102,11 +132,11 @@ function loadState() {
|
|
|
102
132
|
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
|
103
133
|
const state = JSON.parse(raw);
|
|
104
134
|
return {
|
|
105
|
-
|
|
135
|
+
npmLastPackage: typeof state.npmLastPackage === 'string' ? state.npmLastPackage : '',
|
|
106
136
|
pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
|
|
107
137
|
};
|
|
108
138
|
} catch {
|
|
109
|
-
return {
|
|
139
|
+
return { npmLastPackage: '', pypiLastPackage: '' };
|
|
110
140
|
}
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -264,6 +294,19 @@ function appendAlert(alert) {
|
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
|
|
297
|
+
// --- Bundled tooling false-positive filter ---
|
|
298
|
+
|
|
299
|
+
const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
|
|
300
|
+
|
|
301
|
+
function isBundledToolingOnly(threats) {
|
|
302
|
+
if (threats.length === 0) return false;
|
|
303
|
+
return threats.every(t => {
|
|
304
|
+
if (!t.file) return false;
|
|
305
|
+
const basename = path.basename(t.file);
|
|
306
|
+
return KNOWN_BUNDLED_FILES.includes(basename);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
267
310
|
// --- Package scanning ---
|
|
268
311
|
|
|
269
312
|
async function scanPackage(name, version, ecosystem, tarballUrl) {
|
|
@@ -294,53 +337,78 @@ async function scanPackage(name, version, ecosystem, tarballUrl) {
|
|
|
294
337
|
stats.clean++;
|
|
295
338
|
console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
|
|
296
339
|
} else {
|
|
297
|
-
stats.suspect++;
|
|
298
340
|
const counts = [];
|
|
299
341
|
if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
|
|
300
342
|
if (result.summary.high > 0) counts.push(`${result.summary.high} HIGH`);
|
|
301
343
|
if (result.summary.medium > 0) counts.push(`${result.summary.medium} MEDIUM`);
|
|
302
344
|
if (result.summary.low > 0) counts.push(`${result.summary.low} LOW`);
|
|
303
|
-
console.log(`[MONITOR] SUSPECT: ${name}@${version} (${counts.join(', ')})`);
|
|
304
|
-
|
|
305
|
-
// Sandbox: run dynamic analysis on HIGH/CRITICAL findings
|
|
306
|
-
let sandboxResult = null;
|
|
307
|
-
if (hasHighOrCritical(result) && isSandboxEnabled() && sandboxAvailable) {
|
|
308
|
-
try {
|
|
309
|
-
console.log(`[MONITOR] SANDBOX: launching for ${name}@${version}...`);
|
|
310
|
-
sandboxResult = await runSandbox(name);
|
|
311
|
-
console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
|
|
312
|
-
} catch (err) {
|
|
313
|
-
console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
345
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
346
|
+
// Check if all findings come from bundled tooling files
|
|
347
|
+
if (isBundledToolingOnly(result.threats)) {
|
|
348
|
+
stats.scanned++;
|
|
349
|
+
const elapsed = Date.now() - startTime;
|
|
350
|
+
stats.totalTimeMs += elapsed;
|
|
351
|
+
stats.clean++;
|
|
352
|
+
console.log(`[MONITOR] SKIPPED (bundled tooling): ${name}@${version} (${counts.join(', ')})`);
|
|
353
|
+
|
|
354
|
+
const alert = {
|
|
355
|
+
timestamp: new Date().toISOString(),
|
|
356
|
+
name,
|
|
357
|
+
version,
|
|
358
|
+
ecosystem,
|
|
359
|
+
skipped: true,
|
|
360
|
+
findings: result.threats.map(t => ({
|
|
361
|
+
rule: t.rule_id || t.type,
|
|
362
|
+
severity: t.severity,
|
|
363
|
+
file: t.file
|
|
364
|
+
}))
|
|
365
|
+
};
|
|
366
|
+
appendAlert(alert);
|
|
367
|
+
} else {
|
|
368
|
+
stats.suspect++;
|
|
369
|
+
console.log(`[MONITOR] SUSPECT: ${name}@${version} (${counts.join(', ')})`);
|
|
370
|
+
|
|
371
|
+
// Sandbox: run dynamic analysis on HIGH/CRITICAL findings
|
|
372
|
+
let sandboxResult = null;
|
|
373
|
+
if (hasHighOrCritical(result) && isSandboxEnabled() && sandboxAvailable) {
|
|
374
|
+
try {
|
|
375
|
+
console.log(`[MONITOR] SANDBOX: launching for ${name}@${version}...`);
|
|
376
|
+
sandboxResult = await runSandbox(name);
|
|
377
|
+
console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
321
382
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
version
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
severity: sandboxResult.severity,
|
|
338
|
-
findings: sandboxResult.findings
|
|
383
|
+
stats.scanned++;
|
|
384
|
+
const elapsed = Date.now() - startTime;
|
|
385
|
+
stats.totalTimeMs += elapsed;
|
|
386
|
+
console.log(`[MONITOR] ${name}@${version} total time: ${(elapsed / 1000).toFixed(1)}s`);
|
|
387
|
+
|
|
388
|
+
const alert = {
|
|
389
|
+
timestamp: new Date().toISOString(),
|
|
390
|
+
name,
|
|
391
|
+
version,
|
|
392
|
+
ecosystem,
|
|
393
|
+
findings: result.threats.map(t => ({
|
|
394
|
+
rule: t.rule_id || t.type,
|
|
395
|
+
severity: t.severity,
|
|
396
|
+
file: t.file
|
|
397
|
+
}))
|
|
339
398
|
};
|
|
340
|
-
}
|
|
341
399
|
|
|
342
|
-
|
|
343
|
-
|
|
400
|
+
if (sandboxResult && sandboxResult.score > 0) {
|
|
401
|
+
alert.sandbox = {
|
|
402
|
+
score: sandboxResult.score,
|
|
403
|
+
severity: sandboxResult.severity,
|
|
404
|
+
findings: sandboxResult.findings
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
appendAlert(alert);
|
|
409
|
+
dailyAlerts.push({ name, version, ecosystem, findingsCount: result.summary.total });
|
|
410
|
+
await trySendWebhook(name, version, ecosystem, result, sandboxResult);
|
|
411
|
+
}
|
|
344
412
|
}
|
|
345
413
|
} catch (err) {
|
|
346
414
|
stats.errors++;
|
|
@@ -364,7 +432,7 @@ async function processQueue() {
|
|
|
364
432
|
const item = scanQueue.shift();
|
|
365
433
|
try {
|
|
366
434
|
await Promise.race([
|
|
367
|
-
|
|
435
|
+
resolveTarballAndScan(item),
|
|
368
436
|
timeoutPromise(SCAN_TIMEOUT_MS)
|
|
369
437
|
]);
|
|
370
438
|
} catch (err) {
|
|
@@ -382,71 +450,140 @@ function reportStats() {
|
|
|
382
450
|
stats.lastReportTime = Date.now();
|
|
383
451
|
}
|
|
384
452
|
|
|
385
|
-
|
|
453
|
+
const DAILY_REPORT_INTERVAL = 24 * 3600_000; // 24 hours
|
|
386
454
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
455
|
+
function buildDailyReportEmbed() {
|
|
456
|
+
const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
|
|
457
|
+
|
|
458
|
+
// Top 3 suspects sorted by findings count descending
|
|
459
|
+
const top3 = dailyAlerts
|
|
460
|
+
.slice()
|
|
461
|
+
.sort((a, b) => b.findingsCount - a.findingsCount)
|
|
462
|
+
.slice(0, 3);
|
|
463
|
+
|
|
464
|
+
const top3Text = top3.length > 0
|
|
465
|
+
? top3.map((a, i) => `${i + 1}. **${a.ecosystem}/${a.name}@${a.version}** — ${a.findingsCount} finding(s)`).join('\n')
|
|
466
|
+
: 'None';
|
|
467
|
+
|
|
468
|
+
const now = new Date();
|
|
469
|
+
const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
embeds: [{
|
|
473
|
+
title: '\uD83D\uDCCA MUAD\'DIB Daily Report',
|
|
474
|
+
color: 0x3498db,
|
|
475
|
+
fields: [
|
|
476
|
+
{ name: 'Packages Scanned', value: `${stats.scanned}`, inline: true },
|
|
477
|
+
{ name: 'Clean', value: `${stats.clean}`, inline: true },
|
|
478
|
+
{ name: 'Suspects', value: `${stats.suspect}`, inline: true },
|
|
479
|
+
{ name: 'Errors', value: `${stats.errors}`, inline: true },
|
|
480
|
+
{ name: 'Avg Scan Time', value: `${avg}s/pkg`, inline: true },
|
|
481
|
+
{ name: 'Top Suspects', value: top3Text, inline: false }
|
|
482
|
+
],
|
|
483
|
+
footer: {
|
|
484
|
+
text: `MUAD'DIB - Daily summary | ${readableTime}`
|
|
485
|
+
},
|
|
486
|
+
timestamp: now.toISOString()
|
|
487
|
+
}]
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function sendDailyReport() {
|
|
492
|
+
const url = getWebhookUrl();
|
|
493
|
+
if (!url) return;
|
|
494
|
+
|
|
495
|
+
const payload = buildDailyReportEmbed();
|
|
393
496
|
try {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
497
|
+
await sendWebhook(url, payload, { rawPayload: true });
|
|
498
|
+
console.log('[MONITOR] Daily report sent');
|
|
499
|
+
} catch (err) {
|
|
500
|
+
console.error(`[MONITOR] Daily report webhook failed: ${err.message}`);
|
|
397
501
|
}
|
|
398
502
|
|
|
503
|
+
// Reset daily counters
|
|
504
|
+
stats.scanned = 0;
|
|
505
|
+
stats.clean = 0;
|
|
506
|
+
stats.suspect = 0;
|
|
507
|
+
stats.errors = 0;
|
|
508
|
+
stats.totalTimeMs = 0;
|
|
509
|
+
dailyAlerts.length = 0;
|
|
510
|
+
stats.lastDailyReportTime = Date.now();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- npm polling ---
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Parse npm RSS XML (same regex approach as parsePyPIRss).
|
|
517
|
+
* Returns array of package names from <title> tags inside <item>.
|
|
518
|
+
*/
|
|
519
|
+
function parseNpmRss(xml) {
|
|
399
520
|
const packages = [];
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
|
|
521
|
+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
522
|
+
let match;
|
|
523
|
+
while ((match = itemRegex.exec(xml)) !== null) {
|
|
524
|
+
const itemContent = match[1];
|
|
525
|
+
const titleMatch = itemContent.match(/<title>([^<]+)<\/title>/);
|
|
526
|
+
if (titleMatch) {
|
|
527
|
+
const title = titleMatch[1].trim();
|
|
528
|
+
const name = title.split(/\s+/)[0];
|
|
529
|
+
if (name) {
|
|
530
|
+
packages.push(name);
|
|
531
|
+
}
|
|
410
532
|
}
|
|
411
|
-
const pkg = data[key];
|
|
412
|
-
if (!pkg || typeof pkg !== 'object' || !pkg.name) continue;
|
|
413
|
-
const version = (pkg['dist-tags'] && pkg['dist-tags'].latest) || '';
|
|
414
|
-
const tarball = (pkg.dist && pkg.dist.tarball) || '';
|
|
415
|
-
packages.push({ name: pkg.name, version, tarball });
|
|
416
533
|
}
|
|
534
|
+
return packages;
|
|
535
|
+
}
|
|
417
536
|
|
|
418
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Fetch latest version metadata for an npm package.
|
|
539
|
+
* Returns { version, tarball } or null on failure.
|
|
540
|
+
*/
|
|
541
|
+
async function getNpmLatestTarball(packageName) {
|
|
542
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
|
|
543
|
+
const body = await httpsGet(url);
|
|
544
|
+
const data = JSON.parse(body);
|
|
545
|
+
const version = data.version || '';
|
|
546
|
+
const tarball = (data.dist && data.dist.tarball) || null;
|
|
547
|
+
return { version, tarball };
|
|
419
548
|
}
|
|
420
549
|
|
|
421
550
|
async function pollNpm(state) {
|
|
422
|
-
|
|
423
|
-
const startKey = state.npmLastKey || (Date.now() - 120_000);
|
|
424
|
-
const url = `https://registry.npmjs.org/-/all/since?stale=update_after&startkey=${startKey}`;
|
|
551
|
+
const url = 'https://registry.npmjs.org/-/rss?descending=true&limit=50';
|
|
425
552
|
|
|
426
553
|
try {
|
|
427
554
|
const body = await httpsGet(url);
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
555
|
+
const packages = parseNpmRss(body);
|
|
556
|
+
|
|
557
|
+
// Find new packages (those after the last seen one)
|
|
558
|
+
let newPackages;
|
|
559
|
+
if (!state.npmLastPackage) {
|
|
560
|
+
newPackages = packages;
|
|
561
|
+
} else {
|
|
562
|
+
const lastIdx = packages.indexOf(state.npmLastPackage);
|
|
563
|
+
if (lastIdx === -1) {
|
|
564
|
+
newPackages = packages;
|
|
565
|
+
} else {
|
|
566
|
+
newPackages = packages.slice(0, lastIdx);
|
|
439
567
|
}
|
|
440
568
|
}
|
|
441
569
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
570
|
+
for (const name of newPackages) {
|
|
571
|
+
console.log(`[MONITOR] New npm: ${name}`);
|
|
572
|
+
// Queue npm packages — tarball URL resolved during scan
|
|
573
|
+
scanQueue.push({
|
|
574
|
+
name,
|
|
575
|
+
version: '',
|
|
576
|
+
ecosystem: 'npm',
|
|
577
|
+
tarballUrl: null // resolved lazily via resolveTarballAndScan
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Remember the most recent package (first in RSS)
|
|
582
|
+
if (packages.length > 0) {
|
|
583
|
+
state.npmLastPackage = packages[0];
|
|
447
584
|
}
|
|
448
585
|
|
|
449
|
-
return
|
|
586
|
+
return newPackages.length;
|
|
450
587
|
} catch (err) {
|
|
451
588
|
console.error(`[MONITOR] npm poll error: ${err.message}`);
|
|
452
589
|
return 0;
|
|
@@ -550,7 +687,7 @@ async function startMonitor() {
|
|
|
550
687
|
}
|
|
551
688
|
|
|
552
689
|
const state = loadState();
|
|
553
|
-
console.log(`[MONITOR] State loaded — npm
|
|
690
|
+
console.log(`[MONITOR] State loaded — npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}`);
|
|
554
691
|
console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
|
|
555
692
|
|
|
556
693
|
let running = true;
|
|
@@ -582,6 +719,11 @@ async function startMonitor() {
|
|
|
582
719
|
if (Date.now() - stats.lastReportTime >= 3600_000) {
|
|
583
720
|
reportStats();
|
|
584
721
|
}
|
|
722
|
+
|
|
723
|
+
// Daily webhook report
|
|
724
|
+
if (Date.now() - stats.lastDailyReportTime >= DAILY_REPORT_INTERVAL) {
|
|
725
|
+
await sendDailyReport();
|
|
726
|
+
}
|
|
585
727
|
}
|
|
586
728
|
}
|
|
587
729
|
|
|
@@ -603,6 +745,21 @@ async function poll(state) {
|
|
|
603
745
|
* For PyPI packages, we need to fetch the JSON API to get the tarball URL.
|
|
604
746
|
*/
|
|
605
747
|
async function resolveTarballAndScan(item) {
|
|
748
|
+
if (item.ecosystem === 'npm' && !item.tarballUrl) {
|
|
749
|
+
try {
|
|
750
|
+
const npmInfo = await getNpmLatestTarball(item.name);
|
|
751
|
+
if (!npmInfo.tarball) {
|
|
752
|
+
console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on npm`);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
item.tarballUrl = npmInfo.tarball;
|
|
756
|
+
if (npmInfo.version) item.version = npmInfo.version;
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.error(`[MONITOR] ERROR resolving npm tarball for ${item.name}: ${err.message}`);
|
|
759
|
+
stats.errors++;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
606
763
|
if (item.ecosystem === 'pypi' && !item.tarballUrl) {
|
|
607
764
|
try {
|
|
608
765
|
const pypiInfo = await getPyPITarballUrl(item.name);
|
|
@@ -627,7 +784,7 @@ function sleep(ms) {
|
|
|
627
784
|
|
|
628
785
|
module.exports = {
|
|
629
786
|
startMonitor,
|
|
630
|
-
|
|
787
|
+
parseNpmRss,
|
|
631
788
|
parsePyPIRss,
|
|
632
789
|
loadState,
|
|
633
790
|
saveState,
|
|
@@ -636,6 +793,7 @@ module.exports = {
|
|
|
636
793
|
downloadToFile,
|
|
637
794
|
extractTarGz,
|
|
638
795
|
getNpmTarballUrl,
|
|
796
|
+
getNpmLatestTarball,
|
|
639
797
|
getPyPITarballUrl,
|
|
640
798
|
scanPackage,
|
|
641
799
|
scanQueue,
|
|
@@ -644,8 +802,11 @@ module.exports = {
|
|
|
644
802
|
timeoutPromise,
|
|
645
803
|
reportStats,
|
|
646
804
|
stats,
|
|
805
|
+
dailyAlerts,
|
|
647
806
|
resolveTarballAndScan,
|
|
648
807
|
MAX_TARBALL_SIZE,
|
|
808
|
+
KNOWN_BUNDLED_FILES,
|
|
809
|
+
isBundledToolingOnly,
|
|
649
810
|
isSandboxEnabled,
|
|
650
811
|
hasHighOrCritical,
|
|
651
812
|
get sandboxAvailable() { return sandboxAvailable; },
|
|
@@ -653,7 +814,12 @@ module.exports = {
|
|
|
653
814
|
getWebhookUrl,
|
|
654
815
|
shouldSendWebhook,
|
|
655
816
|
buildMonitorWebhookPayload,
|
|
656
|
-
trySendWebhook
|
|
817
|
+
trySendWebhook,
|
|
818
|
+
computeRiskLevel,
|
|
819
|
+
computeRiskScore,
|
|
820
|
+
buildDailyReportEmbed,
|
|
821
|
+
sendDailyReport,
|
|
822
|
+
DAILY_REPORT_INTERVAL
|
|
657
823
|
};
|
|
658
824
|
|
|
659
825
|
// Standalone entry point: node src/monitor.js
|
|
@@ -125,6 +125,13 @@ const PLAYBOOKS = {
|
|
|
125
125
|
ssh_key_read:
|
|
126
126
|
'Lecture des cles SSH. Regenerer immediatement toutes les cles: ssh-keygen -t ed25519',
|
|
127
127
|
|
|
128
|
+
python_reverse_shell:
|
|
129
|
+
'CRITIQUE: Reverse shell Python detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
130
|
+
perl_reverse_shell:
|
|
131
|
+
'CRITIQUE: Reverse shell Perl detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
132
|
+
fifo_reverse_shell:
|
|
133
|
+
'CRITIQUE: Reverse shell FIFO/named pipe detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
134
|
+
|
|
128
135
|
shai_hulud_backdoor:
|
|
129
136
|
'CRITIQUE: Backdoor Shai-Hulud dans GitHub Actions. Supprimer le workflow et auditer les runs precedents.',
|
|
130
137
|
|
package/src/rules/index.js
CHANGED
|
@@ -347,6 +347,33 @@ const RULES = {
|
|
|
347
347
|
references: ['https://attack.mitre.org/techniques/T1552/004/'],
|
|
348
348
|
mitre: 'T1552.004'
|
|
349
349
|
},
|
|
350
|
+
python_reverse_shell: {
|
|
351
|
+
id: 'MUADDIB-SHELL-010',
|
|
352
|
+
name: 'Python Reverse Shell',
|
|
353
|
+
severity: 'CRITICAL',
|
|
354
|
+
confidence: 'high',
|
|
355
|
+
description: 'Reverse shell via python -c import socket detecte',
|
|
356
|
+
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
357
|
+
mitre: 'T1059.006'
|
|
358
|
+
},
|
|
359
|
+
perl_reverse_shell: {
|
|
360
|
+
id: 'MUADDIB-SHELL-011',
|
|
361
|
+
name: 'Perl Reverse Shell',
|
|
362
|
+
severity: 'CRITICAL',
|
|
363
|
+
confidence: 'high',
|
|
364
|
+
description: 'Reverse shell via perl -e socket detecte',
|
|
365
|
+
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
366
|
+
mitre: 'T1059.006'
|
|
367
|
+
},
|
|
368
|
+
fifo_reverse_shell: {
|
|
369
|
+
id: 'MUADDIB-SHELL-012',
|
|
370
|
+
name: 'FIFO Reverse Shell',
|
|
371
|
+
severity: 'CRITICAL',
|
|
372
|
+
confidence: 'high',
|
|
373
|
+
description: 'Reverse shell via mkfifo /dev/tcp detecte',
|
|
374
|
+
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
375
|
+
mitre: 'T1059.004'
|
|
376
|
+
},
|
|
350
377
|
|
|
351
378
|
// AST additional patterns
|
|
352
379
|
possible_obfuscation: {
|