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