diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 800fa0d2a4..c21b82f7af 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -178,3 +178,13 @@ An unusable `workspaceRoot` reports its directory constraint as a configuration error before opening an IDA worker. Native JSON metadata resources accept omitted media types and normal case/parameter variants of `application/json`; their resource URI, JSON structure and recorded target identity remain checked. + +The public profile's registration digest excludes HTTP authentication header +values (`Authorization`, `Proxy-Authorization`, and `Cookie`). Mark any other +credential fields explicitly with `"sensitive": { "headers": ["X-Api-Key"], +"env": ["PRIVATE_VALUE"] }` in the registration. Header names match without +case; environment names match exactly. These values are also redacted from IDA +diagnostics. Ordinary fields such as `IDADIR` and noncredential headers remain +part of analysis identity. Credential rotation does not change that public +identity; each newly resolved client factory still captures its own complete +transport registration and creates a fresh connection. diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index c3db50c80a..97684fdfdd 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -21,6 +21,12 @@ export const DEFAULT_IDA_REQUEST_TIMEOUT_MS = 300_000; export const MAX_IDA_REQUEST_TIMEOUT_MS = 2_147_483_647; const common = { + sensitive: z + .strictObject({ + headers: z.array(z.string().min(1)).default([]), + env: z.array(z.string().min(1)).default([]), + }) + .optional(), mode: z.enum(["attached", "headless"]).default("attached"), protocol: z.literal("native").optional(), timeoutMs: z diff --git a/src/ida/IdaDiagnosticRedactor.test.ts b/src/ida/IdaDiagnosticRedactor.test.ts index 0c65e37d31..188f9fb429 100644 --- a/src/ida/IdaDiagnosticRedactor.test.ts +++ b/src/ida/IdaDiagnosticRedactor.test.ts @@ -561,3 +561,27 @@ describe("unclassified diagnostic blocks and message ownership", () => { ).not.toContain("omitted"); }); }); + +it("redacts explicitly marked registration values without guessing from environment names", () => { + const environment = createIdaDiagnosticRedactor({ + command: "fixture", + args: [], + env: { PRIVATE_VALUE: "marked-secret", IDADIR: "/caller/ida" }, + sensitive: { env: ["PRIVATE_VALUE"], headers: [] }, + mode: "attached", + timeoutMs: 1000, + }); + expect(environment.text("marked-secret /caller/ida")).toBe( + "[redacted transport credential] /caller/ida", + ); + const headers = createIdaDiagnosticRedactor({ + url: "http://localhost:8745/mcp", + headers: { "X-Custom": "marked-header", "X-Input": "retained" }, + sensitive: { headers: ["x-custom"], env: [] }, + mode: "attached", + timeoutMs: 1000, + }); + expect(headers.text("marked-header retained")).toBe( + "[redacted transport credential] retained", + ); +}); diff --git a/src/ida/IdaDiagnosticRedactor.ts b/src/ida/IdaDiagnosticRedactor.ts index d332d15488..e705ce5d4f 100644 --- a/src/ida/IdaDiagnosticRedactor.ts +++ b/src/ida/IdaDiagnosticRedactor.ts @@ -8,6 +8,10 @@ import { writeIdaDiagnostic, type IdaDiagnosticMatch, } from "./IdaDiagnosticWriter.js"; +import { + isIdaAuthenticationHeader, + idaExplicitSensitiveValues, +} from "./IdaRegistrationIdentity.js"; import type { IdaConfiguration } from "./IdaConfiguration.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; @@ -151,8 +155,7 @@ export const createIdaDiagnosticRedactor = ( ? Object.entries(config.headers) : []; const values = headers.flatMap(([name, value]) => { - if (!/^(?:authorization|proxy-authorization|cookie)$/iu.test(name)) - return []; + if (!isIdaAuthenticationHeader(name)) return []; // Fetch normalizes surrounding HTTP whitespace before sending headers. const normalized = value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/gu, ""); if (normalized.length === 0) return []; @@ -164,9 +167,13 @@ export const createIdaDiagnosticRedactor = ( : authorizationPayloads(normalized)), ]; }); - const secrets = [...new Set(values.filter((value) => value.length > 0))].sort( - (left, right) => right.length - left.length, - ); + const secrets = [ + ...new Set( + [...values, ...idaExplicitSensitiveValues(config)].filter( + (value) => value.length > 0, + ), + ), + ].sort((left, right) => right.length - left.length); const replacement = secrets.length === 0 ? IDA_REDACTION_MARKER : redactionMarker(secrets); const alternatives = secrets diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts index bcac7dd0e1..5151d42fef 100644 --- a/src/ida/IdaProvider.test.ts +++ b/src/ida/IdaProvider.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { writeFile } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { parseConfig } from "../config/parseConfig.js"; +import { + RecordingIdaMcp, + createIdaTarget, +} from "../../tests/fixtures/idaMcp.js"; import { IdaProvider } from "./IdaProvider.js"; it.each([ @@ -140,3 +144,84 @@ describe("IDA provider target support", () => { }); }); }); + +it.each(["http", "stdio"] as const)( + "excludes declared %s credentials from profile identity while binding fresh clients to their selected values", + async (transport) => { + const { root, target } = await createIdaTarget(); + const path = join(root, "credential-registration.json"); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const selected: string[] = []; + const provider = new IdaProvider(config.value, {}, (registration) => { + selected.push( + "headers" in registration + ? registration.headers.Authorization! + : registration.env.PRIVATE_VALUE!, + ); + return new RecordingIdaMcp(target, "attached"); + }); + const profiles: string[] = []; + try { + for (const token of ["first credential", "second credential"]) { + await writeFile( + path, + JSON.stringify( + transport === "http" + ? { + url: "http://127.0.0.1:8745/mcp", + headers: { Authorization: token, "X-Input": "retained" }, + } + : { + command: "fixture", + env: { PRIVATE_VALUE: token, IDADIR: "/fixture/ida" }, + sensitive: { env: ["PRIVATE_VALUE"] }, + }, + ), + ); + const resolved = await provider.resolveAnalysisProfile(target); + if (!resolved.ok) throw resolved.error; + if ( + resolved.value.profile === null || + resolved.value.createClient === undefined + ) + throw new Error("Expected a bound profile"); + profiles.push(resolved.value.profile.digest); + const client = resolved.value.createClient( + target, + resolved.value.profile, + ); + await client.close(); + } + expect(profiles[0]).toBe(profiles[1]); + expect(selected).toEqual(["first credential", "second credential"]); + await writeFile( + path, + JSON.stringify( + transport === "http" + ? { + url: "http://127.0.0.1:8745/mcp", + headers: { + Authorization: "second credential", + "X-Input": "changed input", + }, + } + : { + command: "fixture", + env: { + PRIVATE_VALUE: "second credential", + IDADIR: "/changed/ida", + }, + sensitive: { env: ["PRIVATE_VALUE"] }, + }, + ), + ); + const changed = await provider.resolveAnalysisProfile(target); + if (!changed.ok) throw changed.error; + expect(changed.value.profile?.digest).not.toBe(profiles[0]); + } finally { + await provider.closeOwnedResources(); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index d3a6ee84fe..dabbbc9f38 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -21,10 +21,9 @@ import type { } from "../application/AnalysisProvider.js"; import type { AppConfig } from "../config/types.js"; import type { ConfigurationError } from "../domain/configurationErrors.js"; -import { createHash } from "node:crypto"; +import { idaRegistrationDigest } from "./IdaRegistrationIdentity.js"; import { isDeepStrictEqual } from "node:util"; import { analysisProfilesEqual } from "../domain/analysisProfile.js"; -import canonicalize from "canonicalize"; import type { Result } from "../domain/result.js"; import { readIdaConfiguration, @@ -345,9 +344,7 @@ export class IdaProvider implements AnalysisProviderCandidate { : "database-supervisor", database_revision: null, cache_policy: "live", - registration_digest: createHash("sha256") - .update(canonicalize(config.value) ?? "") - .digest("hex"), + registration_digest: idaRegistrationDigest(config.value), }); if (options?.scope !== undefined) this.#registrationByScope.delete(options.scope); diff --git a/src/ida/IdaRegistrationIdentity.ts b/src/ida/IdaRegistrationIdentity.ts new file mode 100644 index 0000000000..b96b111d74 --- /dev/null +++ b/src/ida/IdaRegistrationIdentity.ts @@ -0,0 +1,54 @@ +import { digestCanonicalValue } from "../domain/canonicalDigest.js"; +import type { IdaConfiguration } from "./IdaConfiguration.js"; + +/** HTTP authentication fields have protocol-defined credential meaning. */ +export const isIdaAuthenticationHeader = (name: string): boolean => + ["authorization", "proxy-authorization", "cookie"].includes( + name.toLowerCase(), + ); + +const isSensitiveHeader = (config: IdaConfiguration, name: string): boolean => + isIdaAuthenticationHeader(name) || + (config.sensitive?.headers.some( + (selected) => selected.toLowerCase() === name.toLowerCase(), + ) ?? + false); + +/** Values explicitly marked by the registration, without name-pattern guesses. */ +export const idaExplicitSensitiveValues = ( + config: IdaConfiguration | undefined, +): readonly string[] => { + if (config === undefined) return []; + return "headers" in config + ? Object.entries(config.headers) + .filter(([name]) => isSensitiveHeader(config, name)) + .map(([, value]) => value) + : Object.entries(config.env) + .filter(([name]) => config.sensitive?.env.includes(name)) + .map(([, value]) => value); +}; + +/** Credentials select a live connection, but do not identify analysis semantics. */ +export const idaRegistrationDigest = (config: IdaConfiguration): string => + digestCanonicalValue( + "headers" in config + ? { + ...config, + headers: Object.fromEntries( + Object.entries(config.headers).map(([name, value]) => [ + name, + isSensitiveHeader(config, name) ? null : value, + ]), + ), + } + : { + ...config, + env: Object.fromEntries( + Object.entries(config.env).map(([name, value]) => [ + name, + config.sensitive?.env.includes(name) ? null : value, + ]), + ), + }, + "IDA registration analysis identity", + );