muaddib-scanner 2.3.2 → 2.3.3
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.md +1 -1
- package/bin/muaddib.js +2 -1
- package/package.json +1 -1
- package/README.fr.md +0 -819
- package/assets/muaddibLogo.png +0 -0
- package/src/commands/evaluate.js +0 -484
- package/src/daemon.js +0 -178
- package/src/monitor.js +0 -1880
- package/src/serve.js +0 -58
- package/src/threat-feed.js +0 -95
- package/src/webhook.js +0 -413
package/src/serve.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const http = require('http');
|
|
4
|
-
const url = require('url');
|
|
5
|
-
const { getFeed } = require('./threat-feed.js');
|
|
6
|
-
const pkg = require('../package.json');
|
|
7
|
-
|
|
8
|
-
const SECURITY_HEADERS = {
|
|
9
|
-
'Content-Type': 'application/json',
|
|
10
|
-
'X-Content-Type-Options': 'nosniff',
|
|
11
|
-
'Cache-Control': 'no-store'
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function sendJson(res, statusCode, data) {
|
|
15
|
-
res.writeHead(statusCode, SECURITY_HEADERS);
|
|
16
|
-
res.end(JSON.stringify(data));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function startServer(options = {}) {
|
|
20
|
-
const port = options.port || 3000;
|
|
21
|
-
|
|
22
|
-
const server = http.createServer((req, res) => {
|
|
23
|
-
if (req.method !== 'GET') {
|
|
24
|
-
sendJson(res, 405, { error: 'Method not allowed. Use GET.' });
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const parsed = url.parse(req.url, true);
|
|
29
|
-
const pathname = parsed.pathname;
|
|
30
|
-
|
|
31
|
-
if (pathname === '/feed') {
|
|
32
|
-
const query = parsed.query;
|
|
33
|
-
const feedOptions = {};
|
|
34
|
-
if (query.limit) {
|
|
35
|
-
const n = parseInt(query.limit, 10);
|
|
36
|
-
if (!isNaN(n) && n > 0) feedOptions.limit = n;
|
|
37
|
-
}
|
|
38
|
-
if (query.severity) feedOptions.severity = query.severity;
|
|
39
|
-
if (query.since) feedOptions.since = query.since;
|
|
40
|
-
|
|
41
|
-
const result = getFeed(feedOptions);
|
|
42
|
-
sendJson(res, 200, result);
|
|
43
|
-
} else if (pathname === '/health') {
|
|
44
|
-
sendJson(res, 200, { status: 'ok', version: pkg.version });
|
|
45
|
-
} else {
|
|
46
|
-
sendJson(res, 404, { error: 'Not found. Available: GET /feed, GET /health' });
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
server.listen(port, '127.0.0.1', () => {
|
|
51
|
-
console.log(`[SERVE] Threat feed server listening on http://127.0.0.1:${port}`);
|
|
52
|
-
console.log(`[SERVE] Endpoints: GET /feed, GET /health`);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
return server;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
module.exports = { startServer };
|
package/src/threat-feed.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { loadDetections } = require('./monitor.js');
|
|
4
|
-
const { getRule } = require('./rules/index.js');
|
|
5
|
-
const pkg = require('../package.json');
|
|
6
|
-
|
|
7
|
-
const SEVERITY_WEIGHTS = {
|
|
8
|
-
CRITICAL: 25,
|
|
9
|
-
HIGH: 10,
|
|
10
|
-
MEDIUM: 3,
|
|
11
|
-
LOW: 1
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Compute a score and breakdown for a single detection.
|
|
16
|
-
* Each finding type is mapped to a rule, and the severity weight is summed.
|
|
17
|
-
*/
|
|
18
|
-
function computeDetectionScore(detection) {
|
|
19
|
-
const breakdown = [];
|
|
20
|
-
let total = 0;
|
|
21
|
-
|
|
22
|
-
const findings = detection.findings || [];
|
|
23
|
-
for (const findingType of findings) {
|
|
24
|
-
const rule = getRule(findingType);
|
|
25
|
-
let severity;
|
|
26
|
-
if (rule.id !== 'MUADDIB-UNK-001') {
|
|
27
|
-
severity = rule.severity.toUpperCase();
|
|
28
|
-
} else {
|
|
29
|
-
severity = (detection.severity || 'MEDIUM').toUpperCase();
|
|
30
|
-
}
|
|
31
|
-
const points = SEVERITY_WEIGHTS[severity] || SEVERITY_WEIGHTS.MEDIUM;
|
|
32
|
-
breakdown.push({
|
|
33
|
-
type: findingType,
|
|
34
|
-
points,
|
|
35
|
-
rule: rule.id,
|
|
36
|
-
severity
|
|
37
|
-
});
|
|
38
|
-
total += points;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
breakdown.sort((a, b) => b.points - a.points);
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
score: Math.min(total, 100),
|
|
45
|
-
breakdown
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get the threat feed: load detections, filter, enrich with scores.
|
|
51
|
-
*/
|
|
52
|
-
function getFeed(options = {}) {
|
|
53
|
-
const limit = options.limit || 50;
|
|
54
|
-
const severityFilter = options.severity ? options.severity.toUpperCase() : null;
|
|
55
|
-
const sinceFilter = options.since ? new Date(options.since) : null;
|
|
56
|
-
|
|
57
|
-
const data = loadDetections();
|
|
58
|
-
let detections = data.detections || [];
|
|
59
|
-
|
|
60
|
-
// Filter by severity
|
|
61
|
-
if (severityFilter) {
|
|
62
|
-
detections = detections.filter(d => (d.severity || '').toUpperCase() === severityFilter);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Filter by since date
|
|
66
|
-
if (sinceFilter && !isNaN(sinceFilter.getTime())) {
|
|
67
|
-
detections = detections.filter(d => new Date(d.first_seen_at) >= sinceFilter);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Newest first, then limit
|
|
71
|
-
detections = detections.slice().reverse().slice(0, limit);
|
|
72
|
-
|
|
73
|
-
// Enrich with scores
|
|
74
|
-
const feed = detections.map(d => {
|
|
75
|
-
const { score, breakdown } = computeDetectionScore(d);
|
|
76
|
-
return {
|
|
77
|
-
package: d.package,
|
|
78
|
-
version: d.version,
|
|
79
|
-
ecosystem: d.ecosystem,
|
|
80
|
-
severity: d.severity,
|
|
81
|
-
first_seen: d.first_seen_at,
|
|
82
|
-
findings: d.findings,
|
|
83
|
-
score,
|
|
84
|
-
breakdown
|
|
85
|
-
};
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
generated_at: new Date().toISOString(),
|
|
90
|
-
version: pkg.version,
|
|
91
|
-
feed
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
module.exports = { getFeed, computeDetectionScore, SEVERITY_WEIGHTS };
|
package/src/webhook.js
DELETED
|
@@ -1,413 +0,0 @@
|
|
|
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
|
-
};
|