fix(ida): exclude transport secrets from analysis identity

This commit is contained in:
morluto committed 2026-10-24 02:41:04 +08:00
1 parent f3bb6b1ed0
commit 9927cd1677
7 files changed
+194 -11

No files matched your search

+10
View File
@@ -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.
+6
View File
@@ -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
+24
View File
@@ -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",
);
});
+12 -5
View File
@@ -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
+86 -1
View File
@@ -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 });
}
},
);
+2 -5
View File
@@ -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);
+54
View File
@@ -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",
);