ktg-plugin-marketplace/plugins/llm-security/scanners/mcp-live-inspect.mjs

631 lines
21 KiB
JavaScript

#!/usr/bin/env node
// mcp-live-inspect.mjs — MCP Runtime Inspection Scanner
// Connects to running MCP stdio servers via JSON-RPC 2.0,
// fetches live tool/prompt/resource lists, scans for injection,
// shadowing, and drift. Standalone — not part of scan-orchestrator.
// Zero external dependencies.
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { resolve, join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { scanForInjection } from './lib/injection-patterns.mjs';
// ---------------------------------------------------------------------------
// Section 1: MCP Config Discovery
// ---------------------------------------------------------------------------
/**
* Read a JSON file, returning null on any error.
* @param {string} filePath
* @returns {object|null}
*/
function readJsonSafe(filePath) {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
/**
* Resolve env variable references in a string (${HOME}, $HOME, ${VAR}).
* @param {string} str
* @returns {string}
*/
function resolveEnvVars(str) {
if (typeof str !== 'string') return str;
return str
.replace(/^~(?=[/\\]|$)/, homedir())
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '')
.replace(/\$(\w+)/g, (_, name) => process.env[name] || '');
}
/**
* Extract MCP server descriptors from a config object.
* Handles both top-level { mcpServers: {...} } and direct { serverName: {...} } formats.
* @param {object} config - Parsed JSON config
* @param {string} sourceFile - Path to the config file
* @returns {{ servers: object[], skippedSse: number }}
*/
function extractServers(config, sourceFile) {
const servers = [];
let skippedSse = 0;
const block = config?.mcpServers || config;
if (!block || typeof block !== 'object') return { servers, skippedSse };
for (const [name, entry] of Object.entries(block)) {
if (!entry || typeof entry !== 'object') continue;
// Skip non-server keys (e.g. "enabledPlugins", "permissions")
if (!entry.command && !entry.url) continue;
if (entry.url || entry.type === 'sse') {
skippedSse++;
continue;
}
// Resolve env var references in command, args, env values, and cwd
const resolvedArgs = (entry.args || []).map(resolveEnvVars);
const resolvedEnv = {};
for (const [k, v] of Object.entries(entry.env || {})) {
resolvedEnv[k] = resolveEnvVars(v);
}
servers.push({
name,
transport: 'stdio',
command: resolveEnvVars(entry.command),
args: resolvedArgs,
env: resolvedEnv,
cwd: entry.cwd ? resolveEnvVars(entry.cwd) : null,
sourceFile,
});
}
return { servers, skippedSse };
}
/**
* Discover all MCP servers from 6 config locations.
* @param {string} targetPath - Project root
* @param {boolean} skipGlobal - Skip ~/.claude/ locations
* @returns {{ servers: object[], skippedSse: number, configsRead: string[] }}
*/
export function discoverMcpServers(targetPath, skipGlobal = false) {
const locations = [
join(targetPath, '.mcp.json'),
join(targetPath, '.claude', 'settings.json'),
join(targetPath, 'claude_desktop_config.json'),
];
if (!skipGlobal) {
const home = homedir();
locations.push(
join(home, '.claude', 'settings.json'),
join(home, '.claude', 'mcp.json'),
join(home, '.config', 'claude', 'mcp.json'),
);
}
const allServers = [];
let totalSkippedSse = 0;
const configsRead = [];
const seenNames = new Set();
for (const loc of locations) {
const config = readJsonSafe(loc);
if (!config) continue;
configsRead.push(loc);
const { servers, skippedSse } = extractServers(config, loc);
totalSkippedSse += skippedSse;
for (const s of servers) {
if (seenNames.has(s.name)) continue; // first wins dedup
seenNames.add(s.name);
allServers.push(s);
}
}
return { servers: allServers, skippedSse: totalSkippedSse, configsRead };
}
// ---------------------------------------------------------------------------
// Section 2: JSON-RPC 2.0 Session & Server Inspection
// ---------------------------------------------------------------------------
const DEFAULT_TIMEOUT_MS = 10_000;
const PER_CALL_TIMEOUT_MS = 5_000;
const KILL_GRACE_MS = 500;
/**
* Create a JSON-RPC 2.0 session over a child process's stdin/stdout.
* @param {import('child_process').ChildProcess} proc
* @returns {{ send: function, close: function }}
*/
function createRpcSession(proc) {
const pending = new Map();
let nextId = 1;
const rl = createInterface({ input: proc.stdout });
rl.on('line', (line) => {
if (!line.trim()) return;
let msg;
try { msg = JSON.parse(line); } catch { return; }
if (msg.id != null && pending.has(msg.id)) {
const { resolve: res, reject: rej } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) {
const err = new Error(`RPC ${msg.error.code}: ${msg.error.message || 'unknown'}`);
err.code = msg.error.code;
rej(err);
} else {
res(msg.result);
}
}
});
proc.stdout.on('close', () => {
for (const { reject: rej } of pending.values()) {
rej(new Error('stdout closed'));
}
pending.clear();
});
/**
* Send a JSON-RPC message.
* @param {string} method
* @param {object} params
* @param {boolean} expectResponse - false for notifications
* @returns {Promise<object>}
*/
function send(method, params = {}, expectResponse = true) {
const id = expectResponse ? nextId++ : undefined;
const msg = { jsonrpc: '2.0', method, params };
if (id !== undefined) msg.id = id;
try {
proc.stdin.write(JSON.stringify(msg) + '\n');
} catch {
return Promise.reject(new Error('stdin write failed'));
}
if (!expectResponse) return Promise.resolve();
return new Promise((res, rej) => {
pending.set(id, { resolve: res, reject: rej });
});
}
function close() {
rl.close();
pending.clear();
}
return { send, close };
}
/**
* Race a promise against a timeout.
* @param {Promise} promise
* @param {number} ms
* @param {string} label
* @returns {Promise}
*/
function withTimeout(promise, ms, label = 'operation') {
return new Promise((res, rej) => {
const timer = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms);
promise.then(
(val) => { clearTimeout(timer); res(val); },
(err) => { clearTimeout(timer); rej(err); },
);
});
}
/**
* Safely send an RPC call, treating MethodNotFound as empty result.
* @param {object} session - RPC session
* @param {string} method
* @returns {Promise<object>}
*/
async function safeSend(session, method) {
try {
return await withTimeout(session.send(method, {}), PER_CALL_TIMEOUT_MS, method);
} catch (err) {
if (err.code === -32601) return {}; // MethodNotFound → empty
throw err;
}
}
/**
* Kill a child process with grace period.
* @param {import('child_process').ChildProcess} proc
*/
function killProcess(proc) {
try { proc.kill('SIGTERM'); } catch { /* already dead */ }
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, KILL_GRACE_MS);
}
/**
* Spawn an MCP server, initialize it, fetch tools/prompts/resources.
* @param {object} descriptor - Server descriptor from discovery
* @param {number} timeoutMs - Global timeout per server
* @returns {Promise<object|null>} Inspection result or null on failure
*/
export async function inspectServer(descriptor, timeoutMs = DEFAULT_TIMEOUT_MS) {
const start = Date.now();
return new Promise((outerResolve) => {
let proc;
let session;
let globalTimer;
let resolved = false;
function finish(result) {
if (resolved) return;
resolved = true;
clearTimeout(globalTimer);
if (session) session.close();
if (proc) killProcess(proc);
outerResolve(result);
}
// Global timeout
globalTimer = setTimeout(() => {
finish({ name: descriptor.name, error: 'timeout', durationMs: Date.now() - start });
}, timeoutMs);
try {
const mergedEnv = { ...process.env, ...descriptor.env };
const spawnOpts = {
stdio: ['pipe', 'pipe', 'pipe'],
env: mergedEnv,
};
if (descriptor.cwd) spawnOpts.cwd = descriptor.cwd;
proc = spawn(descriptor.command, descriptor.args, spawnOpts);
} catch (err) {
finish({ name: descriptor.name, error: `spawn failed: ${err.message}`, durationMs: Date.now() - start });
return;
}
// Capture stderr for diagnostics
let stderrBuf = '';
proc.stderr.on('data', (chunk) => {
if (stderrBuf.length < 500) stderrBuf += chunk.toString();
});
proc.on('error', (err) => {
finish({ name: descriptor.name, error: `process error: ${err.message}`, durationMs: Date.now() - start });
});
session = createRpcSession(proc);
// Run the inspection protocol
(async () => {
// 1. Initialize
const initResult = await withTimeout(
session.send('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcp-live-inspect', version: '1.0.0' },
}),
PER_CALL_TIMEOUT_MS,
'initialize',
);
// 2. Send initialized notification
await session.send('notifications/initialized', {}, false);
// 3. Fetch lists concurrently
const [toolsResult, promptsResult, resourcesResult] = await Promise.allSettled([
safeSend(session, 'tools/list'),
safeSend(session, 'prompts/list'),
safeSend(session, 'resources/list'),
]);
finish({
name: descriptor.name,
serverInfo: initResult?.serverInfo || null,
protocolVersion: initResult?.protocolVersion || null,
tools: toolsResult.status === 'fulfilled' ? (toolsResult.value?.tools || []) : [],
prompts: promptsResult.status === 'fulfilled' ? (promptsResult.value?.prompts || []) : [],
resources: resourcesResult.status === 'fulfilled' ? (resourcesResult.value?.resources || []) : [],
toolsError: toolsResult.status === 'rejected' ? toolsResult.reason.message : null,
stderr: stderrBuf.trim().slice(0, 200) || null,
durationMs: Date.now() - start,
error: null,
});
})().catch((err) => {
finish({
name: descriptor.name,
error: `protocol error: ${err.message}`,
stderr: stderrBuf.trim().slice(0, 200) || null,
durationMs: Date.now() - start,
});
});
});
}
// ---------------------------------------------------------------------------
// Section 3: Tool Description Injection Analysis
// ---------------------------------------------------------------------------
const URL_IN_DESC_RE = /https?:\/\/\S+|\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
/**
* Analyze tool descriptions from a server for injection patterns.
* @param {string} serverName
* @param {object[]} tools
* @param {object[]} findings - Mutated: findings pushed here
*/
function analyzeToolDescriptions(serverName, tools, findings) {
for (const tool of tools) {
const desc = tool.description || '';
const name = tool.name || '';
// Injection pattern scan on description
if (desc) {
const result = scanForInjection(desc);
if (result.found) {
findings.push(finding({
scanner: 'MCI',
severity: result.severity,
title: `Tool description injection: ${serverName}.${name}`,
description: `Live tool description contains injection patterns: ${result.patterns.map(p => p.label).join(', ')}`,
evidence: `[${serverName}] ${name}: ${desc.slice(0, 200)}${desc.length > 200 ? '...' : ''}`,
owasp: 'MCP03, MCP06',
recommendation: `Review and sanitize tool description for "${name}" in MCP server "${serverName}". Remove any LLM-directed instructions from tool descriptions.`,
}));
}
// Excessive length
if (desc.length > 500) {
findings.push(finding({
scanner: 'MCI',
severity: SEVERITY.MEDIUM,
title: `Excessive tool description length: ${serverName}.${name}`,
description: `Tool description is ${desc.length} characters (threshold: 500). Long descriptions may contain hidden instructions.`,
evidence: `[${serverName}] ${name}: ${desc.length} chars`,
owasp: 'MCP03',
recommendation: `Review tool description for "${name}" — descriptions over 500 chars are suspicious for embedded instructions.`,
}));
}
// URL/IP in description
if (URL_IN_DESC_RE.test(desc)) {
findings.push(finding({
scanner: 'MCI',
severity: SEVERITY.HIGH,
title: `URL/IP in tool description: ${serverName}.${name}`,
description: `Tool description contains a URL or IP address, which may indicate data exfiltration or tool poisoning.`,
evidence: `[${serverName}] ${name}: ${desc.slice(0, 200)}`,
owasp: 'MCP03',
recommendation: `Investigate why tool "${name}" references external URLs in its description. This is a tool poisoning signal.`,
}));
}
}
// Injection scan on tool name itself
if (name) {
const nameResult = scanForInjection(name);
if (nameResult.found) {
findings.push(finding({
scanner: 'MCI',
severity: SEVERITY.HIGH,
title: `Suspicious tool name: ${serverName}.${name}`,
description: `Tool name contains injection patterns: ${nameResult.patterns.map(p => p.label).join(', ')}`,
evidence: `[${serverName}] name: ${name}`,
owasp: 'MCP03, MCP06',
recommendation: `Tool name "${name}" in server "${serverName}" contains suspicious patterns. Investigate the server's tool registration.`,
}));
}
}
}
}
// ---------------------------------------------------------------------------
// Section 4: Tool Shadowing Detection
// ---------------------------------------------------------------------------
/**
* Detect tool shadowing — same tool name across multiple servers.
* @param {object[]} serverResults - Array of successful inspection results
* @param {object[]} findings - Mutated
*/
function detectToolShadowing(serverResults, findings) {
const toolMap = new Map(); // toolName → [{ serverName, description }]
for (const sr of serverResults) {
if (!sr.tools) continue;
for (const tool of sr.tools) {
const name = tool.name || '';
if (!name) continue;
if (!toolMap.has(name)) toolMap.set(name, []);
toolMap.get(name).push({
serverName: sr.name,
description: (tool.description || '').slice(0, 100),
});
}
}
for (const [toolName, entries] of toolMap) {
if (entries.length < 2) continue;
const descriptions = entries.map(e => e.description);
const allSame = descriptions.every(d => d === descriptions[0]);
const serverNames = entries.map(e => e.serverName).join(', ');
findings.push(finding({
scanner: 'MCI',
severity: allSame ? SEVERITY.MEDIUM : SEVERITY.HIGH,
title: `Tool shadowing: "${toolName}" in ${entries.length} servers`,
description: allSame
? `Tool "${toolName}" is defined in ${entries.length} servers with identical descriptions. Likely redundant config.`
: `Tool "${toolName}" is defined in ${entries.length} servers with DIFFERENT descriptions. One may be impersonating the other.`,
evidence: entries.map(e => `${e.serverName}: "${e.description}"`).join(' | '),
owasp: 'MCP09',
recommendation: allSame
? `Review whether "${toolName}" should be served by multiple servers (${serverNames}). Consider consolidating.`
: `PRIORITY: Different descriptions for "${toolName}" across servers (${serverNames}). Determine which is authoritative and remove the impersonator.`,
}));
}
}
// ---------------------------------------------------------------------------
// Section 5: Description Drift Detection (minimal v2.8.0)
// ---------------------------------------------------------------------------
// TODO: S5 — integrate with diff-engine for cross-run description drift detection.
// For v2.8.0, drift detection is limited to tool count comparison when config
// declares expectedToolCount (uncommon). Full drift requires baseline storage.
// ---------------------------------------------------------------------------
// Section 6: Public API + CLI
// ---------------------------------------------------------------------------
/**
* Connect to all MCP stdio servers discovered from config locations
* and scan live tool descriptions for security issues.
*
* @param {string} targetPath - Project root to resolve relative config paths
* @param {object} [options]
* @param {number} [options.timeoutMs=10000] - Per-server timeout
* @param {boolean} [options.skipGlobal=false] - Skip ~/.claude/ config locations
* @returns {Promise<object>} scannerResult envelope with meta
*/
export async function inspect(targetPath, options = {}) {
const start = Date.now();
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
const skipGlobal = options.skipGlobal || false;
resetCounter();
// Discover servers
const discovery = discoverMcpServers(targetPath, skipGlobal);
const { servers, skippedSse, configsRead } = discovery;
if (servers.length === 0) {
const result = scannerResult('mcp-live-inspect', 'ok', [], 0, Date.now() - start);
result.meta = {
servers_discovered: 0,
servers_contacted: 0,
servers_skipped_sse: skippedSse,
servers_timed_out: 0,
servers_failed: 0,
configs_read: configsRead,
server_details: [],
};
return result;
}
// Inspect each server sequentially (avoid spawning many processes at once)
const allFindings = [];
const successfulResults = [];
let contacted = 0;
let timedOut = 0;
let failed = 0;
const serverDetails = [];
for (const descriptor of servers) {
const inspResult = await inspectServer(descriptor, timeoutMs);
if (inspResult.error === 'timeout') {
timedOut++;
serverDetails.push({ name: descriptor.name, status: 'timeout', tools: 0 });
allFindings.push(finding({
scanner: 'MCI',
severity: SEVERITY.INFO,
title: `Server timeout: ${descriptor.name}`,
description: `MCP server "${descriptor.name}" did not respond within ${timeoutMs}ms. Command: ${descriptor.command} ${(descriptor.args || []).join(' ')}`,
owasp: 'MCP08',
recommendation: `Verify that "${descriptor.name}" can start independently. Check command, args, and required env vars.`,
}));
} else if (inspResult.error) {
failed++;
serverDetails.push({ name: descriptor.name, status: 'failed', error: inspResult.error, tools: 0 });
} else {
contacted++;
const toolCount = inspResult.tools?.length || 0;
serverDetails.push({
name: descriptor.name,
status: 'ok',
tools: toolCount,
prompts: inspResult.prompts?.length || 0,
resources: inspResult.resources?.length || 0,
durationMs: inspResult.durationMs,
});
successfulResults.push(inspResult);
// Analyze tool descriptions
if (inspResult.tools && inspResult.tools.length > 0) {
analyzeToolDescriptions(descriptor.name, inspResult.tools, allFindings);
}
}
}
// Cross-server analysis: tool shadowing
if (successfulResults.length >= 2) {
detectToolShadowing(successfulResults, allFindings);
}
const durationMs = Date.now() - start;
const result = scannerResult('mcp-live-inspect', 'ok', allFindings, contacted, durationMs);
result.meta = {
servers_discovered: servers.length,
servers_contacted: contacted,
servers_skipped_sse: skippedSse,
servers_timed_out: timedOut,
servers_failed: failed,
configs_read: configsRead,
server_details: serverDetails,
};
return result;
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
function parseCliArgs(argv) {
const args = { target: null, timeoutMs: DEFAULT_TIMEOUT_MS, skipGlobal: false };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--timeout' && argv[i + 1]) {
args.timeoutMs = parseInt(argv[++i], 10) || DEFAULT_TIMEOUT_MS;
} else if (argv[i] === '--skip-global') {
args.skipGlobal = true;
} else if (!args.target) {
args.target = argv[i];
}
}
return args;
}
if (import.meta.url === pathToFileURL(resolve(process.argv[1] || '')).href) {
const args = parseCliArgs(process.argv);
const target = resolve(args.target || '.');
inspect(target, { timeoutMs: args.timeoutMs, skipGlobal: args.skipGlobal })
.then((result) => {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
const v = result.counts || {};
if ((v.critical || 0) >= 1) process.exit(2);
if ((v.high || 0) >= 1) process.exit(1);
process.exit(0);
})
.catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(1);
});
}