History

Scrub credentials from span attributes

Strip OAuth authorization codes and CSRF state from exported span URL attributes, and sanitize user-supplied MCP endpoints before stamping them.

Rhys Sullivan committed 3 months ago

Commit 114d8661 parent fc1e58912 files · +666 −13Browse files

Unified · Split

12 files
  1. .changeset/olive-moons-shake.md +8 −0
  2. apps/cloud/src/observability/oauth-callback-telemetry.test.ts +114 −0
  3. apps/cloud/src/observability/redact-span-urls.test.ts +126 −0
  4. apps/cloud/src/observability/redact-span-urls.ts +189 −0
  5. apps/cloud/src/observability/telemetry.ts +7 −1
  6. packages/core/sdk/src/index.ts +4 −0
  7. packages/core/sdk/src/telemetry-endpoint.test.ts +68 −0
  8. packages/core/sdk/src/telemetry-endpoint.ts +54 −0
  9. packages/plugins/graphql/src/sdk/invoke.ts +2 −8
  10. packages/plugins/graphql/src/sdk/plugin.test.ts +1 −1
  11. packages/plugins/mcp/src/sdk/plugin.test.ts +83 −1
  12. packages/plugins/mcp/src/sdk/plugin.ts +10 −2
.changeset/olive-moons-shake.md+8−0
@@ -0,0 +1,8 @@
1---
2"executor": patch
3---
4
5Stop exporting credential-bearing URLs in span attributes. OAuth callback
6authorization codes and CSRF state are stripped from span URL attributes before
7export, and user-supplied MCP endpoints are sanitized of query-string and
8userinfo credentials before being stamped.
apps/cloud/src/observability/oauth-callback-telemetry.test.ts+114−0
@@ -0,0 +1,114 @@
1// ---------------------------------------------------------------------------
2// OAuth callback × telemetry — the authorization code must never be exported.
3//
4// `/api/oauth/callback` is an app-owned path, so Effect's
5// `HttpMiddleware.tracer` opens its `http.server` span and stamps `url.full`
6// and `url.query` unconditionally (`effect/unstable/http/HttpMiddleware.ts`).
7// It redacts URL userinfo and configured header names — nothing else — so the
8// provider's `?code=…&state=…` reached the trace backend on every connect.
9//
10// This drives a real callback request through the real core API web handler,
11// with the real Effect→OTel tracer bridge exporting into an in-memory
12// exporter behind the production span-processor chain, and asserts that no
13// exported span attribute contains the code or the state.
14// ---------------------------------------------------------------------------
15
16import { describe, expect, it } from "@effect/vitest";
17import * as Resource from "@effect/opentelemetry/Resource";
18import * as OtelTracer from "@effect/opentelemetry/Tracer";
19import {
20 InMemorySpanExporter,
21 SimpleSpanProcessor,
22 type ReadableSpan,
23} from "@opentelemetry/sdk-trace-base";
24import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
25import { Context, Effect, Layer } from "effect";
26import { HttpRouter, HttpServer } from "effect/unstable/http";
27import { HttpApiBuilder } from "effect/unstable/httpapi";
28
29import { ExecutorApi, observabilityMiddleware } from "@executor-js/api";
30import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";
31import { createExecutor } from "@executor-js/sdk";
32import { makeTestConfig } from "@executor-js/sdk/testing";
33
34import { UrlRedactingSpanProcessor } from "./redact-span-urls";
35
36// Synthetic placeholders — never a real authorization code or CSRF state.
37const CODE = "synthetic-authorization-code-9f2c";
38const STATE = "synthetic-csrf-state-4b7e";
39
40const makeTracing = () => {
41 const exporter = new InMemorySpanExporter();
42 const provider = new BasicTracerProvider({
43 // The same wrapper order production installs in `telemetry.ts`: the
44 // redactor sits outermost, so nothing downstream ever sees the secret.
45 spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))],
46 });
47 const tracerLayer = OtelTracer.layer.pipe(
48 Layer.provide(Layer.succeed(OtelTracer.OtelTracerProvider)(provider)),
49 Layer.provide(Resource.layer({ serviceName: "executor-cloud-test" })),
50 );
51 return { exporter, provider, tracerLayer };
52};
53
54describe("oauth callback telemetry", () => {
55 it.effect("exports no span attribute containing the authorization code or state", () =>
56 Effect.gen(function* () {
57 const { exporter, provider, tracerLayer } = makeTracing();
58 const executor = yield* createExecutor(makeTestConfig({}));
59
60 const web = yield* Effect.acquireRelease(
61 Effect.sync(() =>
62 HttpRouter.toWebHandler(
63 HttpApiBuilder.layer(ExecutorApi).pipe(
64 Layer.provide(CoreHandlers),
65 Layer.provide(observabilityMiddleware(ExecutorApi)),
66 Layer.provide(Layer.succeed(ExecutorService)(executor)),
67 Layer.provide(
68 Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]),
69 ),
70 Layer.provideMerge(HttpServer.layerServices),
71 Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
72 // The Effect→OTel bridge: HttpMiddleware.tracer's span is created
73 // by this tracer, so it lands in the exporter below.
74 Layer.provideMerge(tracerLayer),
75 ),
76 { disableLogger: true },
77 ),
78 ),
79 (handle) => Effect.promise(() => handle.dispose()),
80 );
81
82 const context = Context.make(ExecutorService, executor).pipe(
83 Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]),
84 );
85
86 // The provider round-trip: a real callback carries the grant and the
87 // CSRF state in the query string.
88 yield* Effect.promise(() =>
89 web.handler(
90 new Request(
91 `http://app.test/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`,
92 ),
93 context,
94 ),
95 );
96
97 yield* Effect.promise(() => provider.forceFlush());
98 const spans: readonly ReadableSpan[] = exporter.getFinishedSpans();
99
100 // The middleware must actually have opened a server span — otherwise
101 // this test would pass vacuously.
102 const serverSpan = spans.find((span) => span.name.startsWith("http.server"));
103 expect(serverSpan).toBeDefined();
104
105 const serialized = JSON.stringify(spans.map((span) => span.attributes));
106 expect(serialized).not.toContain(CODE);
107 expect(serialized).not.toContain(STATE);
108
109 // Route-level visibility survives the scrub.
110 expect(serverSpan?.attributes["url.path"]).toBe("/oauth/callback");
111 expect(String(serverSpan?.attributes["url.full"] ?? "")).toContain("/oauth/callback");
112 }),
113 );
114});
apps/cloud/src/observability/redact-span-urls.test.ts+126−0
@@ -0,0 +1,126 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 BasicTracerProvider,
4 InMemorySpanExporter,
5 SimpleSpanProcessor,
6 type ReadableSpan,
7} from "@opentelemetry/sdk-trace-base";
8
9import {
10 redactSpanUrlAttributes,
11 STRIPPED_QUERY_ATTRIBUTE,
12 UrlRedactingSpanProcessor,
13} from "./redact-span-urls";
14
15// Synthetic placeholders only — never a real authorization code or state.
16const CODE = "synthetic-authorization-code";
17const STATE = "synthetic-csrf-state";
18
19const callbackUrl = `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`;
20
21/** Ends one real SDK span carrying `attributes` through the redacting
22 * processor, and returns what the exporter actually received. Using the real
23 * provider (rather than a hand-built span) exercises the `onEnding` → `onEnd`
24 * hook sequence exactly as production does. */
25const exportSpanWith = (attributes: Record<string, string>): ReadableSpan | undefined => {
26 const exporter = new InMemorySpanExporter();
27 const provider = new BasicTracerProvider({
28 spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))],
29 });
30 const span = provider.getTracer("test").startSpan("http.server GET");
31 span.setAttributes(attributes);
32 span.end();
33 return exporter.getFinishedSpans()[0];
34};
35
36describe("redactSpanUrlAttributes", () => {
37 it("strips the authorization code and state from url.full and url.query", () => {
38 const attributes: Record<string, unknown> = {
39 "url.full": callbackUrl,
40 "url.query": `code=${CODE}&state=${STATE}&domain=example.test`,
41 "url.path": "/api/oauth/callback",
42 "http.request.method": "GET",
43 };
44
45 const stripped = redactSpanUrlAttributes(attributes);
46
47 expect(stripped).toEqual(["code", "state"]);
48 expect(JSON.stringify(attributes)).not.toContain(CODE);
49 expect(JSON.stringify(attributes)).not.toContain(STATE);
50 // Route-level visibility is preserved.
51 expect(attributes["url.path"]).toBe("/api/oauth/callback");
52 expect(attributes["url.full"]).toBe("https://app.test/api/oauth/callback?domain=example.test");
53 expect(attributes["url.query"]).toBe("domain=example.test");
54 });
55
56 it("strips a code nested inside the login redirect's returnTo parameter", () => {
57 // `/login` is not an app-owned path, so its span comes from the worker
58 // boundary — the callback query rides along inside `returnTo`
59 // (auth/return-to.ts + the sign-in redirect in start.ts).
60 const returnTo = encodeURIComponent(`/api/oauth/callback?code=${CODE}&state=${STATE}`);
61 const attributes: Record<string, unknown> = {
62 "url.full": `https://app.test/login?returnTo=${returnTo}`,
63 "url.query": `returnTo=${returnTo}`,
64 };
65
66 const stripped = redactSpanUrlAttributes(attributes);
67
68 expect(stripped).toEqual(["returnTo.code", "returnTo.state"]);
69 expect(JSON.stringify(attributes)).not.toContain(CODE);
70 expect(JSON.stringify(attributes)).not.toContain(STATE);
71 expect(String(attributes["url.full"])).toContain("%2Fapi%2Foauth%2Fcallback");
72 });
73
74 it("leaves a span with no sensitive parameters untouched", () => {
75 const attributes: Record<string, unknown> = {
76 "url.full": "https://app.test/api/integrations?owner=org",
77 "url.query": "owner=org",
78 "url.path": "/api/integrations",
79 };
80
81 expect(redactSpanUrlAttributes(attributes)).toEqual([]);
82 expect(attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org");
83 expect(attributes["url.query"]).toBe("owner=org");
84 });
85
86 it("strips the other sensitive OAuth parameters", () => {
87 const attributes: Record<string, unknown> = {
88 "url.query":
89 "id_token=synthetic-id-token&session_state=synthetic-session&error_description=synthetic-detail&error=access_denied",
90 };
91
92 expect(redactSpanUrlAttributes(attributes)).toEqual([
93 "error_description",
94 "id_token",
95 "session_state",
96 ]);
97 // `error` is an enumerable code, not a secret — it stays.
98 expect(attributes["url.query"]).toBe("error=access_denied");
99 });
100});
101
102describe("UrlRedactingSpanProcessor", () => {
103 it("scrubs the span before the exporter sees it", () => {
104 const exported = exportSpanWith({
105 "url.full": callbackUrl,
106 "url.query": `code=${CODE}&state=${STATE}`,
107 "url.path": "/api/oauth/callback",
108 });
109
110 expect(exported).toBeDefined();
111 expect(JSON.stringify(exported?.attributes)).not.toContain(CODE);
112 expect(JSON.stringify(exported?.attributes)).not.toContain(STATE);
113 expect(exported?.attributes["url.path"]).toBe("/api/oauth/callback");
114 expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("code,state");
115 });
116
117 it("leaves a span with no sensitive parameters unchanged", () => {
118 const exported = exportSpanWith({
119 "url.full": "https://app.test/api/integrations?owner=org",
120 "url.query": "owner=org",
121 });
122
123 expect(exported?.attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org");
124 expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBeUndefined();
125 });
126});
apps/cloud/src/observability/redact-span-urls.ts+189−0
@@ -0,0 +1,189 @@
1// ---------------------------------------------------------------------------
2// URL-attribute redaction for exported spans.
3//
4// Effect's `HttpMiddleware.tracer` stamps `url.full` and `url.query`
5// unconditionally on every `http.server` span (see
6// `effect/unstable/http/HttpMiddleware.ts` — it redacts URL userinfo and
7// configured header names, and nothing else). `/api/oauth/callback` is an
8// app-owned path, so its span carried the provider's `?code=…&state=…`
9// verbatim to the trace backend on every OAuth connect.
10//
11// The scrub runs at the span-processor seam rather than at the route, because
12// this is the only chokepoint every span in the isolate must pass through on
13// its way to the exporter — worker spans, Effect spans, Durable Object spans,
14// and any route added later. A per-route middleware or a `TracerDisabledWhen`
15// override would only cover the routes someone remembered to wire it into, and
16// overriding the middleware's attribute handling would mean forking Effect
17// internals.
18//
19// `url.path` is deliberately preserved: route-level visibility is what makes
20// these traces worth exporting at all. Only the sensitive query parameters are
21// removed, and their presence is recorded as a stripped-key list so a trace
22// still shows that the request carried a `code`, without its value.
23// ---------------------------------------------------------------------------
24
25import type { Context } from "@opentelemetry/api";
26import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
27
28/** Query parameters that are credentials, single-use grants, or CSRF secrets.
29 * Matched case-insensitively against the parameter name. */
30const SENSITIVE_QUERY_KEYS: ReadonlySet<string> = new Set([
31 "access_token",
32 "client_secret",
33 "code",
34 "code_verifier",
35 "error_description",
36 "id_token",
37 "refresh_token",
38 "session_state",
39 "state",
40 "token",
41]);
42
43/** Span attributes whose value is a whole URL. */
44const URL_ATTRIBUTES = ["url.full", "http.url"] as const;
45const QUERY_ATTRIBUTE = "url.query";
46/** Names of the parameters removed from this span's URL attributes. Non-secret
47 * by construction — it is the key list, never the values. */
48export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys";
49
50const isSensitive = (key: string): boolean => SENSITIVE_QUERY_KEYS.has(key.toLowerCase());
51
52/** How deep to follow a query parameter whose value is itself a URL or path
53 * with a query string. Depth 2 covers the real nesting in this app —
54 * `/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fcode%3D…` (see
55 * `auth/return-to.ts` and the sign-in redirect in `start.ts`) — with headroom,
56 * and bounds the work per span. */
57const MAX_NESTED_DEPTH = 2;
58
59/** The query string with every sensitive parameter dropped, plus the names
60 * dropped. Parsed with `URLSearchParams` so encoding round-trips correctly.
61 *
62 * A parameter whose own value carries a nested query string is redacted
63 * recursively rather than dropped: the login redirect round-trips the whole
64 * OAuth callback path through `returnTo`, so the credential rides inside
65 * another parameter's value and a top-level key check alone would miss it. */
66const redactQuery = (
67 query: string,
68 depth = 0,
69): { readonly query: string; readonly stripped: readonly string[] } => {
70 const params = new URLSearchParams(query);
71 const stripped = new Set<string>();
72 for (const key of Array.from(new Set(params.keys()))) {
73 if (isSensitive(key)) {
74 stripped.add(key);
75 params.delete(key);
76 continue;
77 }
78 if (depth >= MAX_NESTED_DEPTH) continue;
79 const values = params.getAll(key);
80 const rewritten = values.map((value) => {
81 const separator = value.indexOf("?");
82 if (separator === -1) return value;
83 const nested = redactQuery(value.slice(separator + 1), depth + 1);
84 for (const name of nested.stripped) stripped.add(`${key}.${name}`);
85 return nested.stripped.length === 0
86 ? value
87 : `${value.slice(0, separator)}${nested.query === "" ? "" : `?${nested.query}`}`;
88 });
89 if (rewritten.every((value, index) => value === values[index])) continue;
90 params.delete(key);
91 for (const value of rewritten) params.append(key, value);
92 }
93 return stripped.size === 0
94 ? { query, stripped: [] }
95 : { query: params.toString(), stripped: Array.from(stripped).sort() };
96};
97
98/** The URL with every sensitive query parameter dropped. Path, host, and
99 * scheme are untouched. Unparseable values are dropped entirely rather than
100 * passed through — if it cannot be parsed it cannot be proven safe. */
101const redactUrl = (
102 value: string,
103): { readonly url: string; readonly stripped: readonly string[] } => {
104 if (!URL.canParse(value)) {
105 const { query, stripped } = redactQuery(value);
106 return { url: stripped.length === 0 ? value : query, stripped };
107 }
108 const url = new URL(value);
109 if (url.search === "") return { url: value, stripped: [] };
110 const { query, stripped } = redactQuery(url.search.slice(1));
111 if (stripped.length === 0) return { url: value, stripped: [] };
112 url.search = query;
113 return { url: url.toString(), stripped };
114};
115
116/** Rewrites the URL-bearing attributes of an in-flight span in place, dropping
117 * sensitive query parameters. Returns the parameter names removed. */
118export const redactSpanUrlAttributes = (attributes: Record<string, unknown>): readonly string[] => {
119 const stripped = new Set<string>();
120 for (const name of URL_ATTRIBUTES) {
121 const value = attributes[name];
122 if (typeof value !== "string") continue;
123 const result = redactUrl(value);
124 for (const key of result.stripped) stripped.add(key);
125 if (result.stripped.length > 0) attributes[name] = result.url;
126 }
127 const query = attributes[QUERY_ATTRIBUTE];
128 if (typeof query === "string") {
129 const result = redactQuery(query);
130 for (const key of result.stripped) stripped.add(key);
131 if (result.stripped.length > 0) attributes[QUERY_ATTRIBUTE] = result.query;
132 }
133 return Array.from(stripped).sort();
134};
135
136/** Wraps a span processor so every span is scrubbed of credential-bearing URL
137 * query parameters before the inner processor (and therefore the exporter)
138 * sees it.
139 *
140 * The rewrite happens in `onEnding`, the last hook the OTel SDK calls while
141 * the span is still mutable (`Span.end()` runs `onEnding` before setting
142 * `_ended`, so `setAttribute` still applies); `onEnd` receives a frozen
143 * `ReadableSpan`. `onEnding` is optional in the SpanProcessor interface, so
144 * `onEnd` re-checks the attribute bag and mutates it directly as a backstop
145 * for any SDK path that skips the earlier hook. */
146export class UrlRedactingSpanProcessor implements SpanProcessor {
147 constructor(private readonly inner: SpanProcessor) {}
148
149 forceFlush(): Promise<void> {
150 return this.inner.forceFlush();
151 }
152
153 onStart(span: Span, parentContext: Context): void {
154 this.inner.onStart(span, parentContext);
155 }
156
157 onEnding(span: Span): void {
158 this.redact(span.attributes, (key, value) => span.setAttribute(key, value));
159 this.inner.onEnding?.(span);
160 }
161
162 onEnd(span: ReadableSpan): void {
163 const attributes = span.attributes as Record<string, unknown>;
164 this.redact(attributes, (key, value) => {
165 attributes[key] = value;
166 });
167 this.inner.onEnd(span);
168 }
169
170 shutdown(): Promise<void> {
171 return this.inner.shutdown();
172 }
173
174 private redact(
175 attributes: Record<string, unknown>,
176 write: (key: string, value: string) => void,
177 ): void {
178 // Work on a copy so the redaction decision is made from the current values
179 // and applied through the caller's writer (span API vs direct mutation).
180 const draft: Record<string, unknown> = { ...attributes };
181 const stripped = redactSpanUrlAttributes(draft);
182 if (stripped.length === 0) return;
183 for (const name of [...URL_ATTRIBUTES, QUERY_ATTRIBUTE]) {
184 const value = draft[name];
185 if (typeof value === "string" && value !== attributes[name]) write(name, value);
186 }
187 write(STRIPPED_QUERY_ATTRIBUTE, stripped.join(","));
188 }
189}
apps/cloud/src/observability/telemetry.ts+7−1
@@ -51,6 +51,7 @@ import { · 50 unmodified lines
51 OTEL_MAX_SPAN_QUEUE_SIZE,51 OTEL_MAX_SPAN_QUEUE_SIZE,
52 recordForceFlush,52 recordForceFlush,
53} from "./memory-metrics";53} from "./memory-metrics";
54import { UrlRedactingSpanProcessor } from "./redact-span-urls";
54 55
55const SERVICE_NAME = "executor-cloud";56const SERVICE_NAME = "executor-cloud";
56const SERVICE_VERSION = "1.0.0";57const SERVICE_VERSION = "1.0.0";
@@ -93,7 +94,12 @@ const ensureGlobalTracerProvider = (): boolean => { · 36 unmodified lines
93 }),94 }),
94 OTEL_MAX_SPAN_QUEUE_SIZE,95 OTEL_MAX_SPAN_QUEUE_SIZE,
95 );96 );
96 return [countingProcessor];97 // Outermost wrapper: every span the isolate produces passes through here
98 // before it is queued for export, so credential-bearing query parameters
99 // (OAuth `code`/`state` on `/api/oauth/callback`, which Effect's
100 // HttpMiddleware.tracer stamps into `url.full`/`url.query`
101 // unconditionally) are stripped no matter which route emitted the span.
102 return [new UrlRedactingSpanProcessor(countingProcessor)];
97 })(),103 })(),
98 });104 });
99 // Skip `provider.register()` — its StackContextManager / W3C propagator105 // Skip `provider.register()` — its StackContextManager / W3C propagator
packages/core/sdk/src/index.ts+4−0
@@ -412,3 +412,7 @@ export { · 411 unmodified lines
412 insufficientScopeFromEmbeddedJson,412 insufficientScopeFromEmbeddedJson,
413 type InsufficientScopeDetection,413 type InsufficientScopeDetection,
414} from "./insufficient-scope";414} from "./insufficient-scope";
415
416// Endpoint sanitization for span attributes — plugins stamping a user-supplied
417// endpoint must strip its credential-bearing parts first.
418export { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint";
packages/core/sdk/src/telemetry-endpoint.test.ts+68−0
@@ -0,0 +1,68 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint";
4
5// Synthetic placeholders only.
6const QUERY_TOKEN = "synthetic-query-token";
7const USERINFO_PASSWORD = "synthetic-userinfo-password";
8
9describe("endpointForTelemetry", () => {
10 it("strips a credential carried in the query string", () => {
11 // The shape the MCP preset list ships and the add-flow passes through raw.
12 expect(endpointForTelemetry(`https://mcp.example.test/mcp?token=${QUERY_TOKEN}`)).toBe(
13 "https://mcp.example.test/mcp",
14 );
15 });
16
17 it("strips a credential carried in URL userinfo", () => {
18 expect(endpointForTelemetry(`https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp`)).toBe(
19 "https://mcp.example.test/mcp",
20 );
21 });
22
23 it("strips query, fragment, and userinfo together", () => {
24 const scrubbed = endpointForTelemetry(
25 `https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp?token=${QUERY_TOKEN}#frag`,
26 );
27 expect(scrubbed).toBe("https://mcp.example.test/mcp");
28 expect(scrubbed).not.toContain(QUERY_TOKEN);
29 expect(scrubbed).not.toContain(USERINFO_PASSWORD);
30 });
31
32 it("leaves a credential-free endpoint intact", () => {
33 expect(endpointForTelemetry("https://mcp.example.test/mcp")).toBe(
34 "https://mcp.example.test/mcp",
35 );
36 });
37
38 it("returns unparseable input as-is", () => {
39 expect(endpointForTelemetry("not a url")).toBe("not a url");
40 });
41});
42
43describe("endpointTelemetryAttributes", () => {
44 it("keeps the endpoint debuggable without exposing the credential", () => {
45 const attributes = endpointTelemetryAttributes(
46 "mcp.endpoint",
47 `https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp?token=${QUERY_TOKEN}`,
48 );
49
50 expect(attributes).toEqual({
51 "mcp.endpoint": "https://mcp.example.test/mcp",
52 "mcp.endpoint.origin": "https://mcp.example.test",
53 "mcp.endpoint.has_query": true,
54 "mcp.endpoint.has_userinfo": true,
55 });
56 expect(JSON.stringify(attributes)).not.toContain(QUERY_TOKEN);
57 expect(JSON.stringify(attributes)).not.toContain(USERINFO_PASSWORD);
58 });
59
60 it("reports absence of both credential shapes for a plain endpoint", () => {
61 expect(endpointTelemetryAttributes("mcp.endpoint", "https://mcp.example.test/mcp")).toEqual({
62 "mcp.endpoint": "https://mcp.example.test/mcp",
63 "mcp.endpoint.origin": "https://mcp.example.test",
64 "mcp.endpoint.has_query": false,
65 "mcp.endpoint.has_userinfo": false,
66 });
67 });
68});
packages/core/sdk/src/telemetry-endpoint.ts+54−0
@@ -0,0 +1,54 @@
1// ---------------------------------------------------------------------------
2// Endpoint sanitization for span attributes.
3//
4// User-supplied endpoints are a credential carrier: `?token=…` in the query
5// string and `user:pass@host` userinfo are both first-class supported input
6// shapes (the MCP preset list ships a query-token URL, and the add-flow passes
7// the raw paste straight through). Stamping such a URL verbatim onto a span
8// ships the credential to the trace backend.
9//
10// Span attributes may carry hostnames, paths, and booleans; they may never
11// carry the secret-bearing parts of a URL. `endpointForTelemetry` keeps the
12// scheme/host/path — the parts that make a trace debuggable — and drops the
13// query, fragment, and userinfo. `endpointTelemetryAttributes` adds the
14// non-sensitive companions (origin, and whether a query string was present) so
15// "the user pasted a URL with credentials in it" stays diagnosable without the
16// credential itself.
17// ---------------------------------------------------------------------------
18
19/** The endpoint with every credential-bearing component removed: query string,
20 * fragment, and `user:pass@` userinfo. Unparseable input is returned as-is —
21 * it is not a URL, so there is nothing to strip, and callers still want the
22 * literal for debugging a malformed paste. */
23export const endpointForTelemetry = (endpoint: string): string => {
24 if (!URL.canParse(endpoint)) return endpoint;
25 const url = new URL(endpoint);
26 url.search = "";
27 url.hash = "";
28 url.username = "";
29 url.password = "";
30 return url.toString();
31};
32
33/** Span attributes describing an endpoint without exposing its credentials.
34 * `<prefix>` is the sanitized URL, `<prefix>.origin` the scheme+host, and the
35 * two booleans record that a query string / userinfo was stripped. */
36export const endpointTelemetryAttributes = (
37 prefix: string,
38 endpoint: string,
39): Record<string, string | boolean> => {
40 if (!URL.canParse(endpoint)) {
41 return {
42 [prefix]: endpoint,
43 [`${prefix}.has_query`]: false,
44 [`${prefix}.has_userinfo`]: false,
45 };
46 }
47 const url = new URL(endpoint);
48 return {
49 [prefix]: endpointForTelemetry(endpoint),
50 [`${prefix}.origin`]: url.origin,
51 [`${prefix}.has_query`]: url.search !== "",
52 [`${prefix}.has_userinfo`]: url.username !== "" || url.password !== "",
53 };
54};
packages/plugins/graphql/src/sdk/invoke.ts+2−8
@@ -1,6 +1,8 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3 3
4import { endpointForTelemetry } from "@executor-js/sdk/core";
5
4import { GraphqlInvocationError } from "./errors";6import { GraphqlInvocationError } from "./errors";
5import { type OperationBinding, InvocationResult } from "./types";7import { type OperationBinding, InvocationResult } from "./types";
6 8
@@ -13,14 +15,6 @@ const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, s · 6 unmodified lines
13 return url.toString();15 return url.toString();
14};16};
15 17
16export const endpointForTelemetry = (endpoint: string): string => {
17 if (!URL.canParse(endpoint)) return endpoint;
18 const url = new URL(endpoint);
19 url.search = "";
20 url.hash = "";
21 return url.toString();
22};
23
24/** The operation string to send for a call. A caller-supplied `select` overrides18/** The operation string to send for a call. A caller-supplied `select` overrides
25 * the default scalar-leaf selection: it is spliced into the field's selection19 * the default scalar-leaf selection: it is spliced into the field's selection
26 * set (`field { <select> }`) so nested/list data can be requested per call. Falls20 * set (`field { <select> }`) so nested/list data can be requested per call. Falls
packages/plugins/graphql/src/sdk/plugin.test.ts+1−1
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16 ProviderKey,16 ProviderKey,
17 ToolAddress,17 ToolAddress,
18 createExecutor,18 createExecutor,
19 endpointForTelemetry,
19} from "@executor-js/sdk";20} from "@executor-js/sdk";
20import {21import {
21 makeTestConfig,22 makeTestConfig,
@@ -24,7 +25,6 @@ import { · 2 unmodified lines
24} from "@executor-js/sdk/testing";25} from "@executor-js/sdk/testing";
25 26
26import { graphqlPlugin } from "./plugin";27import { graphqlPlugin } from "./plugin";
27import { endpointForTelemetry } from "./invoke";
28import { introspect } from "./introspect";28import { introspect } from "./introspect";
29import type { IntrospectionResult } from "./introspect";29import type { IntrospectionResult } from "./introspect";
30import {30import {
packages/plugins/mcp/src/sdk/plugin.test.ts+83−1
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer, Option, Predicate, Schema } from "effect";2import { Effect, Layer, Option, Predicate, Schema, Tracer } from "effect";
3import {3import {
4 HttpClient,4 HttpClient,
5 HttpClientRequest,5 HttpClientRequest,
@@ -1034,3 +1034,85 @@ describe("mcpPlugin detect URL-token fallback", () => { · 1028 unmodified lines
1034 }),1034 }),
1035 );1035 );
1036});1036});
1037
1038describe("mcpPlugin endpoint telemetry", () => {
1039 // A credential in the endpoint's query string is a first-class supported
1040 // input shape here (the shipped preset list carries one, and the add-flow
1041 // passes the raw paste through), so the endpoint must be sanitized before it
1042 // is stamped onto a span. Synthetic placeholders only.
1043 const QUERY_TOKEN = "synthetic-endpoint-token";
1044 const USERINFO_PASSWORD = "synthetic-endpoint-password";
1045
1046 /** Records every span the program opens, so the stamped attributes can be
1047 * read back. Port 1 connection-refuses immediately, so detection resolves
1048 * without any network dependency. */
1049 const recordingTracer = (spans: Array<Tracer.NativeSpan>) =>
1050 Tracer.make({
1051 span: (options) => {
1052 const span = new Tracer.NativeSpan(options);
1053 spans.push(span);
1054 return span;
1055 },
1056 context: (primitive, fiber) => primitive["~effect/Effect/evaluate"](fiber),
1057 });
1058
1059 it.effect("stamps a sanitized endpoint on the detect span", () =>
1060 Effect.gen(function* () {
1061 const spans: Array<Tracer.NativeSpan> = [];
1062 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1063
1064 yield* executor.integrations
1065 .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`)
1066 .pipe(Effect.provideService(Tracer.Tracer, recordingTracer(spans)));
1067
1068 const detect = spans.find((span) => span.name === "mcp.plugin.detect");
1069 expect(detect).toBeDefined();
1070 expect(detect?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/api/mcp");
1071 // The non-sensitive companions keep the trace debuggable.
1072 expect(detect?.attributes.get("mcp.endpoint.origin")).toBe("http://127.0.0.1:1");
1073 expect(detect?.attributes.get("mcp.endpoint.has_query")).toBe(true);
1074 expect(detect?.attributes.get("mcp.endpoint.has_userinfo")).toBe(true);
1075
1076 // Scoped to the plugin's own spans. Effect's HttpClient separately
1077 // stamps `url.full`/`url.query` on its outgoing client spans
1078 // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed
1079 // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`,
1080 // which is not installed at this level.
1081 const serialized = JSON.stringify(
1082 spans
1083 .filter((span) => span.name.startsWith("mcp.plugin."))
1084 .map((span) => Object.fromEntries(span.attributes.entries())),
1085 );
1086 expect(serialized).not.toContain(QUERY_TOKEN);
1087 expect(serialized).not.toContain(USERINFO_PASSWORD);
1088 }),
1089 );
1090
1091 it.effect("stamps a sanitized endpoint on the probe_endpoint span", () =>
1092 Effect.gen(function* () {
1093 const spans: Array<Tracer.NativeSpan> = [];
1094 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
1095
1096 yield* executor.mcp
1097 .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`)
1098 .pipe(Effect.exit, Effect.provideService(Tracer.Tracer, recordingTracer(spans)));
1099
1100 const probe = spans.find((span) => span.name === "mcp.plugin.probe_endpoint");
1101 expect(probe).toBeDefined();
1102 expect(probe?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/mcp");
1103 expect(probe?.attributes.get("mcp.endpoint.has_query")).toBe(true);
1104
1105 // Scoped to the plugin's own spans. Effect's HttpClient separately
1106 // stamps `url.full`/`url.query` on its outgoing client spans
1107 // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed
1108 // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`,
1109 // which is not installed at this level.
1110 const serialized = JSON.stringify(
1111 spans
1112 .filter((span) => span.name.startsWith("mcp.plugin."))
1113 .map((span) => Object.fromEntries(span.attributes.entries())),
1114 );
1115 expect(serialized).not.toContain(QUERY_TOKEN);
1116 }),
1117 );
1118});
packages/plugins/mcp/src/sdk/plugin.ts+10−2
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 AuthTemplateSlug,10 AuthTemplateSlug,
11 ConnectionName,11 ConnectionName,
12 definePlugin,12 definePlugin,
13 endpointTelemetryAttributes,
13 IntegrationAlreadyExistsError,14 IntegrationAlreadyExistsError,
14 IntegrationSlug,15 IntegrationSlug,
15 mergeAuthTemplates,16 mergeAuthTemplates,
@@ -896,7 +897,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 880 unmodified lines
896 });897 });
897 }).pipe(898 }).pipe(
898 Effect.withSpan("mcp.plugin.probe_endpoint", {899 Effect.withSpan("mcp.plugin.probe_endpoint", {
899 attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint },900 // The probed endpoint is raw user paste and routinely carries a
901 // credential in its query string (`?token=…`) — sanitize before
902 // stamping.
903 attributes: endpointTelemetryAttributes(
904 "mcp.endpoint",
905 typeof input === "string" ? input : input.endpoint,
906 ),
900 }),907 }),
901 );908 );
902 909
@@ -1486,7 +1493,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 583 unmodified lines
1486 }).pipe(1493 }).pipe(
1487 Effect.catch(() => Effect.succeed(null)),1494 Effect.catch(() => Effect.succeed(null)),
1488 Effect.withSpan("mcp.plugin.detect", {1495 Effect.withSpan("mcp.plugin.detect", {
1489 attributes: { "mcp.endpoint": url },1496 // Same raw-paste input as probe_endpoint — sanitize before stamping.
1497 attributes: endpointTelemetryAttributes("mcp.endpoint", url),
1490 }),1498 }),
1491 ),1499 ),
1492 1500