nsauditor-ai 0.1.22 → 0.1.23
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 +5 -1
- package/package.json +1 -1
- package/plugins/mcp_scanner.mjs +461 -0
- package/utils/conclusion_utils.mjs +16 -1
package/README.md
CHANGED
|
@@ -352,7 +352,7 @@ nsauditor-ai scan [options]
|
|
|
352
352
|
| `--host <target>` | Target: IP, hostname, CIDR, dash range. Aliases: `--ip`, `--target` | *required*\* |
|
|
353
353
|
| `--host-file <path>` | File with one host per line (`#` comments, blank lines OK) | — |
|
|
354
354
|
| `--plugins <list>` | Comma-separated plugin IDs or `all` | `all` |
|
|
355
|
-
| `--ports <list>` | Comma-separated
|
|
355
|
+
| `--ports <list>` | **Additional** ports to scan, merged into the default config-derived list. Comma-separated. Optional `/tcp` or `/udp` suffix per entry (default: `tcp`). Examples: `8090` · `8090,9090` · `8090/tcp,5353/udp`. Use this to scan custom services on non-standard ports (e.g. MCP servers on `8090`, dev servers on `3000–9000`) | — |
|
|
356
356
|
| `--out <dir>` | Custom output directory — applies to the per-scan folder *and* to alternate-format files (SARIF/CSV/Markdown) | `out/` |
|
|
357
357
|
| `--parallel <n>` | Concurrent host scans | `1` |
|
|
358
358
|
| `--output-format <fmt>` | Additional output format: `sarif` (CI/CD) · `csv` (spreadsheet) · `md` or `markdown` (chat/PR/Slack quotable) | — |
|
|
@@ -394,6 +394,10 @@ nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format sarif --fail-on
|
|
|
394
394
|
# Markdown report — paste straight into a GitHub issue, Slack thread, or chat
|
|
395
395
|
nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format md
|
|
396
396
|
|
|
397
|
+
# Scan custom non-standard ports (e.g. an MCP server on 8090, dev service on 5000)
|
|
398
|
+
# Uses --ports to add to the default scan list — additive, not replacing
|
|
399
|
+
nsauditor-ai scan --host 192.168.1.28 --plugins all --ports 8090,5000/tcp
|
|
400
|
+
|
|
397
401
|
# Continuous monitoring with webhook alerts
|
|
398
402
|
nsauditor-ai scan --host 192.168.1.0/24 --plugins all \
|
|
399
403
|
--watch --interval 30 \
|
package/package.json
CHANGED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// plugins/mcp_scanner.mjs
|
|
2
|
+
//
|
|
3
|
+
// MCP (Model Context Protocol) server detection and security audit.
|
|
4
|
+
//
|
|
5
|
+
// Detects HTTP/SSE-transport MCP servers on the target host by sending a safe,
|
|
6
|
+
// canonical JSON-RPC `initialize` request to a curated set of candidate paths
|
|
7
|
+
// on candidate ports. Identifies the MCP server's transport (HTTP vs SSE),
|
|
8
|
+
// protocol version, server info, auth requirement, and (when no auth is
|
|
9
|
+
// required) enumerates the exposed tools.
|
|
10
|
+
//
|
|
11
|
+
// Maps observations to the audit checklist in `tasks/mcp-server-audit-research.md`
|
|
12
|
+
// §5 and produces structured findings via per-service flags consumed by the
|
|
13
|
+
// concluder, AI prompt, Markdown report, and SARIF/CSV exporters.
|
|
14
|
+
//
|
|
15
|
+
// SAFETY: All probes are read-only at the application layer:
|
|
16
|
+
// - GET → for SSE Content-Type detection (no body sent)
|
|
17
|
+
// - POST → only the canonical JSON-RPC `initialize` and `tools/list` calls
|
|
18
|
+
// (defined by the MCP spec as introspection operations, equivalent
|
|
19
|
+
// to a "what protocol are you" handshake — not exploitation)
|
|
20
|
+
// - No tool/X invocation is ever attempted (would actually call MCP tools)
|
|
21
|
+
// - No payload variations or fuzzing
|
|
22
|
+
//
|
|
23
|
+
// LIMITATIONS:
|
|
24
|
+
// - STDIO-transport MCP servers do NOT bind ports → invisible to network
|
|
25
|
+
// scanning. Per research §2.1, stdio is the dominant local pattern. This
|
|
26
|
+
// plugin only catches HTTP/SSE transport. STDIO audit requires file-system
|
|
27
|
+
// inspection of MCP host configs (e.g. claude_desktop_config.json) — a
|
|
28
|
+
// different scope, not covered here.
|
|
29
|
+
//
|
|
30
|
+
// Reference: tasks/mcp-server-audit-research.md (in-tree research file).
|
|
31
|
+
|
|
32
|
+
import http from 'node:http';
|
|
33
|
+
import https from 'node:https';
|
|
34
|
+
|
|
35
|
+
/* ------------------------------ constants ------------------------------ */
|
|
36
|
+
|
|
37
|
+
// Per research §2.2 — common MCP-server ports (plus 8090 from real-world
|
|
38
|
+
// SSE deployment observed during N.27 verification).
|
|
39
|
+
export const MCP_CANDIDATE_PORTS = [1967, 3000, 3005, 5173, 6274, 6277, 8000, 8090];
|
|
40
|
+
|
|
41
|
+
// Per research §2.2 + 7.4 — Inspector dev tooling that should NEVER be
|
|
42
|
+
// network-reachable. Detection on a non-loopback target = MEDIUM finding.
|
|
43
|
+
export const MCP_INSPECTOR_PORTS = new Set([5173, 6274, 6277]);
|
|
44
|
+
|
|
45
|
+
// MCP RPC method paths (in priority order — try canonical mountpoints first)
|
|
46
|
+
export const MCP_PROBE_PATHS = ['/', '/mcp', '/jsonrpc', '/sse', '/messages'];
|
|
47
|
+
|
|
48
|
+
// Standard HTTPS-conventional ports — try TLS first on these
|
|
49
|
+
const HTTPS_CONVENTIONAL = new Set([443, 8443]);
|
|
50
|
+
|
|
51
|
+
// Latest stable MCP protocol version at time of writing. Anything older
|
|
52
|
+
// than this in a server's initialize response → HIGH finding (deprecated).
|
|
53
|
+
// Update this constant when MCP spec advances.
|
|
54
|
+
const CURRENT_PROTOCOL_VERSION = '2025-03-26';
|
|
55
|
+
|
|
56
|
+
// JSON-RPC body templates (built once; both calls are safe + read-only)
|
|
57
|
+
const INITIALIZE_BODY = JSON.stringify({
|
|
58
|
+
jsonrpc: '2.0',
|
|
59
|
+
id: 1,
|
|
60
|
+
method: 'initialize',
|
|
61
|
+
params: {
|
|
62
|
+
protocolVersion: '2024-11-05',
|
|
63
|
+
capabilities: {},
|
|
64
|
+
clientInfo: { name: 'nsauditor-mcp-probe', version: '1.0' },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const TOOLS_LIST_BODY = JSON.stringify({
|
|
69
|
+
jsonrpc: '2.0', id: 2, method: 'tools/list', params: {},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.MCP_SCANNER_DEBUG || ''));
|
|
73
|
+
function dlog(...a) { if (DEBUG) console.log('[mcp-scanner]', ...a); }
|
|
74
|
+
|
|
75
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Single HTTP/HTTPS request. Always-resolves Promise (never rejects).
|
|
79
|
+
* Returns { ok, status, headers, body, error } where ok=false means the
|
|
80
|
+
* connection failed entirely.
|
|
81
|
+
*/
|
|
82
|
+
function httpRequest({ host, port, path, method, body, headers, timeoutMs, isHttps, allowInsecure }) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const mod = isHttps ? https : http;
|
|
85
|
+
const agent = isHttps ? new https.Agent({ rejectUnauthorized: !allowInsecure }) : undefined;
|
|
86
|
+
const reqHeaders = {
|
|
87
|
+
'User-Agent': 'nsauditor-mcp-probe/0.1',
|
|
88
|
+
Accept: 'application/json, text/event-stream',
|
|
89
|
+
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
|
90
|
+
...(headers || {}),
|
|
91
|
+
};
|
|
92
|
+
const req = mod.request({ host, port, method, path, headers: reqHeaders, agent }, (res) => {
|
|
93
|
+
let buf = '';
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
// Cap response body to avoid OOM on a hostile server that streams forever
|
|
96
|
+
const MAX_BODY = 32 * 1024;
|
|
97
|
+
res.on('data', (chunk) => {
|
|
98
|
+
if (cancelled) return;
|
|
99
|
+
buf += chunk.toString('utf8');
|
|
100
|
+
if (buf.length > MAX_BODY) {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
buf = buf.slice(0, MAX_BODY);
|
|
103
|
+
req.destroy();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
res.on('end', () => {
|
|
107
|
+
resolve({ ok: true, status: res.statusCode, headers: res.headers, body: buf, error: null });
|
|
108
|
+
});
|
|
109
|
+
res.on('error', (err) => resolve({ ok: false, status: 0, headers: {}, body: '', error: err.message }));
|
|
110
|
+
});
|
|
111
|
+
req.setTimeout(timeoutMs, () => {
|
|
112
|
+
req.destroy(new Error('timeout'));
|
|
113
|
+
});
|
|
114
|
+
req.on('error', (err) => resolve({ ok: false, status: 0, headers: {}, body: '', error: err.message }));
|
|
115
|
+
if (body) req.write(body);
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Try to extract a parsed JSON-RPC response from an arbitrary HTTP response body.
|
|
122
|
+
* Returns null if it doesn't look like a JSON-RPC payload.
|
|
123
|
+
*/
|
|
124
|
+
function tryParseJsonRpc(body) {
|
|
125
|
+
if (!body || typeof body !== 'string') return null;
|
|
126
|
+
const trimmed = body.trim();
|
|
127
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(trimmed);
|
|
130
|
+
if (parsed?.jsonrpc === '2.0') return parsed;
|
|
131
|
+
return null;
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Determine if an HTTP response looks like a valid MCP `initialize` reply.
|
|
137
|
+
* Returns { mcp: true, protocolVersion, serverInfo, capabilities } or null.
|
|
138
|
+
*/
|
|
139
|
+
function detectMcpInitialize(parsed) {
|
|
140
|
+
if (!parsed?.result) return null;
|
|
141
|
+
const r = parsed.result;
|
|
142
|
+
if (typeof r.protocolVersion !== 'string') return null;
|
|
143
|
+
return {
|
|
144
|
+
mcp: true,
|
|
145
|
+
protocolVersion: r.protocolVersion,
|
|
146
|
+
serverInfo: r.serverInfo || null,
|
|
147
|
+
capabilities: r.capabilities || {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Try to extract tool list (names) from a JSON-RPC `tools/list` reply.
|
|
153
|
+
*/
|
|
154
|
+
function extractToolNames(parsed) {
|
|
155
|
+
const tools = parsed?.result?.tools;
|
|
156
|
+
if (!Array.isArray(tools)) return [];
|
|
157
|
+
return tools.map((t) => t?.name).filter((n) => typeof n === 'string').slice(0, 50);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compare two YYYY-MM-DD MCP protocol date strings. Returns true if `version`
|
|
162
|
+
* is older (lexically less than) `current`.
|
|
163
|
+
*/
|
|
164
|
+
function isProtocolOlderThan(version, current) {
|
|
165
|
+
// MCP protocol versions are dated strings. Lexical compare works for ISO-ish dates.
|
|
166
|
+
// We require version to look like YYYY-MM-DD; otherwise treat as not-older
|
|
167
|
+
// (don't penalize unknown-format responses).
|
|
168
|
+
if (typeof version !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(version)) return false;
|
|
169
|
+
return version < current;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a host is a loopback address (per RFC 5735 / RFC 4291).
|
|
174
|
+
* Used to gate the "MCP bound to non-loopback" finding — we only flag if the
|
|
175
|
+
* scan target is NOT loopback.
|
|
176
|
+
*/
|
|
177
|
+
function isLoopback(host) {
|
|
178
|
+
const h = String(host || '').toLowerCase();
|
|
179
|
+
if (h === 'localhost' || h === '::1') return true;
|
|
180
|
+
// 127.0.0.0/8
|
|
181
|
+
if (/^127\./.test(h)) return true;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ------------------------------ probe ------------------------------ */
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Probe one (host, port, isHttps) combination for MCP. Returns a detection
|
|
189
|
+
* record or null if not MCP.
|
|
190
|
+
*/
|
|
191
|
+
async function probePort({ host, port, isHttps, timeoutMs, allowInsecure }) {
|
|
192
|
+
const evidence = [];
|
|
193
|
+
const scheme = isHttps ? 'https' : 'http';
|
|
194
|
+
|
|
195
|
+
// Try each candidate path with the JSON-RPC initialize call
|
|
196
|
+
for (const path of MCP_PROBE_PATHS) {
|
|
197
|
+
const initResp = await httpRequest({
|
|
198
|
+
host, port, path, method: 'POST', body: INITIALIZE_BODY,
|
|
199
|
+
timeoutMs, isHttps, allowInsecure,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (!initResp.ok) {
|
|
203
|
+
evidence.push({ path, status: 0, info: 'connection failed', error: initResp.error });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 401/403 with hint of auth → MCP server probable, auth required (good)
|
|
208
|
+
if (initResp.status === 401 || initResp.status === 403) {
|
|
209
|
+
const wwwAuth = initResp.headers['www-authenticate'];
|
|
210
|
+
const looksAuth = !!wwwAuth || /unauthorized|forbidden|bearer/i.test(initResp.body || '');
|
|
211
|
+
if (looksAuth) {
|
|
212
|
+
evidence.push({
|
|
213
|
+
path, status: initResp.status,
|
|
214
|
+
info: `auth required (${initResp.status})`,
|
|
215
|
+
wwwAuthenticate: Array.isArray(wwwAuth) ? wwwAuth.join('; ') : (wwwAuth || null),
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
mcp: true,
|
|
219
|
+
path,
|
|
220
|
+
scheme,
|
|
221
|
+
authRequired: true,
|
|
222
|
+
status: initResp.status,
|
|
223
|
+
protocolVersion: null,
|
|
224
|
+
serverInfo: null,
|
|
225
|
+
capabilities: null,
|
|
226
|
+
tools: [],
|
|
227
|
+
ssePresent: false,
|
|
228
|
+
evidence,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 200 with parseable JSON-RPC `initialize` response → MCP confirmed
|
|
234
|
+
if (initResp.status === 200) {
|
|
235
|
+
const parsed = tryParseJsonRpc(initResp.body);
|
|
236
|
+
const mcpInfo = detectMcpInitialize(parsed);
|
|
237
|
+
if (mcpInfo) {
|
|
238
|
+
evidence.push({
|
|
239
|
+
path, status: 200,
|
|
240
|
+
info: `MCP initialize succeeded (protocolVersion=${mcpInfo.protocolVersion})`,
|
|
241
|
+
serverInfo: mcpInfo.serverInfo,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Probe SSE: GET with Accept: text/event-stream — check if Content-Type is SSE
|
|
245
|
+
const sseResp = await httpRequest({
|
|
246
|
+
host, port, path, method: 'GET',
|
|
247
|
+
headers: { Accept: 'text/event-stream' },
|
|
248
|
+
timeoutMs: Math.min(timeoutMs, 1000),
|
|
249
|
+
isHttps, allowInsecure,
|
|
250
|
+
});
|
|
251
|
+
const contentType = sseResp.headers?.['content-type'] || '';
|
|
252
|
+
const ssePresent = /text\/event-stream/i.test(String(contentType));
|
|
253
|
+
|
|
254
|
+
// Probe tools/list (anonymous tool enumeration — only if initialize
|
|
255
|
+
// succeeded without auth, which it did here since status was 200)
|
|
256
|
+
const toolsResp = await httpRequest({
|
|
257
|
+
host, port, path, method: 'POST', body: TOOLS_LIST_BODY,
|
|
258
|
+
timeoutMs, isHttps, allowInsecure,
|
|
259
|
+
});
|
|
260
|
+
const toolsParsed = tryParseJsonRpc(toolsResp.body);
|
|
261
|
+
const toolNames = extractToolNames(toolsParsed);
|
|
262
|
+
|
|
263
|
+
if (toolNames.length > 0) {
|
|
264
|
+
evidence.push({ path, status: toolsResp.status, info: `tools/list returned ${toolNames.length} tool(s)`, tools: toolNames });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
mcp: true,
|
|
269
|
+
path,
|
|
270
|
+
scheme,
|
|
271
|
+
authRequired: false,
|
|
272
|
+
status: 200,
|
|
273
|
+
protocolVersion: mcpInfo.protocolVersion,
|
|
274
|
+
serverInfo: mcpInfo.serverInfo,
|
|
275
|
+
capabilities: mcpInfo.capabilities,
|
|
276
|
+
tools: toolNames,
|
|
277
|
+
ssePresent,
|
|
278
|
+
evidence,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
evidence.push({ path, status: 200, info: 'HTTP 200 but no MCP-shaped response' });
|
|
282
|
+
} else {
|
|
283
|
+
evidence.push({ path, status: initResp.status, info: `unexpected status ${initResp.status}` });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ------------------------------ findings ------------------------------ */
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build per-port security flags from a detection record. Maps to research
|
|
294
|
+
* §5 audit checklist + populates cwe/owasp/mitre fields per FindingSchema.
|
|
295
|
+
*/
|
|
296
|
+
function buildFindings({ host, port, detection }) {
|
|
297
|
+
const flags = {};
|
|
298
|
+
const cwe = [];
|
|
299
|
+
const owasp = [];
|
|
300
|
+
const mitre = [];
|
|
301
|
+
|
|
302
|
+
// CRITICAL: MCP bound to non-loopback without auth (research §2.3, §3.3)
|
|
303
|
+
if (!detection.authRequired && !isLoopback(host)) {
|
|
304
|
+
flags.mcpAnonymousAccess = true;
|
|
305
|
+
cwe.push('CWE-306'); // Missing Authentication
|
|
306
|
+
owasp.push('A01:2021-Broken Access Control');
|
|
307
|
+
mitre.push('T1190'); // Exploit Public-Facing Application
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// CRITICAL: tools/list returned tools without auth (anonymous capability disclosure)
|
|
311
|
+
if (!detection.authRequired && Array.isArray(detection.tools) && detection.tools.length > 0) {
|
|
312
|
+
flags.mcpAnonymousToolList = detection.tools.slice(0, 20);
|
|
313
|
+
if (!cwe.includes('CWE-306')) cwe.push('CWE-306');
|
|
314
|
+
if (!mitre.includes('T1190')) mitre.push('T1190');
|
|
315
|
+
mitre.push('T1059'); // Command and Scripting Interpreter (tools may execute)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// HIGH: HTTP not HTTPS — bearer tokens in cleartext (research §3.3)
|
|
319
|
+
if (detection.scheme === 'http') {
|
|
320
|
+
flags.mcpCleartextTransport = true;
|
|
321
|
+
cwe.push('CWE-319'); // Cleartext Transmission
|
|
322
|
+
owasp.push('A02:2021-Cryptographic Failures');
|
|
323
|
+
mitre.push('T1040'); // Network Sniffing
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// HIGH: deprecated protocol version
|
|
327
|
+
if (detection.protocolVersion && isProtocolOlderThan(detection.protocolVersion, CURRENT_PROTOCOL_VERSION)) {
|
|
328
|
+
flags.mcpDeprecatedProtocol = detection.protocolVersion;
|
|
329
|
+
cwe.push('CWE-1395'); // Use of Outdated/Deprecated Component
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// MEDIUM: MCP Inspector exposed on a non-loopback address
|
|
333
|
+
if (MCP_INSPECTOR_PORTS.has(port) && !isLoopback(host)) {
|
|
334
|
+
flags.mcpInspectorExposed = true;
|
|
335
|
+
cwe.push('CWE-200'); // Information Exposure
|
|
336
|
+
owasp.push('A05:2021-Security Misconfiguration');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { flags, cwe: [...new Set(cwe)], owasp: [...new Set(owasp)], mitre: [...new Set(mitre)] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ------------------------------ runner ------------------------------ */
|
|
343
|
+
|
|
344
|
+
export default {
|
|
345
|
+
id: '070',
|
|
346
|
+
name: 'MCP Scanner',
|
|
347
|
+
description: 'Detects HTTP/SSE-transport MCP (Model Context Protocol) servers and audits them for cleartext transport, missing authentication, deprecated protocol versions, and Inspector exposure.',
|
|
348
|
+
priority: 70,
|
|
349
|
+
protocols: ['tcp'],
|
|
350
|
+
ports: [],
|
|
351
|
+
runStrategy: 'single',
|
|
352
|
+
requirements: { host: 'up' },
|
|
353
|
+
|
|
354
|
+
// run(host, _portIgnored, opts)
|
|
355
|
+
async run(host, _port = 0, opts = {}) {
|
|
356
|
+
const timeoutMs = parseInt(opts.timeoutMs ?? process.env.MCP_PROBE_TIMEOUT_MS ?? 2000, 10);
|
|
357
|
+
const allowInsecure = !!(opts.insecureHttps ?? /^(1|true|yes|on)$/i.test(String(process.env.INSECURE_HTTPS || '')));
|
|
358
|
+
|
|
359
|
+
// Build the candidate port list:
|
|
360
|
+
// - opts.candidatePorts (test injection / programmatic override) takes full precedence
|
|
361
|
+
// - Otherwise: static MCP_CANDIDATE_PORTS (research §2.2)
|
|
362
|
+
// + any open TCP ports from prior plugins' context that fall in
|
|
363
|
+
// the 3000-9000 MCP-likely range (also from research §2.2)
|
|
364
|
+
let candidatePorts;
|
|
365
|
+
if (Array.isArray(opts.candidatePorts) && opts.candidatePorts.length > 0) {
|
|
366
|
+
candidatePorts = [...new Set(opts.candidatePorts.filter(Number.isInteger))];
|
|
367
|
+
} else {
|
|
368
|
+
const ctxOpen = (opts.context?.tcpOpen instanceof Set) ? [...opts.context.tcpOpen] : [];
|
|
369
|
+
const dynamicPorts = ctxOpen.filter(p => p >= 3000 && p <= 9000);
|
|
370
|
+
candidatePorts = [...new Set([...MCP_CANDIDATE_PORTS, ...dynamicPorts])];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
dlog(`probing ${candidatePorts.length} candidate ports on ${host}: ${candidatePorts.join(',')}`);
|
|
374
|
+
|
|
375
|
+
const detections = [];
|
|
376
|
+
const data = [];
|
|
377
|
+
|
|
378
|
+
for (const port of candidatePorts) {
|
|
379
|
+
// Try HTTPS first on conventional HTTPS ports, otherwise HTTP
|
|
380
|
+
const isHttps = HTTPS_CONVENTIONAL.has(port);
|
|
381
|
+
const detection = await probePort({ host, port, isHttps, timeoutMs, allowInsecure });
|
|
382
|
+
|
|
383
|
+
// Always-record probe attempt evidence (even when no MCP found) to aid debugging
|
|
384
|
+
data.push({
|
|
385
|
+
probe_protocol: 'tcp',
|
|
386
|
+
probe_port: port,
|
|
387
|
+
probe_info: detection ? `MCP server detected via ${detection.scheme} ${detection.path}` : 'no MCP response',
|
|
388
|
+
response_banner: detection ? JSON.stringify({
|
|
389
|
+
protocolVersion: detection.protocolVersion,
|
|
390
|
+
authRequired: detection.authRequired,
|
|
391
|
+
serverInfo: detection.serverInfo,
|
|
392
|
+
tools: detection.tools.length,
|
|
393
|
+
sse: detection.ssePresent,
|
|
394
|
+
}) : null,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (detection) {
|
|
398
|
+
const { flags, cwe, owasp, mitre } = buildFindings({ host, port, detection });
|
|
399
|
+
detections.push({ port, detection, flags, cwe, owasp, mitre });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
up: detections.length > 0,
|
|
405
|
+
type: 'mcp-scan',
|
|
406
|
+
program: detections.length > 0 ? 'MCP Server' : 'Unknown',
|
|
407
|
+
version: detections[0]?.detection?.protocolVersion || 'Unknown',
|
|
408
|
+
os: null,
|
|
409
|
+
mcpDetections: detections,
|
|
410
|
+
data,
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Concluder adapter — convert per-detection records into ServiceRecord
|
|
417
|
+
* entries keyed by (protocol, port). Merges security flags into each
|
|
418
|
+
* service record so downstream consumers (AI prompt, Markdown report,
|
|
419
|
+
* SARIF, CSV) can surface them via the existing per-service flag pattern.
|
|
420
|
+
*
|
|
421
|
+
* Exported as a NAMED export (not on the default object) so that the
|
|
422
|
+
* result_concluder's `mod.conclude` lookup at result_concluder.mjs:189
|
|
423
|
+
* resolves correctly. Convention matches webapp_detector.mjs and other
|
|
424
|
+
* plugins that ship a conclude adapter.
|
|
425
|
+
*/
|
|
426
|
+
export function conclude({ result }) {
|
|
427
|
+
if (!Array.isArray(result?.mcpDetections) || result.mcpDetections.length === 0) return [];
|
|
428
|
+
return result.mcpDetections.map(({ port, detection, flags, cwe, owasp, mitre }) => ({
|
|
429
|
+
port,
|
|
430
|
+
protocol: 'tcp',
|
|
431
|
+
service: 'mcp',
|
|
432
|
+
program: 'MCP Server',
|
|
433
|
+
version: detection.protocolVersion || 'Unknown',
|
|
434
|
+
status: 'open',
|
|
435
|
+
banner: [
|
|
436
|
+
`MCP/${detection.scheme}`,
|
|
437
|
+
detection.serverInfo?.name ? `server=${detection.serverInfo.name}` : null,
|
|
438
|
+
detection.serverInfo?.version ? `v${detection.serverInfo.version}` : null,
|
|
439
|
+
`path=${detection.path}`,
|
|
440
|
+
detection.authRequired ? 'auth=required' : 'auth=NONE',
|
|
441
|
+
detection.ssePresent ? 'transport=sse' : 'transport=http',
|
|
442
|
+
detection.tools.length ? `tools=${detection.tools.length}` : null,
|
|
443
|
+
].filter(Boolean).join(' '),
|
|
444
|
+
authoritative: true,
|
|
445
|
+
// Security flags (consumed by AI prompt / report renderer / SARIF)
|
|
446
|
+
...flags,
|
|
447
|
+
// Evidence fields per N.5 FindingSchema
|
|
448
|
+
evidence: { cwe, owasp, mitre },
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Internal helpers exported for testing
|
|
453
|
+
export const _internals = {
|
|
454
|
+
tryParseJsonRpc,
|
|
455
|
+
detectMcpInitialize,
|
|
456
|
+
extractToolNames,
|
|
457
|
+
isProtocolOlderThan,
|
|
458
|
+
isLoopback,
|
|
459
|
+
buildFindings,
|
|
460
|
+
CURRENT_PROTOCOL_VERSION,
|
|
461
|
+
};
|
|
@@ -18,7 +18,17 @@ export function statusFrom({ info, banner, fallbackUp }) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function normalizeService(svc) {
|
|
21
|
+
// Preserve every field on the input record. Plugin authors attach security
|
|
22
|
+
// flags (anonymousLogin, weakAlgorithms, axfrAllowed, mcpCleartextTransport,
|
|
23
|
+
// etc.) directly to the service record so downstream consumers (sarif,
|
|
24
|
+
// export_csv, report_md, AI prompt) can read them. Stripping unknown fields
|
|
25
|
+
// here would silently kill all those readers — verified bug surfaced during
|
|
26
|
+
// Task N.30 implementation.
|
|
27
|
+
//
|
|
28
|
+
// Standard fields are explicitly normalized (type coercion, defaults). Any
|
|
29
|
+
// other field on the input is passed through verbatim via the spread.
|
|
21
30
|
return {
|
|
31
|
+
...svc,
|
|
22
32
|
port: Number(svc.port),
|
|
23
33
|
protocol: (svc.protocol || 'tcp').toLowerCase(),
|
|
24
34
|
service: String(svc.service || 'unknown').toLowerCase(),
|
|
@@ -29,7 +39,12 @@ export function normalizeService(svc) {
|
|
|
29
39
|
info: svc.info ?? null,
|
|
30
40
|
banner: svc.banner ?? null,
|
|
31
41
|
source: svc.source || 'unknown',
|
|
32
|
-
|
|
42
|
+
// Evidence: accept either an array (legacy — list of probe rows)
|
|
43
|
+
// OR an object (FindingSchema-style { cwe, owasp, mitre } — see N.5/N.14).
|
|
44
|
+
// Both shapes are valid; downstream readers must handle both.
|
|
45
|
+
evidence: (Array.isArray(svc.evidence) || (svc.evidence && typeof svc.evidence === 'object'))
|
|
46
|
+
? svc.evidence
|
|
47
|
+
: []
|
|
33
48
|
};
|
|
34
49
|
}
|
|
35
50
|
|