mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(ida): exclude transport secrets from analysis identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
Reference in New Issue
Block a user