From d1982a8ac0597adb2c89088180e7eb93e66c7e04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:07:00 +0000 Subject: [PATCH] fix(INSECURE_TRANSPORT): detect ws:// declared via top-level serverUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INSECURE_TRANSPORT only read config.transport?.url, so a config that declares its endpoint at the top level as serverUrl: "ws://..." with no nested transport.url block passed the scan with zero findings — a false negative for a security gate on a cleartext WebSocket endpoint. MISSING_TLS already falls back to serverUrl (config.transport?.url ?? config.serverUrl); serverUrl is a documented, first-class field (examples/minimal-config.json uses it alone). Mirror that fallback here so the same endpoint is evaluated regardless of which field carries it. Adds regression tests: ws:// serverUrl fires, wss:// serverUrl does not, and transport.url still takes precedence when both are present. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RfTG58p1JL5Va5ZNKVP1Rv --- src/__tests__/scanner.test.ts | 27 +++++++++++++++++++++++++++ src/rules/runtime-rules.ts | 7 ++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index dd8e44a..419d927 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -561,6 +561,33 @@ describe('INSECURE_TRANSPORT rule', () => { const findings = runRuntimeRule(RuleId.INSECURE_TRANSPORT, config); expect(findings).toHaveLength(0); }); + + it('fires for a ws:// serverUrl when transport.url is absent', () => { + // A config can declare its endpoint at the top level (`serverUrl`) with no + // nested `transport.url`. The rule must still catch cleartext WebSockets + // there — mirroring MISSING_TLS's serverUrl fallback. + const config: ParsedMcpConfig = { serverUrl: 'ws://example.com/mcp' }; + const findings = runRuntimeRule(RuleId.INSECURE_TRANSPORT, config); + expect(findings).toHaveLength(1); + expect(findings[0].ruleId).toBe(RuleId.INSECURE_TRANSPORT); + expect(findings[0].severity).toBe(Severity.HIGH); + }); + + it('does not fire for a wss:// serverUrl when transport.url is absent', () => { + const config: ParsedMcpConfig = { serverUrl: 'wss://example.com/mcp' }; + const findings = runRuntimeRule(RuleId.INSECURE_TRANSPORT, config); + expect(findings).toHaveLength(0); + }); + + it('prefers transport.url over serverUrl when both are present', () => { + const config: ParsedMcpConfig = { + serverUrl: 'wss://secure.example.com/mcp', + transport: { url: 'ws://insecure.example.com/mcp' }, + }; + const findings = runRuntimeRule(RuleId.INSECURE_TRANSPORT, config); + expect(findings).toHaveLength(1); + expect(findings[0].evidence).toContain('ws://insecure.example.com/mcp'); + }); }); // ─── failOn behaviour ───────────────────────────────────────────────────────── diff --git a/src/rules/runtime-rules.ts b/src/rules/runtime-rules.ts index 0d0c3fd..d2d64eb 100644 --- a/src/rules/runtime-rules.ts +++ b/src/rules/runtime-rules.ts @@ -15,7 +15,12 @@ export const runtimeRules: Rule[] = [ severity: Severity.HIGH, title: 'Insecure WebSocket Transport (ws://)', check(config: ParsedMcpConfig): Finding[] { - const url = config.transport?.url; + // Mirror MISSING_TLS: fall back to the top-level `serverUrl` when no + // `transport.url` is set. A config can declare its endpoint at either + // location (e.g. examples/minimal-config.json uses `serverUrl` alone), + // and an unencrypted `ws://` endpoint is equally insecure whichever + // field carries it. + const url = config.transport?.url ?? config.serverUrl; if (url && url.startsWith('ws://')) { return [ {