fix(frida): retain selected discovery endpoint on failure (#7892)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 05:04:53 +08:00
1 parent 78a350e0b6
commit e03ff93913
8 files changed
+209 -5

No files matched your search

@@ -1203,6 +1203,9 @@ it("redacts remote credentials and maps a missing optional binding", async () =>
if (!failed.ok) {
expect(failed.error.message).not.toContain(secret);
expect(failed.error.message).not.toContain("synthetic-url-token");
expect(JSON.stringify(projectAnalysisError(failed.error))).not.toContain(
"synthetic-url-token",
);
}
const missing = new FridaInstrumentationManager({
@@ -1216,6 +1219,80 @@ it("redacts remote credentials and maps a missing optional binding", async () =>
expect(unavailable.error.message).toContain("Cannot find package 'frida'");
});
it("retains the selected device or credential-free remote address on discovery and startup failures", async () => {
const bindings = new FakeDeviceManager();
bindings.getDeviceById = async () => {
throw new Error("Device not found");
};
bindings.addRemoteDevice = async () => {
throw new Error("Unable to connect to remote frida-server");
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const device = await manager.listProcesses({ deviceId: "missing-device" });
expect(device.ok).toBe(false);
if (!device.ok) {
expect(projectAnalysisError(device.error)).toMatchObject({
code: "provider_timeout",
details: { diagnostics: { device_id: "missing-device" } },
});
}
const remote = { address: "127.0.0.1:27042", token: "secret-token" };
for (const result of [
await manager.listProcesses({ remote }),
await manager.listDevices(remote),
await manager.startSession({ mode: "attach", remote, pid: 512 }),
]) {
expect(result.ok).toBe(false);
if (result.ok) continue;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "capability_unavailable",
details: { diagnostics: { remote_address: remote.address } },
});
expect(JSON.stringify(projected)).not.toContain(remote.token);
expect(projected.remediation.action).toContain("selected Frida server");
}
});
it("redacts credentials from inherited typed failure diagnostics", async () => {
const bindings = new FakeDeviceManager();
bindings.addRemoteDevice = async () => {
throw new AnalysisTimeoutError("frida_device_lookup", 1000, {
diagnostics: { detail: "token=synthetic-secret-token" },
});
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const result = await manager.listProcesses({
remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { diagnostics: { remote_address: "127.0.0.1:27042" } },
});
expect(JSON.stringify(projected)).not.toContain("synthetic-secret-token");
});
it("does not describe a connected device's enumeration failure as a connection failure", async () => {
const bindings = new FakeDeviceManager();
bindings.device.enumerateProcesses = async () => {
throw new Error("process enumeration denied");
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const result = await manager.listProcesses({
remote: { address: "127.0.0.1:27042" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected.details?.diagnostics).toMatchObject({
remote_address: "127.0.0.1:27042",
});
expect(projected.remediation.action).not.toContain("selected Frida server");
});
it("clears current binding failure after recovery", async () => {
const bindings = new FakeDeviceManager();
let attempts = 0;
+1 -1
View File
@@ -13,7 +13,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { ok } from "../domain/result.js";
import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "../contracts/fridaToolContracts.js";
import { isValidFridaRemoteAddress } from "../contracts/fridaRemoteAddress.js";
import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "./fridaToolContracts.js";
import { isValidFridaRemoteAddress } from "./fridaRemoteAddress.js";
import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js";
const contract = (name: string) => {
const found = TOOL_CONTRACTS.find((candidate) => candidate.name === name);
+1 -1
View File
@@ -5,7 +5,7 @@ import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { analysisDetailOutputSchemaOf } from "./analysisDetail.js";
import { toolContractMetadata } from "./toolEffects.js";
import { isValidFridaRemoteAddress } from "./fridaRemoteAddress.js";
import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js";
import {
fridaSessionObservationSchema,
fridaSessionStatusInputSchema,
File renamed without changes.
+44 -2
View File
@@ -32,7 +32,9 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -215,6 +217,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
cause,
remote,
cleanupError,
remote === undefined ? undefined : { remote },
);
const reference =
remote === undefined
@@ -285,6 +288,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
cause,
selector.remote,
cleanupError,
selector,
);
const reference =
selector.remote === undefined
@@ -395,6 +399,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
cause,
input.remote,
cleanupFailures.join("; ") || undefined,
input,
);
if (pendingCleanup !== undefined && failure.cleanupIncomplete)
this.#retainCleanupFailure(pendingCleanup, failure);
@@ -1597,12 +1602,39 @@ const providerError = (
cause: unknown,
remote?: FridaRemoteConnection,
cleanupReason?: string,
selector?: FridaDeviceSelector,
): AnalysisError => {
const secrets = [remote?.token, remote?.certificate].filter(
(secret): secret is string => secret !== undefined && secret.length > 0,
);
const selection =
selector?.remote === undefined
? selector?.deviceId
: isValidFridaRemoteAddress(selector.remote.address)
? selector.remote.address
: undefined;
const selectionKey =
selector?.remote === undefined ? "device_id" : "remote_address";
const diagnostics = {
...(cause instanceof AnalysisError
? redactExplicitFailure(cause, secrets).diagnostics
: {}),
...(selection === undefined
? {}
: {
[selectionKey]: boundedRedactedString(selection, 4096, secrets).value,
}),
};
if (cause instanceof AnalysisInputError)
return new AnalysisInputError(operation, { cause }, cause.issues);
return new AnalysisInputError(
operation,
{ cause, diagnostics },
cause.issues,
);
if (cause instanceof AnalysisTimeoutError)
return new AnalysisTimeoutError(operation, cause.timeoutMs, {
cause,
diagnostics,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
@@ -1621,6 +1653,7 @@ const providerError = (
if (cause instanceof AnalysisCancelledError)
return new AnalysisCancelledError(operation, {
cause: cause.cause,
diagnostics,
...(cleanupReason === undefined
? {}
: {
@@ -1633,6 +1666,15 @@ const providerError = (
const reason = sanitizeError(cause, remote);
return new AnalysisCapabilityUnavailableError("frida", operation, reason, {
cause: remote === undefined ? cause : new Error(reason),
diagnostics,
...(selector?.remote === undefined ||
cleanupReason !== undefined ||
reason !== "Unable to connect to remote frida-server"
? {}
: {
remediationAction:
"Confirm the selected Frida server is reachable and its connection options are correct, then retry.",
}),
...(cleanupReason === undefined
? {}
: {
+47
View File
@@ -11,6 +11,53 @@ import type { FridaInstrumentationPort } from "../../../src/application/frida/Fr
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
it.each([
{
option: "--device-id",
value: "missing-device",
error: "Device not found",
field: "device_id",
code: "provider_timeout",
},
{
option: "--remote-address",
value: "127.0.0.1:27042",
error: "Unable to connect to remote frida-server",
field: "remote_address",
code: "capability_unavailable",
},
])(
"retains selected Frida endpoint in CLI discovery failures: $field",
async ({ option, value, error, field, code }) => {
const bindings = {
getDeviceById: async () => {
throw new Error(error);
},
addRemoteDevice: async () => {
throw new Error(error);
},
} as unknown as DeviceManager;
const provider = new FridaInstrumentationManager({
deviceManager: bindings,
});
let stdout = "";
await createCli({}, undefined, provider).serve(
["list-frida-processes", option, value, "--json"],
{
env: {},
exit: () => {},
stdout: (text) => {
stdout += text;
},
},
);
expect(JSON.parse(stdout)).toMatchObject({
code,
details: { diagnostics: { [field]: value } },
});
},
);
it.each([
{ devices: [], cleanupError: null },
{
+38
View File
@@ -1,6 +1,7 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchema } from "ajv";
import type { DeviceManager } from "frida";
import { expect, it, onTestFinished } from "vitest";
import type { FridaInstrumentationPort } from "../../../src/application/frida/FridaInstrumentationPort.js";
@@ -15,6 +16,43 @@ import { evidenceBundleSummarySchema } from "../../../src/domain/evidenceRecordS
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { parseMcpEvidenceBundle } from "../../fixtures/mcpEvidenceBundle.js";
it("retains the remote address, but not authentication, in discovery failures", async () => {
const session = createTestBinarySession(() => {
throw new Error("unexpected binary provider");
});
const bindings = {
addRemoteDevice: async () => {
throw new Error("connection refused: synthetic-secret-token");
},
} as unknown as DeviceManager;
const provider = new FridaInstrumentationManager({ deviceManager: bindings });
const server = createServer(
{ kind: "session", session },
{ fridaInstrumentation: provider },
);
const client = await connectFridaTestClient(
server,
session,
"frida-discovery-failure",
);
const result = await client.callTool({
name: "list_frida_processes",
arguments: {
remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" },
},
});
expect(result.isError).toBe(true);
const first = result.content[0];
if (first?.type !== "text") throw new Error("Expected text failure");
expect(JSON.parse(first.text)).toMatchObject({
error: {
code: "capability_unavailable",
details: { diagnostics: { remote_address: "127.0.0.1:27042" } },
},
});
expect(JSON.stringify(result)).not.toContain("synthetic-secret-token");
});
const createFridaTestProvider = (
overrides: Partial<FridaInstrumentationPort> = {},
): FridaInstrumentationPort => ({