mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import { z } from "incur";
|
|
import type { Logger } from "pino";
|
|
|
|
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
|
import type { Result } from "../domain/result.js";
|
|
import { err } from "../domain/result.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
OBJDUMP_ARTIFACT_OPERATIONS,
|
|
reverseCommandTimeoutSchema,
|
|
} from "../domain/reverseEngineering.js";
|
|
import { logCliCommand } from "../cliLogging.js";
|
|
import type { CliInstance } from "./types.js";
|
|
import { withCommandCancellation } from "./commandCancellation.js";
|
|
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
|
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
|
import {
|
|
GdbSessionManager,
|
|
gdbOneShotCommandMayInvalidateDetachProof,
|
|
} from "../gdb/GdbSessionManager.js";
|
|
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
|
import {
|
|
analysisEvidenceDeliveryOptions,
|
|
cliEvidenceDeliveryInputError,
|
|
publishCliAnalysisEvidence,
|
|
} from "./analysisEvidenceDelivery.js";
|
|
|
|
const executeWithCleanup = async (
|
|
service: ReverseEngineeringService,
|
|
signal: AbortSignal,
|
|
execute: () => Promise<Result<Evidence, AnalysisError>>,
|
|
): Promise<Result<Evidence, AnalysisError>> => {
|
|
let result: Result<Evidence, AnalysisError>;
|
|
try {
|
|
result = await execute();
|
|
} catch (cause) {
|
|
await service.close().catch(() => undefined);
|
|
throw cause;
|
|
}
|
|
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
|
|
try {
|
|
await service.close();
|
|
} catch (cause) {
|
|
if (result.ok) return err(asCleanupError(cause));
|
|
const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined;
|
|
const error = new AnalysisOutputError(
|
|
"reverse_engineering_cleanup",
|
|
"Provider cleanup remains incomplete after the command failed.",
|
|
{
|
|
cause: result.error,
|
|
...(cleanup === undefined ? {} : { cleanup }),
|
|
...(result.error.capturedOutput === undefined
|
|
? {}
|
|
: { capturedOutput: result.error.capturedOutput }),
|
|
...(result.error.partialObservation === undefined
|
|
? {}
|
|
: { partialObservation: result.error.partialObservation }),
|
|
},
|
|
);
|
|
return err(error);
|
|
}
|
|
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
|
|
const evidence = cleanupOnlyCompleteEvidence(result.error);
|
|
if (evidence !== undefined)
|
|
return cancelledAfterCleanup({ ok: true, value: evidence }, signal);
|
|
}
|
|
return cancelledAfterCleanup(result, signal);
|
|
};
|
|
|
|
/** Late cancellation observed during finalization keeps the completed Evidence as partial data. */
|
|
const cancelledAfterCleanup = (
|
|
result: Result<Evidence, AnalysisError>,
|
|
signal: AbortSignal,
|
|
): Result<Evidence, AnalysisError> =>
|
|
result.ok && signal.aborted
|
|
? err(
|
|
new AnalysisCancelledError(result.value.operation, {
|
|
cause: signal.reason,
|
|
partialObservation: result.value,
|
|
}),
|
|
)
|
|
: result;
|
|
|
|
const cleanupOnlyCompleteEvidence = (
|
|
error: AnalysisError,
|
|
): Evidence | undefined => {
|
|
if (
|
|
!(error instanceof AnalysisOutputError) ||
|
|
!error.cleanupOnly ||
|
|
error.partialObservation === undefined
|
|
)
|
|
return undefined;
|
|
const parsed = evidenceSchema.safeParse(error.partialObservation);
|
|
if (!parsed.success) return undefined;
|
|
const normalized = parsed.data.normalized_result;
|
|
return typeof normalized === "object" &&
|
|
normalized !== null &&
|
|
!Array.isArray(normalized) &&
|
|
normalized.completion_status === "complete"
|
|
? parsed.data
|
|
: undefined;
|
|
};
|
|
|
|
const asCleanupError = (cause: unknown): AnalysisError =>
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new AnalysisOutputError(
|
|
"reverse_engineering_cleanup",
|
|
"Provider process cleanup failed.",
|
|
{ cause },
|
|
);
|
|
|
|
/** Add one-shot reverse-engineering commands using the shared service layer. */
|
|
export const registerReverseEngineeringCommands = (
|
|
cli: CliInstance,
|
|
logger: Logger,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
createGdbManager: (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
) => GdbSessionManager = (providerEnvironment) =>
|
|
new GdbSessionManager({ environment: providerEnvironment }),
|
|
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
|
|
): void => {
|
|
const service = createService(environment);
|
|
cli.command(CLI_COMMANDS.inspectWithObjdump, {
|
|
description:
|
|
"Inspect a local artifact with GNU objdump and retain its raw output as Evidence",
|
|
args: z.object({
|
|
path: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Path to the local artifact to inspect."),
|
|
operation: z
|
|
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
|
.describe("GNU objdump inspection operation to run."),
|
|
}),
|
|
options: z.object({
|
|
...analysisEvidenceDeliveryOptions,
|
|
symbol: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.",
|
|
),
|
|
followDebugLinks: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe(
|
|
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
|
|
),
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
|
|
const delivery = {
|
|
providerId: "gnu.objdump",
|
|
operation: "inspect_with_objdump",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined) return projectAnalysisError(inputError);
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.inspectWithObjdump(
|
|
{
|
|
...args,
|
|
follow_debug_links: options.followDebugLinks,
|
|
...(options.symbol === undefined
|
|
? {}
|
|
: { symbol: options.symbol }),
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok
|
|
? publishCliAnalysisEvidence(result.value, delivery)
|
|
: projectAnalysisError(result.error);
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
|
|
description: "List BFD targets advertised by the installed GNU objdump",
|
|
args: z.object({}),
|
|
options: z.object({
|
|
...analysisEvidenceDeliveryOptions,
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(
|
|
logger,
|
|
CLI_COMMANDS.listObjdumpArchitectures,
|
|
async () => {
|
|
const delivery = {
|
|
providerId: "gnu.objdump",
|
|
operation: "inspect_with_objdump",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined)
|
|
return projectAnalysisError(inputError);
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.inspectWithObjdump(
|
|
{
|
|
operation: "architectures",
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok
|
|
? publishCliAnalysisEvidence(result.value, delivery)
|
|
: projectAnalysisError(result.error);
|
|
},
|
|
),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.executeRizinCommand, {
|
|
description: "Execute one Rizin command against a local artifact",
|
|
args: z.object({
|
|
path: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Path to the local artifact to analyze."),
|
|
command: z.string().min(1).describe("One caller-selected Rizin command."),
|
|
}),
|
|
options: z.object({
|
|
commandTimeoutMs: reverseCommandTimeoutSchema,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
|
|
const result = await executeWithCleanup(service, signal, () =>
|
|
service.executeRizinCommand(
|
|
{
|
|
...args,
|
|
...(options.commandTimeoutMs === undefined
|
|
? {}
|
|
: { command_timeout_ms: options.commandTimeoutMs }),
|
|
},
|
|
{ signal },
|
|
),
|
|
);
|
|
return result.ok ? result.value : projectAnalysisError(result.error);
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.debugWithGdb, {
|
|
description:
|
|
"Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached",
|
|
args: z.object({
|
|
command: z
|
|
.string()
|
|
.min(1)
|
|
.describe("One unrestricted GDB console command."),
|
|
}),
|
|
options: z.object({
|
|
inferiorTty: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"Caller-selected durable TTY for output from a live GDB inferior.",
|
|
),
|
|
...analysisEvidenceDeliveryOptions,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
|
|
const delivery = {
|
|
providerId: "gdb",
|
|
operation: "gdb_console",
|
|
signal,
|
|
detail: options.detail,
|
|
evidenceOutput: options.evidence_output,
|
|
};
|
|
const inputError = cliEvidenceDeliveryInputError(delivery);
|
|
if (inputError !== undefined) return projectAnalysisError(inputError);
|
|
if (gdbOneShotCommandMayInvalidateDetachProof(args.command))
|
|
return projectAnalysisError(
|
|
new AnalysisInputError("gdb_console", undefined, [
|
|
{
|
|
path: ["command"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"This command can invalidate the verified inferior output route; use the persistent GDB session workflow.",
|
|
},
|
|
]),
|
|
);
|
|
const manager = createGdbManager(environment);
|
|
let sessionId: string | undefined;
|
|
const completed = await (async () => {
|
|
try {
|
|
const started = await manager.start(signal, {
|
|
inferiorTty: options.inferiorTty,
|
|
});
|
|
if (!started.ok) return projectAnalysisError(started.error);
|
|
sessionId = started.value.session_id;
|
|
const executed = await manager.execute(
|
|
sessionId,
|
|
args.command,
|
|
signal,
|
|
);
|
|
return executed.ok
|
|
? executed.value.evidence
|
|
: projectAnalysisError(executed.error);
|
|
} finally {
|
|
let cleanupFailed = false;
|
|
if (sessionId !== undefined) {
|
|
try {
|
|
const closed = await manager.close(sessionId);
|
|
cleanupFailed = !closed.ok;
|
|
if (!closed.ok)
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithGdb,
|
|
cleanup: projectAnalysisError(closed.error),
|
|
},
|
|
"GDB cleanup failed after collecting command output",
|
|
);
|
|
} catch (cause) {
|
|
cleanupFailed = true;
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithGdb,
|
|
cleanup:
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Unknown cleanup failure",
|
|
},
|
|
"GDB cleanup failed after collecting command output",
|
|
);
|
|
}
|
|
}
|
|
try {
|
|
await manager.closeAll();
|
|
cleanupFailed = false;
|
|
} catch (cause) {
|
|
cleanupFailed = true;
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithGdb,
|
|
cleanup:
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Unknown cleanup failure",
|
|
},
|
|
"GDB fallback cleanup failed",
|
|
);
|
|
}
|
|
if (cleanupFailed) process.exitCode = 1;
|
|
}
|
|
})();
|
|
return publishCliAnalysisEvidence(completed, delivery);
|
|
}),
|
|
),
|
|
});
|
|
cli.command(CLI_COMMANDS.debugWithRizin, {
|
|
description:
|
|
"Run one unrestricted Rizin debugger command in an ephemeral session",
|
|
args: z.object({
|
|
path: z.string().min(1).describe("Path to the local artifact to debug."),
|
|
command: z
|
|
.string()
|
|
.min(1)
|
|
.describe("One unrestricted Rizin debugger command.")
|
|
.refine((value) => !/[\r\n]/u.test(value)),
|
|
}),
|
|
options: z.object({
|
|
inferiorTty: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Caller-selected Linux TTY for inferior stdout and stderr."),
|
|
backend: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Optional Rizin IO debugger backend name."),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => {
|
|
const manager = new RizinDebugSessionManager(environment);
|
|
let sessionId: string | undefined;
|
|
try {
|
|
const started = await manager.start(
|
|
{
|
|
...args,
|
|
inferior_tty: options.inferiorTty,
|
|
...(options.backend === undefined
|
|
? {}
|
|
: { backend: options.backend }),
|
|
},
|
|
signal,
|
|
);
|
|
if (!started.ok) return projectAnalysisError(started.error);
|
|
sessionId = started.value.session_id;
|
|
const executed = await manager.execute(
|
|
started.value.session_id,
|
|
args.command,
|
|
signal,
|
|
);
|
|
return executed.ok
|
|
? executed.value.evidence
|
|
: projectAnalysisError(executed.error);
|
|
} finally {
|
|
try {
|
|
if (sessionId !== undefined) {
|
|
const closed = await manager.close(sessionId);
|
|
if (!closed.ok)
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithRizin,
|
|
cleanup: projectAnalysisError(closed.error),
|
|
},
|
|
"Rizin cleanup failed after collecting command output",
|
|
);
|
|
}
|
|
} catch (cause) {
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithRizin,
|
|
cleanup:
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Unknown cleanup failure",
|
|
},
|
|
"Rizin cleanup failed after collecting command output",
|
|
);
|
|
}
|
|
// The retained owner retries all sessions. Only its final outcome
|
|
// establishes whether command cleanup remains incomplete.
|
|
await manager.closeAll().catch((cause: unknown) => {
|
|
process.exitCode = 1;
|
|
logger.error(
|
|
{
|
|
command: CLI_COMMANDS.debugWithRizin,
|
|
cleanup:
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Unknown cleanup failure",
|
|
},
|
|
"Rizin fallback cleanup failed",
|
|
);
|
|
});
|
|
}
|
|
}),
|
|
),
|
|
});
|
|
};
|