mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Reuse the shared one-shot finalizer for discovery and instrumentation, preserve finalization cancellation with completed observations, and bind Frida cleanup errors to exact owners so confirmed retry recovers the primary error. BREAKING CHANGE: instrument-with-frida JSON now returns the canonical Evidence record directly. Read normalized_result.cleanup_error instead of the earlier top-level cleanup_error wrapper field.
615 lines
18 KiB
TypeScript
615 lines
18 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { access, stat } from "node:fs/promises";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
|
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
|
|
import {
|
|
resolvedAnalysisError,
|
|
type AnalysisError,
|
|
} from "../../domain/analysisErrorBase.js";
|
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
|
import {
|
|
AnalysisAccessDeniedError,
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisInputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import { fridaSessionStatusInputSchema } from "../../domain/fridaSessionStatus.js";
|
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
|
import {
|
|
requireAnalysisViewRetention,
|
|
summarizeRetainedAnalysis,
|
|
type AnalysisViewRetention,
|
|
} from "../analysisView/AnalysisViewService.js";
|
|
import { fridaCaptureLimitations } from "../../frida/fridaCaptureLimits.js";
|
|
import { unknownFridaSession } from "./fridaInputErrors.js";
|
|
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
|
import type {
|
|
FridaDeviceList,
|
|
FridaProcessList,
|
|
} from "../../domain/fridaDiscovery.js";
|
|
import type {
|
|
FridaDeviceSelector,
|
|
FridaInstrumentationPort,
|
|
FridaRemoteConnection,
|
|
FridaScriptObservation,
|
|
FridaScriptSource,
|
|
FridaSessionObservation,
|
|
FridaSessionStatus,
|
|
StartFridaSessionInput,
|
|
} from "./FridaInstrumentationPort.js";
|
|
|
|
type FridaSessionCloseResult = Awaited<
|
|
ReturnType<FridaInstrumentationPort["closeSession"]>
|
|
>;
|
|
type FridaSessionCancellation = {
|
|
readonly operation: string;
|
|
readonly input: StartFridaSessionInput;
|
|
readonly cause: unknown;
|
|
readonly diagnostics?: AnalysisError["diagnostics"];
|
|
readonly observation: FridaSessionStatus;
|
|
readonly closed: FridaSessionCloseResult;
|
|
readonly statusAfterCleanup: FridaSessionStatus | undefined;
|
|
readonly script: FridaScriptObservation | undefined;
|
|
};
|
|
|
|
const unreadableScriptFile = async (
|
|
source: FridaScriptSource,
|
|
): Promise<AnalysisError | undefined> => {
|
|
if (source.sourceKind !== "file") return undefined;
|
|
let reason: string;
|
|
let cause: unknown;
|
|
try {
|
|
if (!(await stat(source.path)).isFile())
|
|
reason = "it is not a regular file";
|
|
else {
|
|
await access(source.path, constants.R_OK);
|
|
return undefined;
|
|
}
|
|
} catch (failure: unknown) {
|
|
cause = failure;
|
|
const code =
|
|
failure instanceof Error && "code" in failure ? failure.code : undefined;
|
|
if (code === "EACCES" || code === "EPERM")
|
|
return new AnalysisAccessDeniedError(
|
|
"instrument_with_frida",
|
|
source.path,
|
|
code,
|
|
{ cause },
|
|
);
|
|
reason =
|
|
code === "ENOENT" ? "no file exists there" : String(code ?? failure);
|
|
}
|
|
return new AnalysisInputError(
|
|
"instrument_with_frida",
|
|
cause === undefined ? undefined : { cause },
|
|
[
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_value",
|
|
message: `Cannot read Frida script file ${source.path}: ${reason}.`,
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Reject an unreadable script file before any attach or spawn effect;
|
|
* cancellation before or during the check still wins.
|
|
*/
|
|
const rejectBeforeStart = async (
|
|
source: FridaScriptSource,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<AnalysisError | undefined> => {
|
|
const cancelled = () =>
|
|
new AnalysisCancelledError(
|
|
"instrument_with_frida",
|
|
signal?.reason === undefined ? {} : { cause: signal.reason },
|
|
);
|
|
if (signal?.aborted) return cancelled();
|
|
const unreadable = await unreadableScriptFile(source);
|
|
return signal?.aborted ? cancelled() : unreadable;
|
|
};
|
|
|
|
/** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */
|
|
export class FridaInstrumentationService {
|
|
readonly #provider: FridaInstrumentationPort;
|
|
|
|
constructor(provider: FridaInstrumentationPort) {
|
|
this.#provider = provider;
|
|
}
|
|
|
|
async listDevices(
|
|
remote?: FridaRemoteConnection,
|
|
): Promise<Result<FridaDeviceList, AnalysisError>> {
|
|
const result = await this.#provider.listDevices(remote);
|
|
return result.ok
|
|
? ok({
|
|
devices: result.value.devices.map(({ deviceId, name, type }) => ({
|
|
device_id: deviceId,
|
|
name,
|
|
type,
|
|
})),
|
|
cleanup_error: result.value.cleanupError,
|
|
})
|
|
: result;
|
|
}
|
|
|
|
async listProcesses(
|
|
selector: FridaDeviceSelector,
|
|
): Promise<Result<FridaProcessList, AnalysisError>> {
|
|
const result = await this.#provider.listProcesses(selector);
|
|
return result.ok
|
|
? ok({
|
|
device_id: result.value.deviceId,
|
|
processes: [...result.value.processes],
|
|
cleanup_error: result.value.cleanupError,
|
|
})
|
|
: result;
|
|
}
|
|
|
|
async startSession(input: StartFridaSessionInput, signal?: AbortSignal) {
|
|
return this.#startSession(input, signal, "start_frida_session");
|
|
}
|
|
|
|
async #startSession(
|
|
input: StartFridaSessionInput,
|
|
signal: AbortSignal | undefined,
|
|
operation: string,
|
|
) {
|
|
if (signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError(
|
|
operation,
|
|
signal.reason === undefined ? {} : { cause: signal.reason },
|
|
),
|
|
);
|
|
const started = await this.#provider.startSession(input);
|
|
if (!started.ok || !signal?.aborted) return started;
|
|
return this.#cancelSession(operation, started.value, input, signal.reason);
|
|
}
|
|
|
|
async #cancelSession(
|
|
operation: string,
|
|
session: FridaSessionObservation,
|
|
input: StartFridaSessionInput,
|
|
cause: unknown,
|
|
load?: {
|
|
readonly script: FridaScriptObservation;
|
|
readonly diagnostics?: AnalysisError["diagnostics"];
|
|
},
|
|
): Promise<Result<never, AnalysisError>> {
|
|
const loaded = load?.script;
|
|
const status = this.#provider.status(session.sessionId);
|
|
const observation = status ?? {
|
|
...session,
|
|
targetResumeObserved: false,
|
|
scripts: [],
|
|
messages: loaded?.messages ?? [],
|
|
messagesTruncated: loaded?.messagesTruncated ?? false,
|
|
};
|
|
const closed = await this.#provider.closeSession(session.sessionId);
|
|
const statusAfterCleanup = this.#provider.status(session.sessionId);
|
|
return err(
|
|
cancelledFridaSession({
|
|
operation,
|
|
input,
|
|
cause,
|
|
diagnostics: load?.diagnostics,
|
|
observation,
|
|
closed,
|
|
statusAfterCleanup,
|
|
script: loaded,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async loadScript(
|
|
sessionId: string,
|
|
source: FridaScriptSource,
|
|
signal?: AbortSignal,
|
|
): Promise<Result<Evidence, AnalysisError>> {
|
|
if (signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError("load_frida_script", {
|
|
cause: signal.reason,
|
|
}),
|
|
);
|
|
const loaded = await this.#provider.loadScript(sessionId, source, signal);
|
|
if (!loaded.ok && loaded.error instanceof FridaScriptLoadCancelledError)
|
|
return err(cancelledScriptEvidence(sessionId, loaded.error));
|
|
return loaded.ok
|
|
? ok(
|
|
fridaEvidence(
|
|
"load_frida_script",
|
|
scriptOutput(loaded.value),
|
|
{
|
|
session_id: sessionId,
|
|
},
|
|
loaded.value,
|
|
),
|
|
)
|
|
: loaded;
|
|
}
|
|
|
|
resumeSession(sessionId: string, signal?: AbortSignal) {
|
|
return this.#provider.resumeSession(sessionId, signal);
|
|
}
|
|
|
|
unloadScript(sessionId: string, scriptId: string, signal?: AbortSignal) {
|
|
return this.#provider.unloadScript(sessionId, scriptId, signal);
|
|
}
|
|
|
|
status(sessionId: string): FridaSessionStatus | undefined {
|
|
return this.#provider.status(sessionId);
|
|
}
|
|
|
|
inspectStatus(
|
|
rawInput: unknown,
|
|
retention: AnalysisViewRetention = {
|
|
recordEvidence: undefined,
|
|
evidenceById: undefined,
|
|
},
|
|
): Result<Evidence, AnalysisError> {
|
|
const input = fridaSessionStatusInputSchema.safeParse(rawInput);
|
|
if (!input.success)
|
|
return err(
|
|
analysisInputErrorFromIssues(
|
|
"frida_session_status",
|
|
input.error.issues,
|
|
rawInput,
|
|
{ cause: input.error },
|
|
),
|
|
);
|
|
if (input.data.detail === "summary") {
|
|
const ready = requireAnalysisViewRetention(
|
|
"frida_session_status",
|
|
retention,
|
|
);
|
|
if (!ready.ok) return ready;
|
|
}
|
|
const status = this.#provider.status(input.data.session_id);
|
|
if (status === undefined)
|
|
return err(
|
|
unknownFridaSession("frida_session_status", input.data.session_id),
|
|
);
|
|
const evidence = fridaEvidence(
|
|
"frida_session_status",
|
|
sessionStatusOutput(status),
|
|
{
|
|
session_id: input.data.session_id,
|
|
},
|
|
);
|
|
return input.data.detail === "summary"
|
|
? summarizeRetainedAnalysis(evidence, retention)
|
|
: ok(evidence);
|
|
}
|
|
|
|
async instrument(
|
|
input: StartFridaSessionInput & {
|
|
readonly source: FridaScriptSource;
|
|
readonly durationMs: number;
|
|
},
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
Result<
|
|
{ readonly evidence: Evidence; readonly cleanupError: string | null },
|
|
AnalysisError
|
|
>
|
|
> {
|
|
const rejected = await rejectBeforeStart(input.source, signal);
|
|
if (rejected !== undefined) return err(rejected);
|
|
const started = await this.#startSession(
|
|
input,
|
|
signal,
|
|
"instrument_with_frida",
|
|
);
|
|
if (!started.ok) return started;
|
|
if (signal?.aborted)
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
signal.reason,
|
|
);
|
|
const loaded = await this.#provider.loadScript(
|
|
started.value.sessionId,
|
|
input.source,
|
|
signal,
|
|
);
|
|
if (!loaded.ok && loaded.error instanceof AnalysisCancelledError)
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
loaded.error.cause,
|
|
loaded.error instanceof FridaScriptLoadCancelledError
|
|
? loaded.error
|
|
: undefined,
|
|
);
|
|
if (!loaded.ok) {
|
|
const closed = await this.#provider.closeSession(started.value.sessionId);
|
|
return closed.ok
|
|
? err(resolvedAnalysisError(loaded.error))
|
|
: err(
|
|
analysisErrorWithCleanupFailure(
|
|
loaded.error,
|
|
closed.error,
|
|
"instrument_with_frida",
|
|
).bindCleanupDependencies([loaded.error, closed.error]),
|
|
);
|
|
}
|
|
if (signal?.aborted)
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
signal.reason,
|
|
{ script: loaded.value },
|
|
);
|
|
if (started.value.mode === "spawn") {
|
|
const resumed = await this.#provider.resumeSession(
|
|
started.value.sessionId,
|
|
signal,
|
|
);
|
|
if (
|
|
!resumed.ok &&
|
|
!(signal?.aborted && resumed.error instanceof AnalysisCancelledError)
|
|
) {
|
|
const closed = await this.#provider.closeSession(
|
|
started.value.sessionId,
|
|
);
|
|
return closed.ok
|
|
? resumed
|
|
: err(
|
|
analysisErrorWithCleanupFailure(
|
|
resumed.error,
|
|
closed.error,
|
|
"instrument_with_frida",
|
|
).bindCleanupDependencies([resumed.error, closed.error]),
|
|
);
|
|
}
|
|
if (signal?.aborted)
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
signal.reason,
|
|
{ script: loaded.value },
|
|
);
|
|
}
|
|
try {
|
|
await delay(input.durationMs, undefined, { signal });
|
|
} catch (cause: unknown) {
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
signal?.aborted ? signal.reason : cause,
|
|
{ script: loaded.value },
|
|
);
|
|
}
|
|
if (signal?.aborted)
|
|
return this.#cancelSession(
|
|
"instrument_with_frida",
|
|
started.value,
|
|
input,
|
|
signal.reason,
|
|
{ script: loaded.value },
|
|
);
|
|
const status = this.#provider.status(started.value.sessionId);
|
|
const observation = status ?? {
|
|
...started.value,
|
|
targetResumeObserved: false,
|
|
scripts: [],
|
|
messages: loaded.value.messages,
|
|
messagesTruncated: loaded.value.messagesTruncated,
|
|
};
|
|
const closed = await this.#provider.closeSession(started.value.sessionId);
|
|
const cleanupError = closed.ok ? null : closed.error.message;
|
|
if (signal?.aborted)
|
|
return err(
|
|
cancelledFridaSession({
|
|
operation: "instrument_with_frida",
|
|
input,
|
|
cause: signal.reason,
|
|
observation,
|
|
closed,
|
|
statusAfterCleanup: this.#provider.status(started.value.sessionId),
|
|
script: loaded.value,
|
|
}),
|
|
);
|
|
const evidence = fridaEvidence(
|
|
"instrument_with_frida",
|
|
{
|
|
...sessionStatusOutput(observation),
|
|
...sourceOutput(loaded.value),
|
|
cleanup_error: cleanupError,
|
|
},
|
|
fridaParameters(started.value.sessionId, input, started.value.deviceId),
|
|
loaded.value,
|
|
);
|
|
return ok({
|
|
evidence,
|
|
cleanupError,
|
|
});
|
|
}
|
|
|
|
closeSession(sessionId: string) {
|
|
return this.#provider.closeSession(sessionId);
|
|
}
|
|
|
|
closeAll(): Promise<void> {
|
|
return this.#provider.closeAll();
|
|
}
|
|
}
|
|
|
|
const fridaEvidence = (
|
|
operation: string,
|
|
result: unknown,
|
|
parameters: unknown,
|
|
script?: FridaScriptObservation,
|
|
): Evidence =>
|
|
createEvidence(
|
|
undefined,
|
|
{ id: "frida", name: "Frida", version: null },
|
|
{
|
|
predicateType: "rea.frida.instrumentation-observation",
|
|
operation,
|
|
parameters: jsonObjectSchema.parse(parameters),
|
|
result: jsonValueSchema.parse(result),
|
|
...(script?.sourceRepresentation === undefined
|
|
? {}
|
|
: {
|
|
rawResult: {
|
|
source_representation: {
|
|
encoding: script.sourceRepresentation.encoding,
|
|
decoding: script.sourceRepresentation.decoding,
|
|
loaded_source_sha256:
|
|
script.sourceRepresentation.loadedSourceSha256,
|
|
},
|
|
},
|
|
}),
|
|
confidence: "observed",
|
|
authority: "external-service",
|
|
environment: null,
|
|
limitations: [
|
|
"The target operating system and architecture were not reported by this observation.",
|
|
...fridaCaptureLimitations(result),
|
|
...(script?.sourceRepresentation === undefined
|
|
? []
|
|
: [
|
|
"Invalid UTF-8 in the selected script file was decoded with replacement characters. source_sha256 identifies the original file bytes; raw_result.source_representation.loaded_source_sha256 identifies the UTF-8 encoding of the string passed to Frida.",
|
|
]),
|
|
],
|
|
},
|
|
);
|
|
|
|
const cancelledFridaSession = ({
|
|
operation,
|
|
input,
|
|
cause,
|
|
diagnostics,
|
|
observation,
|
|
closed,
|
|
statusAfterCleanup,
|
|
script,
|
|
}: FridaSessionCancellation): AnalysisCancelledError => {
|
|
const cleanupError = closed.ok ? null : closed.error.message;
|
|
const failure = new AnalysisCancelledError(operation, {
|
|
...(cause === undefined ? {} : { cause }),
|
|
diagnostics,
|
|
partialObservation: fridaEvidence(
|
|
operation,
|
|
{
|
|
...sessionStatusOutput(observation),
|
|
...(script === undefined ? {} : sourceOutput(script)),
|
|
cleanup_error: cleanupError,
|
|
target_state_before_cleanup: observation.state,
|
|
target_resume_observed:
|
|
observation.targetResumeObserved ||
|
|
(closed.ok && closed.value.targetResumeObserved) ||
|
|
statusAfterCleanup?.targetResumeObserved === true,
|
|
instrumentation_cleanup: closed.ok
|
|
? "released"
|
|
: "incomplete_or_unknown",
|
|
target_liveness_after_cleanup: "unknown",
|
|
},
|
|
fridaParameters(observation.sessionId, input, observation.deviceId),
|
|
script,
|
|
),
|
|
...(closed.ok
|
|
? {}
|
|
: {
|
|
cleanup: {
|
|
reason: closed.error.message,
|
|
resources: [observation.sessionId],
|
|
},
|
|
}),
|
|
});
|
|
return closed.ok ? failure : failure.bindCleanupDependencies([closed.error]);
|
|
};
|
|
|
|
const fridaParameters = (
|
|
sessionId: string,
|
|
input: StartFridaSessionInput,
|
|
deviceId: string,
|
|
) => ({
|
|
session_id: sessionId,
|
|
device_id: deviceId,
|
|
...(input.remote === undefined
|
|
? {}
|
|
: {
|
|
remote: {
|
|
address: input.remote.address,
|
|
...(input.remote.origin === undefined
|
|
? {}
|
|
: { origin: input.remote.origin }),
|
|
...(input.remote.keepaliveInterval === undefined
|
|
? {}
|
|
: { keepalive_interval: input.remote.keepaliveInterval }),
|
|
},
|
|
}),
|
|
mode: input.mode,
|
|
...(input.mode === "attach"
|
|
? { pid: input.pid }
|
|
: {
|
|
program: input.program,
|
|
...(input.argv === undefined ? {} : { argv: input.argv }),
|
|
}),
|
|
});
|
|
|
|
const sourceOutput = (script: FridaScriptObservation) => ({
|
|
source_kind: script.sourceKind,
|
|
source_path: script.sourcePath,
|
|
source_sha256: script.sourceSha256,
|
|
});
|
|
|
|
const scriptOutput = (script: FridaScriptObservation) => ({
|
|
script_id: script.scriptId,
|
|
...sourceOutput(script),
|
|
messages: script.messages,
|
|
messages_truncated: script.messagesTruncated,
|
|
});
|
|
|
|
const sessionStatusOutput = (status: FridaSessionStatus) => ({
|
|
session_id: status.sessionId,
|
|
device_id: status.deviceId,
|
|
state: status.state,
|
|
target: status.target,
|
|
pid: status.pid,
|
|
mode: status.mode,
|
|
scripts: status.scripts.map((script) => ({
|
|
script_id: script.scriptId,
|
|
name: script.name,
|
|
})),
|
|
messages: status.messages,
|
|
messages_truncated: status.messagesTruncated,
|
|
});
|
|
|
|
const cancelledScriptEvidence = (
|
|
sessionId: string,
|
|
error: FridaScriptLoadCancelledError,
|
|
): AnalysisCancelledError =>
|
|
new AnalysisCancelledError(error.operation, {
|
|
cause: error.cause,
|
|
diagnostics: error.diagnostics,
|
|
cleanup: error.cleanup,
|
|
partialObservation: fridaEvidence(
|
|
error.operation,
|
|
{
|
|
...scriptOutput(error.script),
|
|
cleanup_error: error.cleanup?.reason ?? null,
|
|
script_cleanup: !error.scriptHandleAcquired
|
|
? "no_handle"
|
|
: error.cleanupIncomplete
|
|
? "incomplete_or_unknown"
|
|
: "released",
|
|
},
|
|
{ session_id: sessionId },
|
|
error.script,
|
|
),
|
|
}).bindCleanupDependencies([error]);
|