The redaction checked only top-level keys. That was sufficient while values were strings — a secret could not hide beneath one — but #1910 lets a value be an object, so `{ trace: { accessToken: "…" } }` printed in full: `trace` is not sensitive, so the whole subtree went out with it. `servers/show` output is meant to be safe to paste into an issue, so this is a regression this PR introduced rather than a pre-existing gap. `redactMetadataValue` walks the payload: a key matching `isSensitiveHeader` replaces its entire value (so a sensitive key holding an object cannot leak through its members either), anything else is descended into, and arrays are mapped elementwise since objects inside them can carry sensitive keys. Recursion is bounded — the payload came from `JSON.parse`, so it is a finite tree with no cycles. Tests cover a secret two levels under a non-sensitive key and one inside an array element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
import {
|
|
createSampleTestConfig,
|
|
createTestConfig,
|
|
deleteConfigFile,
|
|
} from "./helpers/fixtures.js";
|
|
import { expectCliSuccess } from "./helpers/assertions.js";
|
|
import {
|
|
annotateServerEntriesWithSessions,
|
|
listServerEntries,
|
|
sanitizeServerConfig,
|
|
sanitizeServerSettings,
|
|
showServerEntry,
|
|
summarizeServerConfig,
|
|
} from "../src/handlers/servers-list.js";
|
|
import type {
|
|
InspectorServerSettings,
|
|
MCPServerConfig,
|
|
} from "@inspector/core/mcp/types.js";
|
|
import { expectCliFailure } from "./helpers/assertions.js";
|
|
import {
|
|
InMemorySecretStore,
|
|
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
|
envSecretField,
|
|
} from "@inspector/core/auth/node/secret-store.js";
|
|
|
|
describe("summarizeServerConfig", () => {
|
|
it("summarises stdio, sse, and streamable-http configs", () => {
|
|
expect(
|
|
summarizeServerConfig({
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["server.js"],
|
|
}),
|
|
).toEqual({ type: "stdio", detail: "node server.js" });
|
|
expect(
|
|
summarizeServerConfig({
|
|
type: "stdio",
|
|
command: "node",
|
|
}),
|
|
).toEqual({ type: "stdio", detail: "node" });
|
|
expect(
|
|
summarizeServerConfig({
|
|
type: "sse",
|
|
url: "http://localhost/sse",
|
|
}),
|
|
).toEqual({ type: "sse", detail: "http://localhost/sse" });
|
|
expect(
|
|
summarizeServerConfig({
|
|
type: "streamable-http",
|
|
url: "http://localhost/mcp",
|
|
} as MCPServerConfig),
|
|
).toEqual({ type: "streamable-http", detail: "http://localhost/mcp" });
|
|
expect(
|
|
summarizeServerConfig({
|
|
type: "streamable-http",
|
|
} as MCPServerConfig),
|
|
).toEqual({ type: "streamable-http", detail: "" });
|
|
});
|
|
});
|
|
|
|
describe("annotateServerEntriesWithSessions", () => {
|
|
const entries = [
|
|
{ name: "a", type: "stdio", detail: "node a" },
|
|
{ name: "b", type: "stdio", detail: "node b" },
|
|
];
|
|
|
|
it("returns entries unchanged when there are no sessions", () => {
|
|
expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries);
|
|
});
|
|
|
|
it("marks matching entry names and MRU", () => {
|
|
expect(
|
|
annotateServerEntriesWithSessions(entries, [
|
|
{ name: "b", isMru: true },
|
|
{ name: "other" },
|
|
]),
|
|
).toEqual([
|
|
{ name: "a", type: "stdio", detail: "node a" },
|
|
{ name: "b", type: "stdio", detail: "node b", session: "b", isMru: true },
|
|
]);
|
|
});
|
|
|
|
it("omits isMru when the session is not MRU", () => {
|
|
expect(
|
|
annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]),
|
|
).toEqual([
|
|
{ name: "a", type: "stdio", detail: "node a", session: "a" },
|
|
{ name: "b", type: "stdio", detail: "node b" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("listServerEntries / --method servers/list", () => {
|
|
let configPath: string | undefined;
|
|
|
|
afterEach(() => {
|
|
if (configPath) {
|
|
deleteConfigFile(configPath);
|
|
configPath = undefined;
|
|
}
|
|
});
|
|
|
|
it("lists catalog entries without connecting", async () => {
|
|
configPath = createSampleTestConfig();
|
|
const entries = await listServerEntries({ configPath });
|
|
expect(entries.map((e) => e.name).sort()).toEqual([
|
|
"test-http",
|
|
"test-stdio",
|
|
]);
|
|
expect(entries.find((e) => e.name === "test-stdio")?.type).toBe("stdio");
|
|
});
|
|
|
|
it("works via --method servers/list", async () => {
|
|
configPath = createSampleTestConfig();
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--method",
|
|
"servers/list",
|
|
"--format",
|
|
"json",
|
|
]);
|
|
expectCliSuccess(result);
|
|
const body = JSON.parse(result.stdout) as {
|
|
result: { servers: { name: string }[] };
|
|
};
|
|
expect(body.result.servers.map((s) => s.name).sort()).toEqual([
|
|
"test-http",
|
|
"test-stdio",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("showServerEntry / servers/show", () => {
|
|
let configPath: string | undefined;
|
|
|
|
afterEach(() => {
|
|
if (configPath) {
|
|
deleteConfigFile(configPath);
|
|
configPath = undefined;
|
|
}
|
|
});
|
|
|
|
it("redacts env values, init headers, and sensitive settings fields", () => {
|
|
expect(
|
|
sanitizeServerConfig({
|
|
type: "stdio",
|
|
command: "node",
|
|
env: { SECRET: "value", HELLO: "world" },
|
|
}),
|
|
).toEqual({
|
|
type: "stdio",
|
|
command: "node",
|
|
env: { SECRET: "[redacted]", HELLO: "[redacted]" },
|
|
});
|
|
|
|
expect(
|
|
sanitizeServerConfig({
|
|
type: "streamable-http",
|
|
url: "https://example.com/mcp",
|
|
requestInit: {
|
|
headers: {
|
|
Authorization: "Bearer secret",
|
|
Cookie: "session=abc",
|
|
"X-Auth": "t",
|
|
"Proxy-Authorization": "Basic x",
|
|
"X-Custom": "ok",
|
|
},
|
|
},
|
|
eventSourceInit: {
|
|
headers: {
|
|
"X-Api-Key": "k",
|
|
Accept: "text/event-stream",
|
|
},
|
|
},
|
|
} as MCPServerConfig),
|
|
).toMatchObject({
|
|
requestInit: {
|
|
headers: {
|
|
Authorization: "[redacted]",
|
|
Cookie: "[redacted]",
|
|
"X-Auth": "[redacted]",
|
|
"Proxy-Authorization": "[redacted]",
|
|
"X-Custom": "ok",
|
|
},
|
|
},
|
|
eventSourceInit: {
|
|
headers: {
|
|
"X-Api-Key": "[redacted]",
|
|
Accept: "text/event-stream",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
sanitizeServerConfig({
|
|
type: "streamable-http",
|
|
url: "https://example.com/mcp",
|
|
requestInit: {
|
|
headers: [
|
|
["Authorization", "Bearer secret"],
|
|
["X-Custom", "ok"],
|
|
],
|
|
},
|
|
} as MCPServerConfig),
|
|
).toMatchObject({
|
|
requestInit: {
|
|
headers: [
|
|
["Authorization", "[redacted]"],
|
|
["X-Custom", "ok"],
|
|
],
|
|
},
|
|
});
|
|
|
|
const settings: InspectorServerSettings = {
|
|
headers: [
|
|
{ key: "Authorization", value: "Bearer x" },
|
|
{ key: "X-Custom", value: "ok" },
|
|
],
|
|
metadata: {
|
|
Authorization: "Bearer meta",
|
|
"X-Custom": "ok",
|
|
// A structured value under a sensitive key: the redaction must replace
|
|
// the whole value, not walk into it (#1910).
|
|
"X-Api-Key": { primary: "sk-live-1", fallback: "sk-live-2" },
|
|
nested: { keep: true },
|
|
// A secret buried under a non-sensitive key. A top-level-only check
|
|
// would print `accessToken` in full.
|
|
trace: {
|
|
id: "t-1",
|
|
accessToken: "sk-live-3",
|
|
deeper: { refresh_token: "sk-live-4", ok: 1 },
|
|
},
|
|
// Objects inside an array are reached too.
|
|
attempts: [{ password: "hunter2" }, { attempt: 2 }],
|
|
},
|
|
env: [
|
|
{ key: "TOKEN", value: "secret" },
|
|
{ key: "", value: "still-secret" },
|
|
],
|
|
connectionTimeout: 0,
|
|
requestTimeout: 0,
|
|
taskTtl: 60_000,
|
|
maxFetchRequests: 100,
|
|
roots: [],
|
|
oauthClientSecret: "shh",
|
|
oauthClientId: "cid",
|
|
};
|
|
const sanitized = sanitizeServerSettings(settings);
|
|
expect(sanitized.oauthClientSecret).toBe("[redacted]");
|
|
expect(sanitized.oauthClientId).toBe("cid");
|
|
expect(sanitized.headers).toEqual([
|
|
{ key: "Authorization", value: "[redacted]" },
|
|
{ key: "X-Custom", value: "ok" },
|
|
]);
|
|
expect(sanitized.metadata).toEqual({
|
|
Authorization: "[redacted]",
|
|
"X-Custom": "ok",
|
|
"X-Api-Key": "[redacted]",
|
|
// A structured value under a non-sensitive key survives intact...
|
|
nested: { keep: true },
|
|
// ...but a sensitive key nested inside one does not.
|
|
trace: {
|
|
id: "t-1",
|
|
accessToken: "[redacted]",
|
|
deeper: { refresh_token: "[redacted]", ok: 1 },
|
|
},
|
|
attempts: [{ password: "[redacted]" }, { attempt: 2 }],
|
|
});
|
|
expect(sanitized.env).toEqual([
|
|
{ key: "TOKEN", value: "[redacted]" },
|
|
{ key: "", value: "[redacted]" },
|
|
]);
|
|
});
|
|
|
|
it("shows one resolved entry without connecting", async () => {
|
|
configPath = createSampleTestConfig();
|
|
const entry = await showServerEntry("test-stdio", {
|
|
configPath,
|
|
secretStore: new InMemorySecretStore(),
|
|
});
|
|
expect(entry.name).toBe("test-stdio");
|
|
expect(entry.type).toBe("stdio");
|
|
expect(entry.config).toMatchObject({
|
|
type: "stdio",
|
|
command: expect.any(String),
|
|
});
|
|
expect(entry.config.env).toEqual({ HELLO: "[redacted]" });
|
|
});
|
|
|
|
it("redacts secrets rehydrated from an injected secret store", async () => {
|
|
configPath = createTestConfig({
|
|
mcpServers: {
|
|
"secret-stdio": {
|
|
type: "stdio",
|
|
command: "node",
|
|
env: { HELLO: "" },
|
|
},
|
|
"secret-http": {
|
|
type: "streamable-http",
|
|
url: "https://example.com/mcp",
|
|
// Disk keeps clientId only; secret lives in the store.
|
|
oauth: { clientId: "cid" },
|
|
} as MCPServerConfig,
|
|
},
|
|
});
|
|
const secretStore = new InMemorySecretStore();
|
|
await secretStore.set(
|
|
"secret-stdio",
|
|
envSecretField("HELLO"),
|
|
"top-secret-env",
|
|
);
|
|
await secretStore.set(
|
|
"secret-http",
|
|
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
|
"top-secret-oauth",
|
|
);
|
|
|
|
const stdio = await showServerEntry("secret-stdio", {
|
|
configPath,
|
|
secretStore,
|
|
});
|
|
expect(stdio.config.env).toEqual({ HELLO: "[redacted]" });
|
|
expect(JSON.stringify(stdio)).not.toContain("top-secret-env");
|
|
|
|
const http = await showServerEntry("secret-http", {
|
|
configPath,
|
|
secretStore,
|
|
});
|
|
expect(http.settings?.oauthClientSecret).toBe("[redacted]");
|
|
expect(http.settings?.oauthClientId).toBe("cid");
|
|
expect(JSON.stringify(http)).not.toContain("top-secret-oauth");
|
|
});
|
|
|
|
it("works via --method servers/show --server", async () => {
|
|
configPath = createSampleTestConfig();
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--method",
|
|
"servers/show",
|
|
"--server",
|
|
"test-http",
|
|
"--format",
|
|
"json",
|
|
]);
|
|
expectCliSuccess(result);
|
|
const body = JSON.parse(result.stdout) as {
|
|
result: { name: string; type: string };
|
|
};
|
|
expect(body.result.name).toBe("test-http");
|
|
expect(body.result.type).toBe("streamable-http");
|
|
});
|
|
|
|
it("rejects servers/show without --server", async () => {
|
|
configPath = createSampleTestConfig();
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--method",
|
|
"servers/show",
|
|
]);
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toMatch(/--server/);
|
|
});
|
|
|
|
it("rejects unknown server names", async () => {
|
|
configPath = createSampleTestConfig();
|
|
await expect(
|
|
showServerEntry("nope", {
|
|
configPath,
|
|
secretStore: new InMemorySecretStore(),
|
|
}),
|
|
).rejects.toThrow(/not found/);
|
|
});
|
|
});
|