mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
588 lines
16 KiB
TypeScript
588 lines
16 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 { FRIDA_TOOL_CONTRACTS } from "../../../src/contracts/fridaToolContracts.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 = FRIDA_TOOL_CONTRACTS.find(
|
|
(contract) => contract.name === "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: { partial_observation: expected },
|
|
});
|
|
else
|
|
expect(
|
|
FRIDA_TOOL_CONTRACTS.find(
|
|
(contract) => contract.name === "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(() =>
|
|
FRIDA_TOOL_CONTRACTS.find(
|
|
(contract) => contract.name === "instrument_with_frida",
|
|
)!.outputSchema.parse(result),
|
|
).not.toThrow();
|
|
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({
|
|
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({
|
|
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 without clearing an unbound cleanup marker", 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: "cleanup_incomplete",
|
|
});
|
|
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: {
|
|
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)", "--script-file", "source.js"],
|
|
"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);
|
|
},
|
|
);
|
|
|
|
it("advances each Frida script source to the attach target error without attaching or spawning", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "rea-frida-script-source-"));
|
|
const scriptPath = join(directory, "source.js");
|
|
try {
|
|
await writeFile(scriptPath, "send(1);\n");
|
|
for (const scriptArguments of [
|
|
["--script", "send(1)"],
|
|
["--script-file", scriptPath],
|
|
]) {
|
|
let starts = 0;
|
|
const provider = {
|
|
async startSession() {
|
|
starts += 1;
|
|
throw new Error("Frida must not attach or spawn for an invalid PID");
|
|
},
|
|
} as unknown as FridaInstrumentationPort;
|
|
let stdout = "";
|
|
await createCli({}, undefined, provider).serve(
|
|
[
|
|
"instrument-with-frida",
|
|
"attach",
|
|
"abc",
|
|
...scriptArguments,
|
|
"--json",
|
|
],
|
|
{
|
|
env: {},
|
|
exit: () => undefined,
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
},
|
|
);
|
|
expect(JSON.parse(stdout)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["target"],
|
|
reason: "invalid_value",
|
|
message: "attach mode needs a positive integer process ID.",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(starts).toBe(0);
|
|
}
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|