muaddib-scanner 1.6.17 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.fr.md CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  Les attaques supply-chain npm et PyPI explosent. Shai-Hulud a compromis 25K+ repos en 2025. Les outils existants détectent, mais n'aident pas à répondre.
32
32
 
33
- MUAD'DIB détecte ET guide votre réponse.
33
+ MUAD'DIB combine analyse statique + analyse dynamique (sandbox Docker) pour détecter les menaces ET guider votre réponse.
34
34
 
35
35
  ---
36
36
 
@@ -196,7 +196,9 @@ muaddib sandbox <nom-package>
196
196
  muaddib sandbox <nom-package> --strict
197
197
  ```
198
198
 
199
- Analyse un package dans un container Docker isolé avec monitoring multi-couches :
199
+ Analyse dynamique : installe le package dans un container Docker isolé et surveille le comportement à l'exécution via strace, tcpdump et diff filesystem.
200
+
201
+ Monitoring multi-couches :
200
202
  - **Traçage système** (strace) : accès fichiers, spawn de processus, monitoring syscalls
201
203
  - **Capture réseau** (tcpdump) : résolutions DNS avec IPs résolues, requêtes HTTP (méthode, host, path, body), détection TLS SNI
202
204
  - **Diff filesystem** : snapshot avant/après install, détecte les fichiers créés dans des emplacements suspects
@@ -385,7 +387,7 @@ Détecte les patterns malveillants dans les fichiers YAML `.github/workflows/`,
385
387
  | Typosquatting (npm + PyPI) | T1195.002 | Levenshtein |
386
388
  | Supply chain compromise | T1195.002 | IOC matching |
387
389
  | Package PyPI malveillant | T1195.002 | IOC matching |
388
- | Analyse comportementale sandbox | Multiple | Docker + strace + tcpdump |
390
+ | Sandbox analyse dynamique | Multiple | Docker + strace + tcpdump |
389
391
 
390
392
  ---
391
393
 
package/README.md CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB detects AND guides your response.
33
+ MUAD'DIB combines static analysis + dynamic analysis (Docker sandbox) to detect threats AND guide your response.
34
34
 
35
35
  ---
36
36
 
@@ -196,7 +196,9 @@ muaddib sandbox <package-name>
196
196
  muaddib sandbox <package-name> --strict
197
197
  ```
198
198
 
199
- Analyzes a package in an isolated Docker container with multi-layer monitoring:
199
+ Dynamic analysis: installs the package in an isolated Docker container and monitors runtime behavior via strace, tcpdump, and filesystem diffing.
200
+
201
+ Multi-layer monitoring:
200
202
  - **System tracing** (strace): file access, process spawns, syscall monitoring
201
203
  - **Network capture** (tcpdump): DNS resolutions with resolved IPs, HTTP requests (method, host, path, body), TLS SNI detection
202
204
  - **Filesystem diff**: snapshot before/after install, detects files created in suspicious locations
@@ -281,7 +283,7 @@ Add to `.pre-commit-config.yaml`:
281
283
  ```yaml
282
284
  repos:
283
285
  - repo: https://github.com/DNSZLSK/muad-dib
284
- rev: v1.6.11
286
+ rev: v1.6.18
285
287
  hooks:
286
288
  - id: muaddib-scan # Scan all threats
287
289
  # - id: muaddib-diff # Or: only new threats
@@ -381,11 +383,12 @@ Detects malicious patterns in `.github/workflows/` YAML files, including Shai-Hu
381
383
  | Reverse shell | T1059.004 | Pattern |
382
384
  | Dead man's switch | T1485 | Pattern |
383
385
  | Obfuscated code | T1027 | Heuristics |
384
- | Shannon entropy analysis | T1027 | Entropy calculation |
386
+ | JS obfuscation patterns | T1027.002 | Pattern detection |
387
+ | Shannon entropy (strings) | T1027 | Entropy calculation |
385
388
  | Typosquatting (npm + PyPI) | T1195.002 | Levenshtein |
386
389
  | Supply chain compromise | T1195.002 | IOC matching |
387
390
  | PyPI malicious package | T1195.002 | IOC matching |
388
- | Sandbox behavioral analysis | Multiple | Docker + strace + tcpdump |
391
+ | Sandbox dynamic analysis | Multiple | Docker + strace + tcpdump |
389
392
 
390
393
  ---
391
394
 
@@ -494,12 +497,17 @@ MUAD'DIB Scanner
494
497
  | +-- Snyk Known Malware
495
498
  | +-- Static IOCs (Socket, Phylum)
496
499
  |
497
- +-- AST Parse (acorn)
498
- +-- Pattern Matching (shell, scripts)
499
- +-- Typosquat Detection (npm + PyPI, Levenshtein)
500
- +-- Python Scanner (requirements.txt, setup.py, pyproject.toml)
501
- +-- Shannon Entropy Analysis
502
- +-- GitHub Actions Scanner
500
+ +-- 12 Parallel Scanners
501
+ | +-- AST Parse (acorn) — eval/Function severity by argument type
502
+ | +-- Pattern Matching (shell, scripts)
503
+ | +-- Obfuscation Detection (skip .min.js, ignore hex/unicode alone)
504
+ | +-- Typosquat Detection (npm + PyPI, Levenshtein)
505
+ | +-- Python Scanner (requirements.txt, setup.py, pyproject.toml)
506
+ | +-- Shannon Entropy (string-level, 5.5 bits + 50 chars min)
507
+ | +-- JS Obfuscation Patterns (_0x* vars, encoded arrays, eval+entropy)
508
+ | +-- GitHub Actions Scanner
509
+ | +-- Package, Dependencies, Hash, npm-registry, Dataflow scanners
510
+ |
503
511
  +-- Paranoid Mode (ultra-strict)
504
512
  +-- Docker Sandbox (behavioral analysis, network capture)
505
513
  |
@@ -545,9 +553,10 @@ npm test
545
553
 
546
554
  ### Testing
547
555
 
548
- - **296 unit/integration tests** - 80% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
556
+ - **316 unit/integration tests** - 80% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
549
557
  - **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
550
558
  - **15 adversarial tests** - Simulated malicious packages, 15/15 detection rate
559
+ - **False positive validation** - 0 false positives on express, lodash, axios, react
551
560
  - **ESLint security audit** - `eslint-plugin-security` with 14 rules enabled
552
561
 
553
562
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "1.6.17",
3
+ "version": "1.7.0",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/monitor.js ADDED
@@ -0,0 +1,665 @@
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync } = require('child_process');
6
+ const { run } = require('./index.js');
7
+ const { runSandbox, isDockerAvailable } = require('./sandbox.js');
8
+ const { sendWebhook } = require('./webhook.js');
9
+
10
+ const STATE_FILE = path.join(__dirname, '..', 'data', 'monitor-state.json');
11
+ const ALERTS_FILE = path.join(__dirname, '..', 'data', 'monitor-alerts.json');
12
+ const POLL_INTERVAL = 60_000;
13
+ const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
14
+ const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
15
+
16
+ // --- Stats counters ---
17
+
18
+ const stats = {
19
+ scanned: 0,
20
+ clean: 0,
21
+ suspect: 0,
22
+ errors: 0,
23
+ totalTimeMs: 0,
24
+ lastReportTime: Date.now()
25
+ };
26
+
27
+ // --- Scan queue (FIFO, sequential) ---
28
+
29
+ const scanQueue = [];
30
+
31
+ // --- Sandbox integration ---
32
+
33
+ let sandboxAvailable = false;
34
+
35
+ function isSandboxEnabled() {
36
+ const env = process.env.MUADDIB_MONITOR_SANDBOX;
37
+ if (env !== undefined && env.toLowerCase() === 'false') return false;
38
+ return true;
39
+ }
40
+
41
+ function hasHighOrCritical(result) {
42
+ return result.summary.critical > 0 || result.summary.high > 0;
43
+ }
44
+
45
+ // --- Webhook alerting ---
46
+
47
+ function getWebhookUrl() {
48
+ return process.env.MUADDIB_WEBHOOK_URL || null;
49
+ }
50
+
51
+ function shouldSendWebhook(result, sandboxResult) {
52
+ if (!getWebhookUrl()) return false;
53
+ if (hasHighOrCritical(result)) return true;
54
+ if (sandboxResult && sandboxResult.score > 50) return true;
55
+ return false;
56
+ }
57
+
58
+ function buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult) {
59
+ const payload = {
60
+ event: 'malicious_package',
61
+ package: name,
62
+ version,
63
+ ecosystem,
64
+ timestamp: new Date().toISOString(),
65
+ findings: result.threats.map(t => ({
66
+ rule: t.rule_id || t.type,
67
+ severity: t.severity
68
+ }))
69
+ };
70
+ if (sandboxResult && sandboxResult.score > 0) {
71
+ payload.sandbox = {
72
+ score: sandboxResult.score,
73
+ severity: sandboxResult.severity
74
+ };
75
+ }
76
+ return payload;
77
+ }
78
+
79
+ async function trySendWebhook(name, version, ecosystem, result, sandboxResult) {
80
+ if (!shouldSendWebhook(result, sandboxResult)) return;
81
+ const url = getWebhookUrl();
82
+ const payload = buildMonitorWebhookPayload(name, version, ecosystem, result, sandboxResult);
83
+ // sendWebhook expects a results-like object; wrap payload for formatGeneric
84
+ const webhookData = {
85
+ target: `${ecosystem}/${name}@${version}`,
86
+ timestamp: payload.timestamp,
87
+ summary: result.summary,
88
+ threats: result.threats
89
+ };
90
+ try {
91
+ await sendWebhook(url, webhookData);
92
+ console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
93
+ } catch (err) {
94
+ console.error(`[MONITOR] Webhook failed for ${name}@${version}: ${err.message}`);
95
+ }
96
+ }
97
+
98
+ // --- State persistence ---
99
+
100
+ function loadState() {
101
+ try {
102
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
103
+ const state = JSON.parse(raw);
104
+ return {
105
+ npmLastKey: typeof state.npmLastKey === 'number' ? state.npmLastKey : 0,
106
+ pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
107
+ };
108
+ } catch {
109
+ return { npmLastKey: 0, pypiLastPackage: '' };
110
+ }
111
+ }
112
+
113
+ function saveState(state) {
114
+ try {
115
+ const dir = path.dirname(STATE_FILE);
116
+ if (!fs.existsSync(dir)) {
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ }
119
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
120
+ } catch (err) {
121
+ console.error(`[MONITOR] Failed to save state: ${err.message}`);
122
+ }
123
+ }
124
+
125
+ // --- HTTP helpers ---
126
+
127
+ function httpsGet(url, timeoutMs = 30_000) {
128
+ return new Promise((resolve, reject) => {
129
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
130
+ if (res.statusCode === 301 || res.statusCode === 302) {
131
+ res.resume();
132
+ const location = res.headers.location;
133
+ if (!location) return reject(new Error(`Redirect without Location for ${url}`));
134
+ return httpsGet(location, timeoutMs).then(resolve, reject);
135
+ }
136
+ if (res.statusCode < 200 || res.statusCode >= 300) {
137
+ res.resume();
138
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
139
+ }
140
+ const chunks = [];
141
+ res.on('data', (chunk) => chunks.push(chunk));
142
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
143
+ res.on('error', reject);
144
+ });
145
+ req.on('error', reject);
146
+ req.on('timeout', () => {
147
+ req.destroy();
148
+ reject(new Error(`Timeout for ${url}`));
149
+ });
150
+ });
151
+ }
152
+
153
+ // --- Download & extraction helpers ---
154
+
155
+ function downloadToFile(url, destPath, timeoutMs = 30_000) {
156
+ return new Promise((resolve, reject) => {
157
+ const doRequest = (requestUrl) => {
158
+ const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
159
+ if (res.statusCode === 301 || res.statusCode === 302) {
160
+ res.resume();
161
+ const location = res.headers.location;
162
+ if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
163
+ return doRequest(location);
164
+ }
165
+ if (res.statusCode < 200 || res.statusCode >= 300) {
166
+ res.resume();
167
+ return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
168
+ }
169
+ const contentLength = parseInt(res.headers['content-length'], 10);
170
+ if (contentLength && contentLength > MAX_TARBALL_SIZE) {
171
+ res.resume();
172
+ return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
173
+ }
174
+ const fileStream = fs.createWriteStream(destPath);
175
+ let downloadedBytes = 0;
176
+ res.on('data', (chunk) => {
177
+ downloadedBytes += chunk.length;
178
+ if (downloadedBytes > MAX_TARBALL_SIZE) {
179
+ res.destroy();
180
+ fileStream.destroy();
181
+ try { fs.unlinkSync(destPath); } catch {}
182
+ reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
183
+ }
184
+ });
185
+ res.pipe(fileStream);
186
+ fileStream.on('finish', () => resolve(downloadedBytes));
187
+ fileStream.on('error', (err) => {
188
+ try { fs.unlinkSync(destPath); } catch {}
189
+ reject(err);
190
+ });
191
+ res.on('error', (err) => {
192
+ fileStream.destroy();
193
+ try { fs.unlinkSync(destPath); } catch {}
194
+ reject(err);
195
+ });
196
+ });
197
+ req.on('error', reject);
198
+ req.on('timeout', () => {
199
+ req.destroy();
200
+ reject(new Error(`Timeout downloading ${requestUrl}`));
201
+ });
202
+ };
203
+ doRequest(url);
204
+ });
205
+ }
206
+
207
+ function extractTarGz(tgzPath, destDir) {
208
+ // Use --force-local on Windows so tar doesn't interpret C: as a remote host
209
+ const forceLocal = process.platform === 'win32' ? ' --force-local' : '';
210
+ execSync(`tar xzf "${tgzPath}"${forceLocal} -C "${destDir}"`, { timeout: 60_000, stdio: 'pipe' });
211
+ // npm tarballs extract into a package/ subdirectory; detect it
212
+ const packageSubdir = path.join(destDir, 'package');
213
+ if (fs.existsSync(packageSubdir) && fs.statSync(packageSubdir).isDirectory()) {
214
+ return packageSubdir;
215
+ }
216
+ // Otherwise return destDir itself (PyPI sdists vary)
217
+ const entries = fs.readdirSync(destDir);
218
+ if (entries.length === 1) {
219
+ const single = path.join(destDir, entries[0]);
220
+ if (fs.statSync(single).isDirectory()) return single;
221
+ }
222
+ return destDir;
223
+ }
224
+
225
+ // --- Tarball URL helpers ---
226
+
227
+ function getNpmTarballUrl(pkgData) {
228
+ return (pkgData.dist && pkgData.dist.tarball) || null;
229
+ }
230
+
231
+ async function getPyPITarballUrl(packageName) {
232
+ const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
233
+ const body = await httpsGet(url);
234
+ const data = JSON.parse(body);
235
+ const version = (data.info && data.info.version) || '';
236
+ const urls = data.urls || [];
237
+ // Prefer sdist (.tar.gz)
238
+ const sdist = urls.find(u => u.packagetype === 'sdist' && u.url);
239
+ if (sdist) return { url: sdist.url, version };
240
+ // Fallback: any .tar.gz
241
+ const tarGz = urls.find(u => u.url && u.url.endsWith('.tar.gz'));
242
+ if (tarGz) return { url: tarGz.url, version };
243
+ // Fallback: first available file
244
+ if (urls.length > 0 && urls[0].url) return { url: urls[0].url, version };
245
+ return { url: null, version };
246
+ }
247
+
248
+ // --- Alerts persistence ---
249
+
250
+ function appendAlert(alert) {
251
+ try {
252
+ const dir = path.dirname(ALERTS_FILE);
253
+ if (!fs.existsSync(dir)) {
254
+ fs.mkdirSync(dir, { recursive: true });
255
+ }
256
+ let alerts = [];
257
+ try {
258
+ alerts = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
259
+ } catch {}
260
+ alerts.push(alert);
261
+ fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2), 'utf8');
262
+ } catch (err) {
263
+ console.error(`[MONITOR] Failed to save alert: ${err.message}`);
264
+ }
265
+ }
266
+
267
+ // --- Package scanning ---
268
+
269
+ async function scanPackage(name, version, ecosystem, tarballUrl) {
270
+ const startTime = Date.now();
271
+ const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
272
+ if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
273
+ const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${name.replace(/\//g, '_')}-`));
274
+
275
+ try {
276
+ const tgzPath = path.join(tmpDir, 'package.tar.gz');
277
+ await downloadToFile(tarballUrl, tgzPath);
278
+
279
+ // Check downloaded size
280
+ const fileSize = fs.statSync(tgzPath).size;
281
+ if (fileSize > MAX_TARBALL_SIZE) {
282
+ console.log(`[MONITOR] SKIP: ${name}@${version} — tarball too large (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
283
+ stats.scanned++;
284
+ return;
285
+ }
286
+
287
+ const extractedDir = extractTarGz(tgzPath, tmpDir);
288
+ const result = await run(extractedDir, { _capture: true });
289
+
290
+ if (result.summary.total === 0) {
291
+ stats.scanned++;
292
+ const elapsed = Date.now() - startTime;
293
+ stats.totalTimeMs += elapsed;
294
+ stats.clean++;
295
+ console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
296
+ } else {
297
+ stats.suspect++;
298
+ const counts = [];
299
+ if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
300
+ if (result.summary.high > 0) counts.push(`${result.summary.high} HIGH`);
301
+ if (result.summary.medium > 0) counts.push(`${result.summary.medium} MEDIUM`);
302
+ 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
+
317
+ stats.scanned++;
318
+ const elapsed = Date.now() - startTime;
319
+ stats.totalTimeMs += elapsed;
320
+ console.log(`[MONITOR] ${name}@${version} total time: ${(elapsed / 1000).toFixed(1)}s`);
321
+
322
+ const alert = {
323
+ timestamp: new Date().toISOString(),
324
+ name,
325
+ version,
326
+ ecosystem,
327
+ findings: result.threats.map(t => ({
328
+ rule: t.rule_id || t.type,
329
+ severity: t.severity,
330
+ file: t.file
331
+ }))
332
+ };
333
+
334
+ if (sandboxResult && sandboxResult.score > 0) {
335
+ alert.sandbox = {
336
+ score: sandboxResult.score,
337
+ severity: sandboxResult.severity,
338
+ findings: sandboxResult.findings
339
+ };
340
+ }
341
+
342
+ appendAlert(alert);
343
+ await trySendWebhook(name, version, ecosystem, result, sandboxResult);
344
+ }
345
+ } catch (err) {
346
+ stats.errors++;
347
+ stats.scanned++;
348
+ stats.totalTimeMs += Date.now() - startTime;
349
+ console.error(`[MONITOR] ERROR scanning ${name}@${version}: ${err.message}`);
350
+ } finally {
351
+ // Cleanup temp dir
352
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
353
+ }
354
+ }
355
+
356
+ function timeoutPromise(ms) {
357
+ return new Promise((_, reject) => {
358
+ setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
359
+ });
360
+ }
361
+
362
+ async function processQueue() {
363
+ while (scanQueue.length > 0) {
364
+ const item = scanQueue.shift();
365
+ try {
366
+ await Promise.race([
367
+ scanPackage(item.name, item.version, item.ecosystem, item.tarballUrl),
368
+ timeoutPromise(SCAN_TIMEOUT_MS)
369
+ ]);
370
+ } catch (err) {
371
+ stats.errors++;
372
+ console.error(`[MONITOR] Queue error for ${item.name}: ${err.message}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ // --- Stats reporting ---
378
+
379
+ function reportStats() {
380
+ const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
381
+ console.log(`[MONITOR] Stats: ${stats.scanned} scanned, ${stats.clean} clean, ${stats.suspect} suspect, ${stats.errors} error${stats.errors !== 1 ? 's' : ''}, avg ${avg}s/pkg`);
382
+ stats.lastReportTime = Date.now();
383
+ }
384
+
385
+ // --- npm polling ---
386
+
387
+ /**
388
+ * Parse the npm /-/all/since response.
389
+ * Returns array of { name, version, tarball } and the max timestamp seen.
390
+ */
391
+ function parseNpmResponse(body) {
392
+ let data;
393
+ try {
394
+ data = JSON.parse(body);
395
+ } catch {
396
+ return { packages: [], maxTimestamp: 0 };
397
+ }
398
+
399
+ const packages = [];
400
+ let maxTimestamp = 0;
401
+
402
+ // The response is an object keyed by package name.
403
+ // Each value has name, "dist-tags", time, etc.
404
+ // There is a special "_updated" key with the latest timestamp.
405
+ for (const key of Object.keys(data)) {
406
+ if (key === '_updated') {
407
+ const ts = Number(data[key]);
408
+ if (ts > maxTimestamp) maxTimestamp = ts;
409
+ continue;
410
+ }
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
+ }
417
+
418
+ return { packages, maxTimestamp };
419
+ }
420
+
421
+ async function pollNpm(state) {
422
+ // First run: use "now - 120s" so we don't get the entire registry
423
+ const startKey = state.npmLastKey || (Date.now() - 120_000);
424
+ const url = `https://registry.npmjs.org/-/all/since?stale=update_after&startkey=${startKey}`;
425
+
426
+ try {
427
+ const body = await httpsGet(url);
428
+ const { packages, maxTimestamp } = parseNpmResponse(body);
429
+
430
+ for (const pkg of packages) {
431
+ console.log(`[MONITOR] New npm: ${pkg.name}@${pkg.version}`);
432
+ if (pkg.tarball) {
433
+ scanQueue.push({
434
+ name: pkg.name,
435
+ version: pkg.version,
436
+ ecosystem: 'npm',
437
+ tarballUrl: pkg.tarball
438
+ });
439
+ }
440
+ }
441
+
442
+ if (maxTimestamp > 0) {
443
+ state.npmLastKey = maxTimestamp;
444
+ } else if (packages.length > 0) {
445
+ // Fallback: advance timestamp to now
446
+ state.npmLastKey = Date.now();
447
+ }
448
+
449
+ return packages.length;
450
+ } catch (err) {
451
+ console.error(`[MONITOR] npm poll error: ${err.message}`);
452
+ return 0;
453
+ }
454
+ }
455
+
456
+ // --- PyPI polling ---
457
+
458
+ /**
459
+ * Parse PyPI RSS XML (simple regex, no deps).
460
+ * Returns array of package names from <title> tags inside <item>.
461
+ */
462
+ function parsePyPIRss(xml) {
463
+ const packages = [];
464
+ // Match each <item>...</item> block
465
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
466
+ let match;
467
+ while ((match = itemRegex.exec(xml)) !== null) {
468
+ const itemContent = match[1];
469
+ // Extract <title>...</title> inside item
470
+ const titleMatch = itemContent.match(/<title>([^<]+)<\/title>/);
471
+ if (titleMatch) {
472
+ // Title format is usually "package-name 1.0.0"
473
+ const title = titleMatch[1].trim();
474
+ // Extract just the package name (first word before space or version)
475
+ const name = title.split(/\s+/)[0];
476
+ if (name) {
477
+ packages.push(name);
478
+ }
479
+ }
480
+ }
481
+ return packages;
482
+ }
483
+
484
+ async function pollPyPI(state) {
485
+ const url = 'https://pypi.org/rss/packages.xml';
486
+
487
+ try {
488
+ const body = await httpsGet(url);
489
+ const packages = parsePyPIRss(body);
490
+
491
+ // Find new packages (those after the last seen one)
492
+ let newPackages;
493
+ if (!state.pypiLastPackage) {
494
+ // First run: log all and remember the first one
495
+ newPackages = packages;
496
+ } else {
497
+ const lastIdx = packages.indexOf(state.pypiLastPackage);
498
+ if (lastIdx === -1) {
499
+ // Last seen not in feed — all are new
500
+ newPackages = packages;
501
+ } else {
502
+ // Items before lastIdx are newer (RSS is newest-first)
503
+ newPackages = packages.slice(0, lastIdx);
504
+ }
505
+ }
506
+
507
+ for (const name of newPackages) {
508
+ console.log(`[MONITOR] New pypi: ${name}`);
509
+ // Queue PyPI packages — tarball URL resolved during scan
510
+ scanQueue.push({
511
+ name,
512
+ version: '',
513
+ ecosystem: 'pypi',
514
+ tarballUrl: null // resolved lazily in scanPackage wrapper
515
+ });
516
+ }
517
+
518
+ // Remember the most recent package (first in RSS)
519
+ if (packages.length > 0) {
520
+ state.pypiLastPackage = packages[0];
521
+ }
522
+
523
+ return newPackages.length;
524
+ } catch (err) {
525
+ console.error(`[MONITOR] PyPI poll error: ${err.message}`);
526
+ return 0;
527
+ }
528
+ }
529
+
530
+ // --- Main loop ---
531
+
532
+ async function startMonitor() {
533
+ console.log(`
534
+ ╔════════════════════════════════════════════╗
535
+ ║ MUAD'DIB - Registry Monitor ║
536
+ ║ Scanning npm + PyPI new packages ║
537
+ ╚════════════════════════════════════════════╝
538
+ `);
539
+
540
+ // Check sandbox availability
541
+ if (isSandboxEnabled()) {
542
+ sandboxAvailable = isDockerAvailable();
543
+ if (sandboxAvailable) {
544
+ console.log('[MONITOR] Docker detected — sandbox enabled for HIGH/CRITICAL findings');
545
+ } else {
546
+ console.log('[MONITOR] WARNING: Docker not available — running static analysis only');
547
+ }
548
+ } else {
549
+ console.log('[MONITOR] Sandbox disabled (MUADDIB_MONITOR_SANDBOX=false)');
550
+ }
551
+
552
+ const state = loadState();
553
+ console.log(`[MONITOR] State loaded — npm startKey: ${state.npmLastKey || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}`);
554
+ console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
555
+
556
+ let running = true;
557
+
558
+ // SIGINT: save state and exit
559
+ process.on('SIGINT', () => {
560
+ console.log('\n[MONITOR] Stopping — saving state...');
561
+ saveState(state);
562
+ reportStats();
563
+ console.log('[MONITOR] State saved. Bye!');
564
+ running = false;
565
+ process.exit(0);
566
+ });
567
+
568
+ // Initial poll + scan
569
+ await poll(state);
570
+ saveState(state);
571
+ await processQueue();
572
+
573
+ // Interval polling
574
+ while (running) {
575
+ await sleep(POLL_INTERVAL);
576
+ if (!running) break;
577
+ await poll(state);
578
+ saveState(state);
579
+ await processQueue();
580
+
581
+ // Hourly stats report
582
+ if (Date.now() - stats.lastReportTime >= 3600_000) {
583
+ reportStats();
584
+ }
585
+ }
586
+ }
587
+
588
+ async function poll(state) {
589
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
590
+ console.log(`[MONITOR] ${timestamp} — polling registries...`);
591
+
592
+ const [npmCount, pypiCount] = await Promise.all([
593
+ pollNpm(state),
594
+ pollPyPI(state)
595
+ ]);
596
+
597
+ console.log(`[MONITOR] Found ${npmCount} npm + ${pypiCount} PyPI new packages`);
598
+ }
599
+
600
+ /**
601
+ * Wrapper to resolve PyPI tarball URLs before scanning.
602
+ * For npm packages, tarballUrl is already set from the registry response.
603
+ * For PyPI packages, we need to fetch the JSON API to get the tarball URL.
604
+ */
605
+ async function resolveTarballAndScan(item) {
606
+ if (item.ecosystem === 'pypi' && !item.tarballUrl) {
607
+ try {
608
+ const pypiInfo = await getPyPITarballUrl(item.name);
609
+ if (!pypiInfo.url) {
610
+ console.log(`[MONITOR] SKIP: ${item.name} — no tarball URL found on PyPI`);
611
+ return;
612
+ }
613
+ item.tarballUrl = pypiInfo.url;
614
+ if (pypiInfo.version) item.version = pypiInfo.version;
615
+ } catch (err) {
616
+ console.error(`[MONITOR] ERROR resolving PyPI tarball for ${item.name}: ${err.message}`);
617
+ stats.errors++;
618
+ return;
619
+ }
620
+ }
621
+ await scanPackage(item.name, item.version, item.ecosystem, item.tarballUrl);
622
+ }
623
+
624
+ function sleep(ms) {
625
+ return new Promise((resolve) => setTimeout(resolve, ms));
626
+ }
627
+
628
+ module.exports = {
629
+ startMonitor,
630
+ parseNpmResponse,
631
+ parsePyPIRss,
632
+ loadState,
633
+ saveState,
634
+ STATE_FILE,
635
+ ALERTS_FILE,
636
+ downloadToFile,
637
+ extractTarGz,
638
+ getNpmTarballUrl,
639
+ getPyPITarballUrl,
640
+ scanPackage,
641
+ scanQueue,
642
+ processQueue,
643
+ appendAlert,
644
+ timeoutPromise,
645
+ reportStats,
646
+ stats,
647
+ resolveTarballAndScan,
648
+ MAX_TARBALL_SIZE,
649
+ isSandboxEnabled,
650
+ hasHighOrCritical,
651
+ get sandboxAvailable() { return sandboxAvailable; },
652
+ set sandboxAvailable(v) { sandboxAvailable = v; },
653
+ getWebhookUrl,
654
+ shouldSendWebhook,
655
+ buildMonitorWebhookPayload,
656
+ trySendWebhook
657
+ };
658
+
659
+ // Standalone entry point: node src/monitor.js
660
+ if (require.main === module) {
661
+ startMonitor().catch(err => {
662
+ console.error('[MONITOR] Fatal error:', err.message);
663
+ process.exit(1);
664
+ });
665
+ }