muaddib-scanner 2.11.0 → 2.11.1
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 +3 -3
- package/src/integrations/api-ingest.js +222 -0
- package/src/monitor/webhook.js +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@eslint/js": "10.0.1",
|
|
58
|
-
"eslint": "10.2.
|
|
58
|
+
"eslint": "10.2.1",
|
|
59
59
|
"eslint-plugin-security": "^4.0.0",
|
|
60
|
-
"globals": "17.
|
|
60
|
+
"globals": "17.5.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-ingest.js — Real-time alert push from monitor to muad-api.
|
|
3
|
+
*
|
|
4
|
+
* The monitor calls sendIngest() whenever it decides to fire a Discord
|
|
5
|
+
* webhook. Fire-and-forget: errors are logged but never block the caller.
|
|
6
|
+
*
|
|
7
|
+
* Required env:
|
|
8
|
+
* MUADDIB_API_URL Base URL of muad-api (e.g. https://api.example.com)
|
|
9
|
+
* MUADDIB_INGEST_TOKEN Static shared secret matching the API's INGEST_TOKEN
|
|
10
|
+
*
|
|
11
|
+
* Both unset = ingest disabled silently (the monitor still works on its own).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const https = require('https');
|
|
15
|
+
const http = require('http');
|
|
16
|
+
const dns = require('dns');
|
|
17
|
+
|
|
18
|
+
const PRIVATE_IP_PATTERNS = [
|
|
19
|
+
/^127\./,
|
|
20
|
+
/^10\./,
|
|
21
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
22
|
+
/^192\.168\./,
|
|
23
|
+
/^0\./,
|
|
24
|
+
/^169\.254\./,
|
|
25
|
+
/^::1$/,
|
|
26
|
+
/^::ffff:127\./,
|
|
27
|
+
/^fc00:/,
|
|
28
|
+
/^fe80:/
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
32
|
+
const MAX_FINDINGS = 200;
|
|
33
|
+
const MAX_FINDING_LENGTH = 500;
|
|
34
|
+
|
|
35
|
+
function getApiUrl() {
|
|
36
|
+
const url = process.env.MUADDIB_API_URL;
|
|
37
|
+
return url && url.trim() ? url.replace(/\/$/, '') : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getIngestToken() {
|
|
41
|
+
return process.env.MUADDIB_INGEST_TOKEN || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isIngestConfigured() {
|
|
45
|
+
return Boolean(getApiUrl() && getIngestToken());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLocalHostname(hostname) {
|
|
49
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateApiUrl(url) {
|
|
53
|
+
let urlObj;
|
|
54
|
+
try {
|
|
55
|
+
urlObj = new URL(url);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { valid: false, error: `Invalid URL: ${e.message}` };
|
|
58
|
+
}
|
|
59
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
60
|
+
const local = isLocalHostname(hostname);
|
|
61
|
+
if (urlObj.protocol !== 'https:' && !local) {
|
|
62
|
+
return { valid: false, error: 'HTTPS required for non-localhost API' };
|
|
63
|
+
}
|
|
64
|
+
if (urlObj.protocol !== 'https:' && urlObj.protocol !== 'http:') {
|
|
65
|
+
return { valid: false, error: `Unsupported protocol: ${urlObj.protocol}` };
|
|
66
|
+
}
|
|
67
|
+
if (!local && PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) {
|
|
68
|
+
return { valid: false, error: 'Private IP not allowed' };
|
|
69
|
+
}
|
|
70
|
+
return { valid: true, urlObj, local };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalize a threat severity into the API's allowed enum.
|
|
75
|
+
* API accepts only CRITICAL|HIGH|MEDIUM|LOW. CLEAN/unknown -> LOW.
|
|
76
|
+
*/
|
|
77
|
+
function normalizeSeverity(level) {
|
|
78
|
+
switch ((level || '').toUpperCase()) {
|
|
79
|
+
case 'CRITICAL':
|
|
80
|
+
return 'CRITICAL';
|
|
81
|
+
case 'HIGH':
|
|
82
|
+
return 'HIGH';
|
|
83
|
+
case 'MEDIUM':
|
|
84
|
+
return 'MEDIUM';
|
|
85
|
+
default:
|
|
86
|
+
return 'LOW';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function computeSeverityFromSummary(summary) {
|
|
91
|
+
if (!summary) return 'LOW';
|
|
92
|
+
if (summary.riskLevel) return normalizeSeverity(summary.riskLevel);
|
|
93
|
+
const score = summary.riskScore || 0;
|
|
94
|
+
if (score >= 75) return 'CRITICAL';
|
|
95
|
+
if (score >= 50) return 'HIGH';
|
|
96
|
+
if (score >= 25) return 'MEDIUM';
|
|
97
|
+
return 'LOW';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildIngestPayload(name, version, result) {
|
|
101
|
+
const summary = (result && result.summary) || {};
|
|
102
|
+
const threats = (result && Array.isArray(result.threats)) ? result.threats : [];
|
|
103
|
+
|
|
104
|
+
const findings = threats
|
|
105
|
+
.slice(0, MAX_FINDINGS)
|
|
106
|
+
.map(t => {
|
|
107
|
+
const raw = t.message || t.rule_id || t.type || 'unknown';
|
|
108
|
+
return String(raw).slice(0, MAX_FINDING_LENGTH);
|
|
109
|
+
})
|
|
110
|
+
.filter(s => s.length > 0);
|
|
111
|
+
|
|
112
|
+
const breakdown = Array.isArray(summary.breakdown) ? summary.breakdown : undefined;
|
|
113
|
+
|
|
114
|
+
const payload = {
|
|
115
|
+
package: name,
|
|
116
|
+
version: version || 'unknown',
|
|
117
|
+
score: Math.max(0, Math.min(100, summary.riskScore || 0)),
|
|
118
|
+
severity: computeSeverityFromSummary(summary),
|
|
119
|
+
findings
|
|
120
|
+
};
|
|
121
|
+
if (breakdown !== undefined) payload.breakdown = breakdown;
|
|
122
|
+
return payload;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function resolveAndCheck(hostname, allowPrivate) {
|
|
126
|
+
if (allowPrivate) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const [v4, v6] = await Promise.all([
|
|
130
|
+
dns.promises.resolve4(hostname).catch(() => []),
|
|
131
|
+
dns.promises.resolve6(hostname).catch(() => [])
|
|
132
|
+
]);
|
|
133
|
+
const all = [...v4, ...v6];
|
|
134
|
+
if (all.length === 0) {
|
|
135
|
+
throw new Error(`DNS resolution failed for ${hostname}`);
|
|
136
|
+
}
|
|
137
|
+
for (const addr of all) {
|
|
138
|
+
if (PRIVATE_IP_PATTERNS.some(p => p.test(addr))) {
|
|
139
|
+
throw new Error(`Hostname ${hostname} resolves to private IP ${addr}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return v4[0] || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function postOnce(targetUrl, body, token, resolvedAddress) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const urlObj = new URL(targetUrl);
|
|
148
|
+
const proto = urlObj.protocol === 'https:' ? https : http;
|
|
149
|
+
const options = {
|
|
150
|
+
hostname: resolvedAddress || urlObj.hostname,
|
|
151
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
152
|
+
path: urlObj.pathname + urlObj.search,
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'Content-Length': Buffer.byteLength(body),
|
|
157
|
+
'Authorization': `Bearer ${token}`,
|
|
158
|
+
'Host': urlObj.hostname
|
|
159
|
+
},
|
|
160
|
+
servername: urlObj.hostname
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const req = proto.request(options, (res) => {
|
|
164
|
+
let size = 0;
|
|
165
|
+
res.on('data', chunk => {
|
|
166
|
+
size += chunk.length;
|
|
167
|
+
if (size > 64 * 1024) res.destroy();
|
|
168
|
+
});
|
|
169
|
+
res.on('end', () => {
|
|
170
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
171
|
+
resolve({ status: res.statusCode });
|
|
172
|
+
} else {
|
|
173
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
178
|
+
req.destroy();
|
|
179
|
+
reject(new Error(`Timeout after ${REQUEST_TIMEOUT_MS}ms`));
|
|
180
|
+
});
|
|
181
|
+
req.on('error', reject);
|
|
182
|
+
req.write(body);
|
|
183
|
+
req.end();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Push an alert to muad-api. Fire-and-forget: never throws.
|
|
189
|
+
* Returns { ok: true, status } or { ok: false, error }.
|
|
190
|
+
*/
|
|
191
|
+
async function sendIngest(name, version, result) {
|
|
192
|
+
if (!isIngestConfigured()) return { ok: false, error: 'not_configured' };
|
|
193
|
+
|
|
194
|
+
const apiUrl = getApiUrl();
|
|
195
|
+
const token = getIngestToken();
|
|
196
|
+
const validation = validateApiUrl(apiUrl);
|
|
197
|
+
if (!validation.valid) {
|
|
198
|
+
console.error(`[INGEST] Invalid MUADDIB_API_URL: ${validation.error}`);
|
|
199
|
+
return { ok: false, error: validation.error };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const target = `${apiUrl}/alerts/ingest`;
|
|
203
|
+
const payload = buildIngestPayload(name, version, result);
|
|
204
|
+
const body = JSON.stringify(payload);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const resolved = await resolveAndCheck(validation.urlObj.hostname, validation.local);
|
|
208
|
+
const res = await postOnce(target, body, token, resolved);
|
|
209
|
+
return { ok: true, status: res.status };
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`[INGEST] ${name}@${version}: ${err.message}`);
|
|
212
|
+
return { ok: false, error: err.message };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
sendIngest,
|
|
218
|
+
buildIngestPayload,
|
|
219
|
+
computeSeverityFromSummary,
|
|
220
|
+
isIngestConfigured,
|
|
221
|
+
validateApiUrl
|
|
222
|
+
};
|
package/src/monitor/webhook.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
|
|
11
11
|
const { sendWebhook } = require('../webhook.js');
|
|
12
|
+
const { sendIngest, isIngestConfigured } = require('../integrations/api-ingest.js');
|
|
12
13
|
const {
|
|
13
14
|
atomicWriteFileSync,
|
|
14
15
|
ALERTS_LOG_DIR,
|
|
@@ -451,6 +452,12 @@ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, m
|
|
|
451
452
|
alertedPackageRules.set(name, new Set(currentRules));
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
// Push to muad-api dashboard (fire-and-forget, fires once per unique package
|
|
456
|
+
// even when scope grouping batches the Discord webhook).
|
|
457
|
+
if (isIngestConfigured()) {
|
|
458
|
+
sendIngest(name, version, result).catch(() => {});
|
|
459
|
+
}
|
|
460
|
+
|
|
454
461
|
// Scope grouping: buffer scoped npm packages for grouped webhook
|
|
455
462
|
const scope = extractScope(name);
|
|
456
463
|
if (scope && ecosystem === 'npm') {
|