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 +5 -3
- package/README.md +21 -12
- package/package.json +1 -1
- package/src/monitor.js +665 -0
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
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
|
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
|
|
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
|
-
+--
|
|
498
|
-
+--
|
|
499
|
-
+--
|
|
500
|
-
+--
|
|
501
|
-
+--
|
|
502
|
-
+--
|
|
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
|
-
- **
|
|
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
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
|
+
}
|