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

Showing the changes to packages/plugins (4 of 12 files). Show all

Unified · Split

4 files
  1. packages/plugins/graphql/src/sdk/invoke.ts +2 −8
  2. packages/plugins/graphql/src/sdk/plugin.test.ts +1 −1
  3. packages/plugins/mcp/src/sdk/plugin.test.ts +83 −1
  4. packages/plugins/mcp/src/sdk/plugin.ts +10 −2
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