Files
reverse-engineering-anything/src/cli/fridaCommands.ts
T
morluto cb4e5cfbff feat(analysis): make resource budgets recoverable across CLI and MCP (#9706)
* refactor(analysis): centralize resource budgets and recovery facts

* feat(analysis): expose ASAR and Ghidra runtime admission controls

* feat(artifacts): expose reader admission and preserve limited recognition

* feat(javascript): expose recoverable semantic precision budgets

* feat(web): add recoverable source-map budgets and compact retained views

* feat(web): make historical capture decoding budgets recoverable

* fix(desktop): enforce selected view policies across capture and export

* feat(runtime): make Frida and LLDB retention budgets selectable

* feat(native): expose diagnostic budgets and preserve exhaustion causes

* feat(managed): expose recoverable snapshot and representation budgets

* feat(metadata): make Go and Rust inspection budgets recoverable

* feat(wasm): expose representation and producer admissions

* feat(evm): expose per-call worker resource budgets

* feat(android): make resource and string-pool omissions recoverable

* feat(producers): expose command and recorded-capture budgets

* docs(analysis): record recovery contracts and integrated validation

* fix(docs): link calibration oracle from published verification pages

* fix(docs): restore desktop task anchors and receipt links

* test(analysis): align fixtures with selectable budget contracts

* fix(analysis): remove source-location projector dependency cycle

* test(analysis): refresh budget recovery and verifier regressions

* test(analysis): preserve format and publication recovery expectations

* test(windows): align conformance with owned runtime admission

* fix(desktop): preserve selected alias kinds in exported state

* test(analysis): align provider fixtures with recoverable budgets

* test(desktop): validate emitted alias metadata structurally

* fix(ghidra): classify bounded Windows endpoint refusals

* test(cli): assert selectable artifact admission recovery

* fix(cli): preserve capture provenance in environment builds
2026-11-10 05:11:20 -05:00

349 lines
13 KiB
TypeScript

import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
import type {
FridaInstrumentationPort,
FridaRemoteConnection,
} from "../application/frida/FridaInstrumentationPort.js";
import { createFridaInstrumentationProvider } from "../composition/frida.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
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 "../domain/fridaRemoteAddress.js";
import type { Logger } from "pino";
import { cliDecimalField } from "./options.js";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Script sources stay optional in the options schema; exactly one is required. */
const exactlyOneScriptSource =
"Provide exactly one of --script or --script-file.";
/** Register Frida discovery and one-shot instrumentation CLI operations. */
export const registerFridaCommands = (
cli: CliInstance,
logger: Logger,
provider: FridaInstrumentationPort = createFridaInstrumentationProvider(),
): void => {
const service = new FridaInstrumentationService(provider);
// CLI calls own a disposable provider; MCP retains its shared provider until
// server shutdown. Await cleanup here, including failed startup resources.
const cleanup = { close: () => provider.closeAll() };
const remoteOptions = z.object({
remoteAddress: z
.string()
.min(1)
.max(2048)
.refine(isValidFridaRemoteAddress)
.optional()
.describe("Remote Frida host address, optionally with a port"),
token: z.string().min(1).optional().describe("Remote authentication token"),
certificate: z
.string()
.min(1)
.optional()
.describe("Remote TLS certificate"),
origin: z.string().min(1).optional().describe("Remote connection origin"),
keepaliveInterval: cliDecimalField(
z
.number()
.int()
.positive()
.optional()
.describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION),
),
});
const remoteFrom = (
options: z.infer<typeof remoteOptions>,
): FridaRemoteConnection | undefined =>
options.remoteAddress === undefined
? undefined
: {
address: options.remoteAddress,
...(options.token === undefined ? {} : { token: options.token }),
...(options.certificate === undefined
? {}
: { certificate: options.certificate }),
...(options.origin === undefined ? {} : { origin: options.origin }),
...(options.keepaliveInterval === undefined
? {}
: { keepaliveInterval: options.keepaliveInterval }),
};
const remoteAddressRequired = (
options: z.infer<typeof remoteOptions>,
): boolean =>
options.remoteAddress !== undefined ||
(options.token === undefined &&
options.certificate === undefined &&
options.origin === undefined &&
options.keepaliveInterval === undefined);
cli.command(CLI_COMMANDS.listFridaDevices, {
description: "List local Frida devices and an optional remote endpoint",
args: z.object({}),
options: remoteOptions.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.listFridaDevices, () =>
finishAnalysisCommand(
() => service.listDevices(remoteFrom(options)),
cleanup,
"frida",
"list_frida_devices",
signal,
).then(projectCliAnalysisResult),
),
),
});
cli.command(CLI_COMMANDS.listFridaProcesses, {
description: "List processes visible through a Frida device",
args: z.object({}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.listFridaProcesses, () => {
const remote = remoteFrom(options);
return finishAnalysisCommand(
() =>
service.listProcesses(
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote },
),
cleanup,
"frida",
"list_frida_processes",
signal,
).then(projectCliAnalysisResult);
}),
),
});
cli.command(CLI_COMMANDS.instrumentWithFrida, {
description: `Run one Frida script against a selected process and detach. ${exactlyOneScriptSource}`,
args: z.object({
mode: z
.enum(["attach", "spawn"])
.describe("Attach to a PID or spawn a program"),
target: z
.string()
.min(1)
.describe("Process ID for attach or program path for spawn"),
}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
script: z
.string()
.optional()
.describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`),
scriptFile: z
.string()
.min(1)
.optional()
.describe(
`Path to JavaScript source file. ${exactlyOneScriptSource}`,
),
argument: z
.array(z.string())
.optional()
.describe("Arguments passed to a spawned target"),
captureSessionBytes: cliDecimalField(
fridaCapturePolicyInputSchema.shape.session_bytes,
).describe(
"Session retention bytes including encoded payloads; select before starting the experiment",
),
captureMessageTextBytes: cliDecimalField(
fridaCapturePolicyInputSchema.shape.message_text_bytes,
).describe("Retained bytes per Frida error text field"),
captureMessageTypeBytes: cliDecimalField(
fridaCapturePolicyInputSchema.shape.message_type_bytes,
).describe("Retained bytes per Frida message type"),
captureJsonNodes: cliDecimalField(
fridaCapturePolicyInputSchema.shape.json_nodes,
).describe("Retained JSON nodes per Frida payload"),
captureJsonDepth: cliDecimalField(
fridaCapturePolicyInputSchema.shape.json_depth,
).describe("Retained JSON nesting depth per Frida payload"),
durationMs: cliDecimalField(
z
.number()
.int()
.min(0)
.max(60_000)
.default(1_000)
.describe("Instrumentation duration in milliseconds"),
),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (args.mode === "attach" && options.argument !== undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["argument"],
reason: "invalid_value",
message:
"--argument is only valid in spawn mode; remove it when attaching to a process.",
},
],
),
);
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["script"],
reason: "invalid_value",
message:
"Provide exactly one of --script or --script-file.",
},
],
),
);
const remote = remoteFrom(options);
const selected =
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote };
const targetInput =
args.mode === "attach"
? /^[0-9]+$/u.test(args.target) &&
Number.isSafeInteger(Number(args.target)) &&
Number(args.target) > 0
? {
...selected,
mode: "attach" as const,
pid: Number(args.target),
}
: undefined
: {
...selected,
mode: "spawn" as const,
program: args.target,
...(options.argument === undefined
? {}
: { argv: options.argument }),
};
if (targetInput === undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["target"],
reason: "invalid_value",
message: "attach mode needs a positive integer process ID.",
},
],
),
);
return finishAnalysisCommand(
async () => {
const result = await service.instrument(
{
...targetInput,
...([
options.captureSessionBytes,
options.captureMessageTextBytes,
options.captureMessageTypeBytes,
options.captureJsonNodes,
options.captureJsonDepth,
].every((value) => value === undefined)
? {}
: {
captureLimits: {
...(options.captureSessionBytes === undefined
? {}
: { session_bytes: options.captureSessionBytes }),
...(options.captureMessageTextBytes === undefined
? {}
: {
message_text_bytes:
options.captureMessageTextBytes,
}),
...(options.captureMessageTypeBytes === undefined
? {}
: {
message_type_bytes:
options.captureMessageTypeBytes,
}),
...(options.captureJsonNodes === undefined
? {}
: { json_nodes: options.captureJsonNodes }),
...(options.captureJsonDepth === undefined
? {}
: { json_depth: options.captureJsonDepth }),
},
}),
source:
options.scriptFile === undefined
? { sourceKind: "inline", source: options.script ?? "" }
: { sourceKind: "file", path: options.scriptFile },
durationMs: options.durationMs,
},
signal,
);
if (!result.ok) return result;
if (result.value.cleanupError !== null) process.exitCode = 1;
return ok(result.value.evidence);
},
cleanup,
"frida",
"instrument_with_frida",
signal,
).then(projectCliAnalysisResult);
}),
),
});
};