nsauditor-ai 0.1.22 → 0.1.24

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 CHANGED
@@ -21,7 +21,7 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
21
21
  Scan → Verify → Prioritize → Track → Act
22
22
  ```
23
23
 
24
- - **26 scanner plugins** probe networks across ICMP, TCP, UDP, HTTP, TLS, SNMP, DNS, SMB, RPC, mDNS, UPnP, WS-Discovery, and more
24
+ - **27 scanner plugins** probe networks across ICMP, TCP, UDP, HTTP, TLS, SNMP, DNS, SMB, RPC, mDNS, UPnP, WS-Discovery, MCP (Model Context Protocol), and more
25
25
  - **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
26
26
  - **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
27
27
  - **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
@@ -36,7 +36,7 @@ NSAuditor AI is available in three editions:
36
36
 
37
37
  | | Community (Free) | Pro ($49/mo) | Enterprise ($2k+/yr) |
38
38
  |---|:---:|:---:|:---:|
39
- | 26 scanner plugins | ✅ | ✅ | ✅ |
39
+ | 27 scanner plugins | ✅ | ✅ | ✅ |
40
40
  | AI analysis (OpenAI, Claude, Ollama) | ✅ (basic prompts) | ✅ (enriched) | ✅ (enriched) |
41
41
  | Structured finding format | ✅ | ✅ | ✅ |
42
42
  | CTEM watch mode | ✅ | ✅ | ✅ |
@@ -144,6 +144,7 @@ Results land in `./out/<host>_<timestamp>/`:
144
144
  | 040 | TLS Certificate & Cipher Auditor | TCP:443+ | Cert expiry, chain integrity, hostname mismatch, weak ciphers, deprecated protocols, key strength |
145
145
  | 050 | TRIBE v2 Neural API Security Probe | TCP/HTTP:8080 | Debug leak detection, stack traces in errors, header security, CORS misconfiguration, unauthenticated routes |
146
146
  | 060 | DNS Security Auditor | DNS/UDP:53 | SPF/DKIM/DMARC, dangling CNAMEs, DNSSEC, NS delegation, zone transfer exposure, MX security, CAA records |
147
+ | 070 | MCP Scanner | TCP/HTTP+SSE | Detects MCP (Model Context Protocol) servers on candidate ports (1967, 3000, 3005, 5173, 6274, 6277, 8000, 8090). Audits for cleartext transport (HTTP not HTTPS), missing/anonymous auth, anonymous tool enumeration, deprecated protocol versions, and Inspector exposure on non-loopback. Maps findings to CWE/OWASP/MITRE per the FindingSchema. STDIO-transport MCP servers are out of scope (no network port). |
147
148
 
148
149
  ### Discovery Plugins
149
150
 
@@ -352,7 +353,7 @@ nsauditor-ai scan [options]
352
353
  | `--host <target>` | Target: IP, hostname, CIDR, dash range. Aliases: `--ip`, `--target` | *required*\* |
353
354
  | `--host-file <path>` | File with one host per line (`#` comments, blank lines OK) | — |
354
355
  | `--plugins <list>` | Comma-separated plugin IDs or `all` | `all` |
355
- | `--ports <list>` | Comma-separated ports to pass to plugins | — |
356
+ | `--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
357
  | `--out <dir>` | Custom output directory — applies to the per-scan folder *and* to alternate-format files (SARIF/CSV/Markdown) | `out/` |
357
358
  | `--parallel <n>` | Concurrent host scans | `1` |
358
359
  | `--output-format <fmt>` | Additional output format: `sarif` (CI/CD) · `csv` (spreadsheet) · `md` or `markdown` (chat/PR/Slack quotable) | — |
@@ -394,6 +395,10 @@ nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format sarif --fail-on
394
395
  # Markdown report — paste straight into a GitHub issue, Slack thread, or chat
395
396
  nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format md
396
397
 
398
+ # Scan custom non-standard ports (e.g. an MCP server on 8090, dev service on 5000)
399
+ # Uses --ports to add to the default scan list — additive, not replacing
400
+ nsauditor-ai scan --host 192.168.1.28 --plugins all --ports 8090,5000/tcp
401
+
397
402
  # Continuous monitoring with webhook alerts
398
403
  nsauditor-ai scan --host 192.168.1.0/24 --plugins all \
399
404
  --watch --interval 30 \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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
- evidence: Array.isArray(svc.evidence) ? svc.evidence : []
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