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 @@
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