muaddib-scanner 2.4.15 → 2.4.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.4.15",
3
+ "version": "2.4.17",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -165,7 +165,7 @@ async function ensureIOCs() {
165
165
  return true;
166
166
  } catch (err) {
167
167
  process.stderr.write('[WARN] Could not download IOC database: ' + err.message + '\n');
168
- process.stderr.write('[WARN] Continuing with compact IOCs (limited PyPI coverage)\n');
168
+ process.stderr.write('[WARN] Continuing with YAML IOCs only (run "muaddib update" for full coverage)\n');
169
169
  return false;
170
170
  }
171
171
  }
@@ -0,0 +1 @@
1
+ fd1036e8a68e10f49e3b990772a755047d6d204c
package/src/webhook.js ADDED
@@ -0,0 +1,413 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+ const dns = require('dns');
4
+
5
+ // Allowed domains for webhooks (SSRF security)
6
+ const ALLOWED_WEBHOOK_DOMAINS = [
7
+ 'discord.com',
8
+ 'discordapp.com',
9
+ 'hooks.slack.com'
10
+ ];
11
+
12
+ // Private IP ranges for SSRF protection (checked against resolved IPs)
13
+ const PRIVATE_IP_PATTERNS = [
14
+ /^127\./,
15
+ /^10\./,
16
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
17
+ /^192\.168\./,
18
+ /^0\./,
19
+ /^169\.254\./,
20
+ /^::1$/,
21
+ /^::ffff:127\./,
22
+ /^fc00:/,
23
+ /^fe80:/
24
+ ];
25
+
26
+ /**
27
+ * Validates that a webhook URL is allowed
28
+ * @param {string} url - Webhook URL
29
+ * @returns {{valid: boolean, error?: string}} Validation result
30
+ */
31
+ function validateWebhookUrl(url) {
32
+ try {
33
+ const urlObj = new URL(url);
34
+
35
+ // Check protocol (HTTPS required, no exceptions)
36
+ if (urlObj.protocol !== 'https:') {
37
+ return { valid: false, error: 'HTTPS required for webhooks' };
38
+ }
39
+
40
+ // Check that the domain is allowed (no localhost exemption)
41
+ const hostname = urlObj.hostname.toLowerCase();
42
+ const isAllowed = ALLOWED_WEBHOOK_DOMAINS.some(domain =>
43
+ hostname === domain || hostname.endsWith('.' + domain)
44
+ );
45
+
46
+ if (!isAllowed) {
47
+ return { valid: false, error: `Domain not allowed: ${hostname}. Allowed domains: ${ALLOWED_WEBHOOK_DOMAINS.join(', ')}` };
48
+ }
49
+
50
+ // Block private IP addresses (SSRF) — checks literal IP hostnames
51
+ if (PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostname))) {
52
+ return { valid: false, error: 'Private IP addresses not allowed' };
53
+ }
54
+
55
+ return { valid: true };
56
+ } catch (e) {
57
+ return { valid: false, error: `Invalid URL: ${e.message}` };
58
+ }
59
+ }
60
+
61
+ async function sendWebhook(url, results, options = {}) {
62
+ // Validate URL before sending
63
+ const validation = validateWebhookUrl(url);
64
+ if (!validation.valid) {
65
+ throw new Error(`Webhook blocked: ${validation.error}`);
66
+ }
67
+
68
+ // DNS resolution check: verify ALL resolved IPs (IPv4 + IPv6) are not private (SSRF via DNS rebinding)
69
+ // Pin the first resolved IPv4 and use it for the actual connection (WHK-001)
70
+ const urlObj = new URL(url);
71
+ let resolvedAddress;
72
+ try {
73
+ const [ipv4Addresses, ipv6Addresses] = await Promise.all([
74
+ dns.promises.resolve4(urlObj.hostname).catch(() => []),
75
+ dns.promises.resolve6(urlObj.hostname).catch(() => [])
76
+ ]);
77
+ const allAddresses = [...ipv4Addresses, ...ipv6Addresses];
78
+ if (allAddresses.length === 0) {
79
+ throw new Error(`Webhook blocked: no DNS records found for ${urlObj.hostname}`);
80
+ }
81
+ for (const address of allAddresses) {
82
+ if (PRIVATE_IP_PATTERNS.some(pattern => pattern.test(address))) {
83
+ throw new Error(`Webhook blocked: hostname ${urlObj.hostname} resolves to private IP ${address}`);
84
+ }
85
+ }
86
+ resolvedAddress = ipv4Addresses[0] || null;
87
+ } catch (e) {
88
+ if (e.message.startsWith('Webhook blocked')) throw e;
89
+ throw new Error(`Webhook blocked: DNS resolution failed for ${urlObj.hostname}`);
90
+ }
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
+
97
+ const isDiscord = url.includes('discord.com');
98
+ const isSlack = url.includes('hooks.slack.com');
99
+
100
+ let payload;
101
+
102
+ if (isDiscord) {
103
+ payload = formatDiscord(results);
104
+ } else if (isSlack) {
105
+ payload = formatSlack(results);
106
+ } else {
107
+ payload = formatGeneric(results);
108
+ }
109
+
110
+ return send(url, payload, resolvedAddress);
111
+ }
112
+
113
+ function formatDiscord(results) {
114
+ const { summary, threats, target } = results;
115
+
116
+ const color = summary.riskLevel === 'CRITICAL' ? 0xe74c3c
117
+ : summary.riskLevel === 'HIGH' ? 0xe67e22
118
+ : summary.riskLevel === 'MEDIUM' ? 0xf1c40f
119
+ : summary.riskLevel === 'LOW' ? 0x3498db
120
+ : 0x2ecc71;
121
+
122
+ const emoji = summary.riskLevel === 'CRITICAL' ? '\uD83D\uDD34'
123
+ : summary.riskLevel === 'HIGH' ? '\uD83D\uDFE0'
124
+ : summary.riskLevel === 'MEDIUM' ? '\uD83D\uDFE1'
125
+ : '';
126
+
127
+ const criticalThreats = threats
128
+ .filter(t => t.severity === 'CRITICAL')
129
+ .slice(0, 5)
130
+ .map(t => `- ${t.message}`)
131
+ .join('\n');
132
+
133
+ const fields = [
134
+ {
135
+ name: 'Risk Score',
136
+ value: `**${summary.riskScore}/100** (${summary.riskLevel})`,
137
+ inline: true
138
+ },
139
+ {
140
+ name: 'Threats',
141
+ value: `${summary.critical} CRITICAL\n${summary.high} HIGH\n${summary.medium} MEDIUM`,
142
+ inline: true
143
+ },
144
+ {
145
+ name: 'Total',
146
+ value: `**${summary.total}** threat(s)`,
147
+ inline: true
148
+ }
149
+ ];
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
+
177
+ // Add critical threats if present
178
+ if (criticalThreats) {
179
+ fields.push({
180
+ name: 'Critical Threats',
181
+ value: criticalThreats || 'None',
182
+ inline: false
183
+ });
184
+ }
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
+
199
+ return {
200
+ embeds: [{
201
+ title: `${titlePrefix}MUAD'DIB Security Scan`,
202
+ description: `Scan of **${target}**`,
203
+ color: color,
204
+ fields: fields,
205
+ footer: {
206
+ text: `MUAD'DIB - Supply-chain threat detection | ${readableTime}`
207
+ },
208
+ timestamp: results.timestamp
209
+ }]
210
+ };
211
+ }
212
+
213
+ function formatSlack(results) {
214
+ const { summary, threats, target } = results;
215
+
216
+ const emoji = summary.riskLevel === 'CRITICAL' ? ':rotating_light:'
217
+ : summary.riskLevel === 'HIGH' ? ':warning:'
218
+ : summary.riskLevel === 'MEDIUM' ? ':large_yellow_circle:'
219
+ : summary.riskLevel === 'LOW' ? ':information_source:'
220
+ : ':white_check_mark:';
221
+
222
+ const criticalList = threats
223
+ .filter(t => t.severity === 'CRITICAL')
224
+ .slice(0, 5)
225
+ .map(t => `• ${t.message}`)
226
+ .join('\n');
227
+
228
+ const blocks = [
229
+ {
230
+ type: 'header',
231
+ text: {
232
+ type: 'plain_text',
233
+ text: `${emoji} MUAD'DIB Security Scan`
234
+ }
235
+ },
236
+ {
237
+ type: 'section',
238
+ fields: [
239
+ {
240
+ type: 'mrkdwn',
241
+ text: `*Target:*\n${target}`
242
+ },
243
+ {
244
+ type: 'mrkdwn',
245
+ text: `*Score:*\n${summary.riskScore}/100 (${summary.riskLevel})`
246
+ }
247
+ ]
248
+ },
249
+ {
250
+ type: 'section',
251
+ fields: [
252
+ {
253
+ type: 'mrkdwn',
254
+ text: `*CRITICAL:* ${summary.critical}`
255
+ },
256
+ {
257
+ type: 'mrkdwn',
258
+ text: `*HIGH:* ${summary.high}`
259
+ },
260
+ {
261
+ type: 'mrkdwn',
262
+ text: `*MEDIUM:* ${summary.medium}`
263
+ },
264
+ {
265
+ type: 'mrkdwn',
266
+ text: `*Total:* ${summary.total}`
267
+ }
268
+ ]
269
+ }
270
+ ];
271
+
272
+ // Add critical threats if present
273
+ if (criticalList) {
274
+ blocks.push({
275
+ type: 'section',
276
+ text: {
277
+ type: 'mrkdwn',
278
+ text: `*Critical Threats:*\n${criticalList}`
279
+ }
280
+ });
281
+ }
282
+
283
+ return { blocks };
284
+ }
285
+
286
+ function formatGeneric(results) {
287
+ return {
288
+ tool: 'MUADDIB',
289
+ target: results.target,
290
+ timestamp: results.timestamp,
291
+ summary: results.summary,
292
+ threats: results.threats.map(t => ({
293
+ type: t.type,
294
+ severity: t.severity,
295
+ message: t.message,
296
+ file: t.file
297
+ }))
298
+ };
299
+ }
300
+
301
+ const MAX_RESPONSE_SIZE = 1024 * 1024; // 1MB
302
+ const MAX_RETRIES = 3;
303
+ const BACKOFF_BASE_MS = 1000; // 1s, 2s, 4s exponential backoff
304
+ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
305
+
306
+ // Rate limiting: max 1 webhook per second (Discord limit is 30/min)
307
+ const RATE_LIMIT_MS = 1000;
308
+ let lastWebhookTime = 0;
309
+
310
+ function rateLimitDelay() {
311
+ const now = Date.now();
312
+ const elapsed = now - lastWebhookTime;
313
+ if (elapsed < RATE_LIMIT_MS) {
314
+ return RATE_LIMIT_MS - elapsed;
315
+ }
316
+ return 0;
317
+ }
318
+
319
+ function sleepMs(ms) {
320
+ return new Promise(resolve => setTimeout(resolve, ms));
321
+ }
322
+
323
+ function sendOnce(url, payload, resolvedAddress) {
324
+ return new Promise((resolve, reject) => {
325
+ const urlObj = new URL(url);
326
+ const protocol = urlObj.protocol === 'https:' ? https : http;
327
+
328
+ const body = JSON.stringify(payload);
329
+ const options = {
330
+ hostname: resolvedAddress || urlObj.hostname,
331
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
332
+ path: urlObj.pathname + urlObj.search,
333
+ method: 'POST',
334
+ headers: {
335
+ 'Content-Type': 'application/json',
336
+ 'Content-Length': Buffer.byteLength(body),
337
+ 'Host': urlObj.hostname
338
+ },
339
+ servername: urlObj.hostname
340
+ };
341
+
342
+ const req = protocol.request(options, (res) => {
343
+ let data = '';
344
+ let size = 0;
345
+ res.on('data', chunk => {
346
+ size += chunk.length;
347
+ if (size > MAX_RESPONSE_SIZE) {
348
+ res.destroy();
349
+ reject(new Error('Webhook response exceeded 1MB limit'));
350
+ return;
351
+ }
352
+ data += chunk;
353
+ });
354
+ res.on('end', () => {
355
+ if (res.statusCode >= 200 && res.statusCode < 300) {
356
+ resolve({ success: true, status: res.statusCode });
357
+ } else {
358
+ const err = new Error(`Webhook failed: HTTP ${res.statusCode}`);
359
+ err.statusCode = res.statusCode;
360
+ err.retryAfter = res.headers['retry-after'] || null;
361
+ reject(err);
362
+ }
363
+ });
364
+ });
365
+
366
+ req.setTimeout(10000, () => {
367
+ req.destroy();
368
+ reject(new Error('Webhook timeout after 10s'));
369
+ });
370
+ req.on('error', reject);
371
+ req.write(body);
372
+ req.end();
373
+ });
374
+ }
375
+
376
+ async function send(url, payload, resolvedAddress) {
377
+ // Rate limiting: wait if sending too fast
378
+ const delay = rateLimitDelay();
379
+ if (delay > 0) {
380
+ await sleepMs(delay);
381
+ }
382
+
383
+ let lastError;
384
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
385
+ try {
386
+ lastWebhookTime = Date.now();
387
+ const result = await sendOnce(url, payload, resolvedAddress);
388
+ return result;
389
+ } catch (err) {
390
+ lastError = err;
391
+ const isRetryable = err.statusCode && RETRYABLE_STATUS_CODES.has(err.statusCode);
392
+ if (!isRetryable || attempt >= MAX_RETRIES) {
393
+ throw err;
394
+ }
395
+ // Compute backoff: respect Retry-After header if present, else exponential
396
+ let backoffMs;
397
+ if (err.retryAfter) {
398
+ const parsed = parseInt(err.retryAfter, 10);
399
+ backoffMs = !isNaN(parsed) ? parsed * 1000 : BACKOFF_BASE_MS * Math.pow(2, attempt);
400
+ } else {
401
+ backoffMs = BACKOFF_BASE_MS * Math.pow(2, attempt);
402
+ }
403
+ console.error(`[WEBHOOK] HTTP ${err.statusCode}, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
404
+ await sleepMs(backoffMs);
405
+ }
406
+ }
407
+ throw lastError;
408
+ }
409
+
410
+ module.exports = {
411
+ sendWebhook, validateWebhookUrl, formatDiscord, formatSlack, formatGeneric,
412
+ MAX_RETRIES, BACKOFF_BASE_MS, RETRYABLE_STATUS_CODES, RATE_LIMIT_MS
413
+ };