Own device and process discovery projection in the application workflow, bound to each named output contract. CLI and MCP now consume that result directly, including retained observations when final CLI cleanup fails. Remove the duplicate adapter projections and preserve provider failures, caller-selected identifiers, process records and cleanup diagnostics. Validated with 103 focused tests, npm run check and docs:check. Real local Frida 17.23.2 discovery on macOS arm64 returned the same three devices through the compiled CLI and production stdio MCP; both now pass the advertised schema. Remote endpoints and native Linux/Windows unverified. Fixes #4637 Co-authored-by: shreyansh1729 <shreyanshiitian1729@gmail.com> Co-authored-by: Kaushalendra-Marcus <yadavkausha4a5@gmail.com>
532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
import type { DeviceManager } from "frida";
|
|
|
|
import { createCli } from "../../../src/cli.js";
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import type { FridaInstrumentationPort } from "../../../src/application/frida/FridaInstrumentationPort.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
|
|
|
|
it.each([
|
|
{ devices: [], cleanupError: null },
|
|
{
|
|
devices: [
|
|
{ deviceId: "local", name: "Local System", type: "local" },
|
|
{ deviceId: "socket", name: "Local Socket", type: "remote" },
|
|
],
|
|
cleanupError: null,
|
|
},
|
|
{
|
|
devices: [{ deviceId: "remote-device", name: "endpoint", type: "remote" }],
|
|
cleanupError: "remote removal failed",
|
|
},
|
|
])("returns canonical Frida device JSON for %j", async (observation) => {
|
|
const provider: FridaInstrumentationPort = new FridaInstrumentationManager();
|
|
provider.listDevices = async () => ({ ok: true, value: observation });
|
|
let closed = false;
|
|
provider.closeAll = async () => {
|
|
closed = true;
|
|
};
|
|
let stdout = "";
|
|
let exitCode = 0;
|
|
await createCli({}, undefined, provider).serve(
|
|
["list-frida-devices", "--json"],
|
|
{
|
|
env: {},
|
|
exit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(closed).toBe(true);
|
|
const output = toolContract("list_frida_devices").outputSchema.parse(
|
|
JSON.parse(stdout),
|
|
);
|
|
expect(output).toEqual({
|
|
devices: observation.devices.map(({ deviceId, name, type }) => ({
|
|
device_id: deviceId,
|
|
name,
|
|
type,
|
|
})),
|
|
cleanup_error: observation.cleanupError,
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves canonical process discovery when CLI cleanup fails: %s",
|
|
async (cleanupFails) => {
|
|
const provider: FridaInstrumentationPort =
|
|
new FridaInstrumentationManager();
|
|
const processes = [
|
|
{ pid: 0, name: "idle", identifier: null },
|
|
{ pid: 71, name: "target", identifier: "example.target" },
|
|
];
|
|
provider.listProcesses = async (selector) => {
|
|
expect(selector).toEqual({ deviceId: "chosen-device" });
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
deviceId: "chosen-device",
|
|
processes,
|
|
cleanupError: "earlier endpoint cleanup diagnostic",
|
|
},
|
|
};
|
|
};
|
|
provider.closeAll = async () => {
|
|
if (cleanupFails) throw new Error("final cleanup failed");
|
|
};
|
|
let stdout = "";
|
|
await createCli({}, undefined, provider).serve(
|
|
["list-frida-processes", "--device-id", "chosen-device", "--json"],
|
|
{
|
|
env: {},
|
|
exit: () => {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
const expected = {
|
|
device_id: "chosen-device",
|
|
processes,
|
|
cleanup_error: "earlier endpoint cleanup diagnostic",
|
|
};
|
|
if (cleanupFails)
|
|
expect(JSON.parse(stdout)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { diagnostics: { partial_observation: expected } },
|
|
});
|
|
else
|
|
expect(
|
|
toolContract("list_frida_processes").outputSchema.parse(
|
|
JSON.parse(stdout),
|
|
),
|
|
).toEqual(expected);
|
|
},
|
|
);
|
|
|
|
it("maps one-shot Frida CLI target, script, duration, and remote options", async () => {
|
|
const calls: unknown[] = [];
|
|
const provider: FridaInstrumentationPort = {
|
|
async listDevices() {
|
|
calls.push({ operation: "devices" });
|
|
return { ok: true, value: { devices: [], cleanupError: null } };
|
|
},
|
|
async listProcesses() {
|
|
return {
|
|
ok: true,
|
|
value: { deviceId: "local", processes: [], cleanupError: null },
|
|
};
|
|
},
|
|
async startSession(input) {
|
|
calls.push({ operation: "start", input });
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
deviceId: "remote",
|
|
target: "fixture",
|
|
pid: 71,
|
|
mode: input.mode,
|
|
state: "running",
|
|
},
|
|
};
|
|
},
|
|
async loadScript(sessionId, source) {
|
|
calls.push({ operation: "load", sessionId, source });
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
scriptId: "00000000-0000-4000-8000-000000000002",
|
|
sourceKind: source.sourceKind,
|
|
sourcePath: source.sourceKind === "file" ? source.path : null,
|
|
sourceSha256: "a".repeat(64),
|
|
messages: [{ type: "send", payload: "observed" }],
|
|
messagesTruncated: false,
|
|
},
|
|
};
|
|
},
|
|
async resumeSession(sessionId) {
|
|
calls.push({ operation: "resume", sessionId });
|
|
return { ok: true, value: null };
|
|
},
|
|
async unloadScript() {
|
|
return { ok: true, value: null };
|
|
},
|
|
status(sessionId) {
|
|
return {
|
|
sessionId,
|
|
deviceId: "remote",
|
|
target: "fixture",
|
|
pid: 71,
|
|
mode: "attach",
|
|
state: "running",
|
|
targetResumeObserved: false,
|
|
scripts: [],
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
};
|
|
},
|
|
async closeSession(sessionId) {
|
|
calls.push({ operation: "close", sessionId });
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
async closeAll() {},
|
|
};
|
|
const cli = createCli({}, undefined, provider);
|
|
let stdout = "";
|
|
let exitCode = 0;
|
|
await cli.serve(
|
|
[
|
|
"instrument-with-frida",
|
|
"attach",
|
|
"71",
|
|
"--script",
|
|
"send('hello')",
|
|
"--remote-address",
|
|
"127.0.0.1:27042",
|
|
"--token",
|
|
"synthetic-secret-token",
|
|
"--keepalive-interval",
|
|
"10",
|
|
"--duration-ms",
|
|
"0",
|
|
"--json",
|
|
],
|
|
{
|
|
env: {},
|
|
exit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
const result: unknown = JSON.parse(stdout);
|
|
expect(JSON.stringify(result)).not.toContain("synthetic-secret-token");
|
|
expect(calls).toEqual([
|
|
{
|
|
operation: "start",
|
|
input: {
|
|
mode: "attach",
|
|
pid: 71,
|
|
remote: {
|
|
address: "127.0.0.1:27042",
|
|
token: "synthetic-secret-token",
|
|
keepaliveInterval: 10,
|
|
},
|
|
source: { sourceKind: "inline", source: "send('hello')" },
|
|
durationMs: 0,
|
|
},
|
|
},
|
|
{
|
|
operation: "load",
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
source: { sourceKind: "inline", source: "send('hello')" },
|
|
},
|
|
{
|
|
operation: "close",
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
},
|
|
]);
|
|
|
|
stdout = "";
|
|
exitCode = 0;
|
|
await cli.serve(
|
|
["list-frida-devices", "--token", "synthetic-secret-token", "--json"],
|
|
{
|
|
env: {},
|
|
exit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(calls).toHaveLength(3);
|
|
expect(stdout).not.toContain("synthetic-secret-token");
|
|
});
|
|
|
|
it("keeps Frida script-file identity in original bytes when UTF-8 decoding replaces invalid bytes", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "rea-frida-script-identity-"));
|
|
const files = [
|
|
{
|
|
name: "ff.js",
|
|
bytes: Buffer.from([
|
|
0x2f, 0x2f, 0x20, 0xff, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x28, 0x31, 0x29,
|
|
0x3b,
|
|
]),
|
|
},
|
|
{
|
|
name: "fe.js",
|
|
bytes: Buffer.from([
|
|
0x2f, 0x2f, 0x20, 0xfe, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x28, 0x31, 0x29,
|
|
0x3b,
|
|
]),
|
|
},
|
|
{ name: "replacement.js", bytes: Buffer.from("// �\nsend(1);", "utf8") },
|
|
{
|
|
name: "ascii.js",
|
|
bytes: Buffer.from("// ordinary ASCII\nsend(1);", "ascii"),
|
|
},
|
|
];
|
|
try {
|
|
await Promise.all(
|
|
files.map(({ name, bytes }) => writeFile(join(directory, name), bytes)),
|
|
);
|
|
const loadedSources: string[] = [];
|
|
const deviceManager = {
|
|
async getDeviceById() {
|
|
return {
|
|
id: "local",
|
|
name: "Fixture device",
|
|
type: "local",
|
|
async getProcessByPid(pid: number) {
|
|
return { pid, name: "fixture" };
|
|
},
|
|
async attach() {
|
|
let detached = false;
|
|
return {
|
|
pid: 71,
|
|
detached: { connect() {}, disconnect() {} },
|
|
isDetached: () => detached,
|
|
async detach() {
|
|
detached = true;
|
|
},
|
|
async createScript(source: string) {
|
|
loadedSources.push(source);
|
|
return {
|
|
message: { connect() {}, disconnect() {} },
|
|
async load() {},
|
|
async unload() {},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
} as unknown as DeviceManager;
|
|
|
|
for (const { name } of files) {
|
|
const provider = new FridaInstrumentationManager({ deviceManager });
|
|
let stdout = "";
|
|
let exitCode = 0;
|
|
await createCli({}, undefined, provider).serve(
|
|
[
|
|
"instrument-with-frida",
|
|
"attach",
|
|
"71",
|
|
"--script-file",
|
|
join(directory, name),
|
|
"--duration-ms",
|
|
"0",
|
|
"--json",
|
|
],
|
|
{
|
|
env: {},
|
|
exit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
const output: unknown = JSON.parse(stdout);
|
|
const bytes = files.find((file) => file.name === name)?.bytes;
|
|
if (bytes === undefined) throw new Error(`Missing fixture ${name}`);
|
|
const decodedSource = bytes.toString("utf8");
|
|
expect(loadedSources.at(-1)).toBe(decodedSource);
|
|
const rawDigest = createHash("sha256").update(bytes).digest("hex");
|
|
const loadedSourceDigest = createHash("sha256")
|
|
.update(decodedSource, "utf8")
|
|
.digest("hex");
|
|
const outputText = JSON.stringify(output);
|
|
expect(outputText).toContain(rawDigest);
|
|
if (name === "ff.js" || name === "fe.js") {
|
|
expect(rawDigest).not.toBe(loadedSourceDigest);
|
|
expect(output).toMatchObject({
|
|
evidence: {
|
|
normalized_result: { source_sha256: rawDigest },
|
|
raw_result: {
|
|
source_representation: {
|
|
encoding: "utf8",
|
|
decoding: "replacement",
|
|
loaded_source_sha256: loadedSourceDigest,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(outputText).toMatch(
|
|
/limitations.*(?:replacement|original bytes)/iu,
|
|
);
|
|
} else {
|
|
expect(output).toMatchObject({
|
|
evidence: {
|
|
normalized_result: { source_sha256: rawDigest },
|
|
},
|
|
});
|
|
expect(outputText).not.toContain("source_representation");
|
|
}
|
|
await provider.closeAll();
|
|
}
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("awaits failed startup cleanup and retains the primary error if cleanup still fails", async () => {
|
|
for (const cleanupFails of [false, true]) {
|
|
const calls: string[] = [];
|
|
const provider: FridaInstrumentationPort =
|
|
new FridaInstrumentationManager();
|
|
provider.startSession = async () => {
|
|
calls.push("start");
|
|
return {
|
|
ok: false,
|
|
error: new AnalysisCapabilityUnavailableError(
|
|
"frida",
|
|
"start_frida_session",
|
|
"attach failed after spawn",
|
|
{
|
|
cleanup: {
|
|
reason: "first resume failed",
|
|
resources: ["paused spawn"],
|
|
},
|
|
},
|
|
),
|
|
};
|
|
};
|
|
provider.closeAll = async () => {
|
|
calls.push("cleanup");
|
|
if (cleanupFails) throw new Error("resume still failed");
|
|
};
|
|
let stdout = "";
|
|
await createCli({}, undefined, provider).serve(
|
|
[
|
|
"instrument-with-frida",
|
|
"spawn",
|
|
"/tmp/fixture",
|
|
"--script",
|
|
"send('ready')",
|
|
"--json",
|
|
],
|
|
{
|
|
env: {},
|
|
exit: () => {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(calls).toEqual(["start", "cleanup"]);
|
|
const output: unknown = JSON.parse(stdout);
|
|
expect(output).toMatchObject({
|
|
code: cleanupFails ? "cleanup_incomplete" : "capability_unavailable",
|
|
});
|
|
expect(stdout).toContain("attach failed after spawn");
|
|
if (cleanupFails) expect(stdout).toContain("primary_error");
|
|
}
|
|
});
|
|
|
|
it("preserves observed devices when CLI provider cleanup fails", async () => {
|
|
const provider: FridaInstrumentationPort = new FridaInstrumentationManager();
|
|
const devices = [
|
|
{ deviceId: "remote-device", name: "selected endpoint", type: "remote" },
|
|
];
|
|
provider.listDevices = async () => ({
|
|
ok: true,
|
|
value: { devices, cleanupError: null },
|
|
});
|
|
provider.closeAll = async () => {
|
|
throw new Error("remote removal failed");
|
|
};
|
|
let stdout = "";
|
|
await createCli({}, undefined, provider).serve(
|
|
["list-frida-devices", "--json"],
|
|
{
|
|
env: {},
|
|
exit: () => {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
const output: unknown = JSON.parse(stdout);
|
|
expect(output).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
partial_observation: {
|
|
devices: [
|
|
{
|
|
device_id: "remote-device",
|
|
name: "selected endpoint",
|
|
type: "remote",
|
|
},
|
|
],
|
|
cleanup_error: null,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
["attach", "abc", "--script", "send(1)"],
|
|
"target",
|
|
"attach mode needs a positive integer process ID.",
|
|
],
|
|
[
|
|
["attach", "71"],
|
|
"script",
|
|
"Provide exactly one of --script or --script-file.",
|
|
],
|
|
[
|
|
["attach", "71", "--script", "send(1)", "--argument", "ignored"],
|
|
"argument",
|
|
"--argument is only valid in spawn mode; remove it when attaching to a process.",
|
|
],
|
|
])(
|
|
"names the rejected instrument-with-frida argument for %j",
|
|
async (argv, path, message) => {
|
|
let starts = 0;
|
|
const provider = {
|
|
async startSession() {
|
|
starts += 1;
|
|
throw new Error("Frida must not start for invalid input");
|
|
},
|
|
} as unknown as FridaInstrumentationPort;
|
|
const cli = createCli({}, undefined, provider);
|
|
let stdout = "";
|
|
await cli.serve(["instrument-with-frida", ...argv, "--json"], {
|
|
env: {},
|
|
exit: () => undefined,
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
});
|
|
expect(JSON.parse(stdout)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [{ path: [path], reason: "invalid_value", message }],
|
|
},
|
|
});
|
|
expect(starts).toBe(0);
|
|
},
|
|
);
|