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/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 };
@@ -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
- };