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+
5+Stop exporting credential-bearing URLs in span attributes. OAuth callback
6+authorization codes and CSRF state are stripped from span URL attributes before
7+export, and user-supplied MCP endpoints are sanitized of query-string and
8+userinfo 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+
16+import { describe, expect, it } from "@effect/vitest";
17+import * as Resource from "@effect/opentelemetry/Resource";
18+import * as OtelTracer from "@effect/opentelemetry/Tracer";
19+import {
20+ InMemorySpanExporter,
21+ SimpleSpanProcessor,
22+ type ReadableSpan,
23+} from "@opentelemetry/sdk-trace-base";
24+import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
25+import { Context, Effect, Layer } from "effect";
26+import { HttpRouter, HttpServer } from "effect/unstable/http";
27+import { HttpApiBuilder } from "effect/unstable/httpapi";
28+
29+import { ExecutorApi, observabilityMiddleware } from "@executor-js/api";
30+import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";
31+import { createExecutor } from "@executor-js/sdk";
32+import { makeTestConfig } from "@executor-js/sdk/testing";
33+
34+import { UrlRedactingSpanProcessor } from "./redact-span-urls";
35+
36+// Synthetic placeholders — never a real authorization code or CSRF state.
37+const CODE = "synthetic-authorization-code-9f2c";
38+const STATE = "synthetic-csrf-state-4b7e";
39+
40+const 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+
54+describe("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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ BasicTracerProvider,
4+ InMemorySpanExporter,
5+ SimpleSpanProcessor,
6+ type ReadableSpan,
7+} from "@opentelemetry/sdk-trace-base";
8+
9+import {
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.
16+const CODE = "synthetic-authorization-code";
17+const STATE = "synthetic-csrf-state";
18+
19+const 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. */
25+const 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+
36+describe("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+
102+describe("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+
25+import type { Context } from "@opentelemetry/api";
26+import 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. */
30+const 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. */
44+const URL_ATTRIBUTES = ["url.full", "http.url"] as const;
45+const 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. */
48+export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys";
49+
50+const 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. */
57+const 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. */
66+const 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. */
101+const 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. */
118+export 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. */
146+export 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
5151 OTEL_MAX_SPAN_QUEUE_SIZE,
5252 recordForceFlush,
5353 } from "./memory-metrics";
54+import { UrlRedactingSpanProcessor } from "./redact-span-urls";
5455
5556 const SERVICE_NAME = "executor-cloud";
5657 const SERVICE_VERSION = "1.0.0";
@@ -93,7 +94,12 @@ const ensureGlobalTracerProvider = (): boolean => { · 36 unmodified lines
9394 }),
9495 OTEL_MAX_SPAN_QUEUE_SIZE,
9596 );
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)];
97103 })(),
98104 });
99105 // Skip `provider.register()` — its StackContextManager / W3C propagator
packages/core/sdk/src/index.ts+4−0
@@ -412,3 +412,7 @@ export { · 411 unmodified lines
412412 insufficientScopeFromEmbeddedJson,
413413 type InsufficientScopeDetection,
414414 } from "./insufficient-scope";
415+
416+// Endpoint sanitization for span attributes — plugins stamping a user-supplied
417+// endpoint must strip its credential-bearing parts first.
418+export { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint";
packages/core/sdk/src/telemetry-endpoint.test.ts+68−0
@@ -0,0 +1,68 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint";
4+
5+// Synthetic placeholders only.
6+const QUERY_TOKEN = "synthetic-query-token";
7+const USERINFO_PASSWORD = "synthetic-userinfo-password";
8+
9+describe("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+
43+describe("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. */
23+export 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. */
36+export 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 @@
11 import { Effect, Layer, Option } from "effect";
22 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
33
4+import { endpointForTelemetry } from "@executor-js/sdk/core";
5+
46 import { GraphqlInvocationError } from "./errors";
57 import { type OperationBinding, InvocationResult } from "./types";
68
@@ -13,14 +15,6 @@ const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, s · 6 unmodified lines
1315 return url.toString();
1416 };
1517
16−export 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−
2418 /** The operation string to send for a call. A caller-supplied `select` overrides
2519 * the default scalar-leaf selection: it is spliced into the field's selection
2620 * 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
1616 ProviderKey,
1717 ToolAddress,
1818 createExecutor,
19+ endpointForTelemetry,
1920 } from "@executor-js/sdk";
2021 import {
2122 makeTestConfig,
@@ -24,7 +25,6 @@ import { · 2 unmodified lines
2425 } from "@executor-js/sdk/testing";
2526
2627 import { graphqlPlugin } from "./plugin";
27−import { endpointForTelemetry } from "./invoke";
2828 import { introspect } from "./introspect";
2929 import type { IntrospectionResult } from "./introspect";
3030 import {
packages/plugins/mcp/src/sdk/plugin.test.ts+83−1
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Layer, Option, Predicate, Schema } from "effect";
2+import { Effect, Layer, Option, Predicate, Schema, Tracer } from "effect";
33 import {
44 HttpClient,
55 HttpClientRequest,
@@ -1034,3 +1034,85 @@ describe("mcpPlugin detect URL-token fallback", () => { · 1028 unmodified lines
10341034 }),
10351035 );
10361036 });
1037+
1038+describe("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
1010 AuthTemplateSlug,
1111 ConnectionName,
1212 definePlugin,
13+ endpointTelemetryAttributes,
1314 IntegrationAlreadyExistsError,
1415 IntegrationSlug,
1516 mergeAuthTemplates,
@@ -896,7 +897,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 880 unmodified lines
896897 });
897898 }).pipe(
898899 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+ ),
900907 }),
901908 );
902909
@@ -1486,7 +1493,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 583 unmodified lines
14861493 }).pipe(
14871494 Effect.catch(() => Effect.succeed(null)),
14881495 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),
14901498 }),
14911499 ),
14921500