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 +1 -1
- package/src/ioc/bootstrap.js +1 -1
- package/src/ioc/data/.ossf-tree-sha +1 -0
- package/src/webhook.js +413 -0
package/package.json
CHANGED
package/src/ioc/bootstrap.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|