mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
3c1cce78ee
* fix(domain): admit Qualcomm Hexagon (EM_HEXAGON, 164) ELF binaries Resolve missing ELF admission case for Qualcomm Hexagon DSP targets (e_machine = 164, EM_HEXAGON). - Add 'hexagon' to BINARY_ARCHITECTURES and HeaderArchitecture - Map EM_HEXAGON (164) in elfArchitecture() to 'hexagon' - Include 'hexagon' in GhidraProvider's SUPPORTED_ARCHITECTURES - Gracefully decline 'hexagon' with Ghidra-directed diagnostic in Hopper and IDA - Add unit tests for Hexagon ELF parsing and adapter admission checks Fixes #4781 * fix(hopper,ghidra): add Ghidra guidance to Hopper Hexagon refusal and add Ghidra admission regression test
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
|
import { fileURLToPath } from "node:url";
|
|
import { accessSync, constants, statSync } from "node:fs";
|
|
|
|
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
|
import type {
|
|
AnalysisClient,
|
|
AnalysisOperation,
|
|
AnalysisClientContext,
|
|
AnalysisProfileResolutionOptions,
|
|
AnalysisProviderCandidate,
|
|
CapabilityDescriptor,
|
|
ProviderAvailability,
|
|
ProviderIdentity,
|
|
ProviderTargetSupport,
|
|
} from "../application/AnalysisProvider.js";
|
|
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
|
import type { AppConfig } from "../config/types.js";
|
|
import {
|
|
isElfMetadataArchitecture,
|
|
type BinaryTarget,
|
|
} from "../domain/binaryTargetTypes.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import type { Logger } from "pino";
|
|
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
|
|
import {
|
|
hopperDeclinedArchitectureReason,
|
|
hopperLoaderArgsForTarget,
|
|
resolveHopperAnalysisProfile,
|
|
} from "./HopperAnalysisProfile.js";
|
|
import { mapHopperFileOffset } from "./HopperFileOffset.js";
|
|
import { HopperRegexSearch } from "./HopperRegexSearch.js";
|
|
import { HopperClient } from "./HopperClient.js";
|
|
import { hopperMachOImageSchema } from "./HopperMachOImage.js";
|
|
|
|
import {
|
|
CAPABILITIES,
|
|
HOPPER_PROVIDER_IDENTITY,
|
|
} from "./HopperProviderCapabilities.js";
|
|
|
|
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
|
export class HopperProvider implements AnalysisProviderCandidate {
|
|
private readonly environment: Readonly<NodeJS.ProcessEnv>;
|
|
|
|
/**
|
|
* Host platform, injected so availability and launch-mode decisions can be
|
|
* exercised for any host rather than only the machine running the suite.
|
|
* Mirrors `GhidraProvider`, which already threads its installation host's
|
|
* platform the same way.
|
|
*/
|
|
constructor(
|
|
private readonly config: AppConfig,
|
|
private readonly logger: Logger,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
private readonly platform: NodeJS.Platform = process.platform,
|
|
) {
|
|
this.environment = snapshotEnvironment(environment, platform);
|
|
}
|
|
|
|
identity(): ProviderIdentity {
|
|
return HOPPER_PROVIDER_IDENTITY;
|
|
}
|
|
|
|
capabilities(): readonly CapabilityDescriptor[] {
|
|
return CAPABILITIES;
|
|
}
|
|
|
|
inspectAvailability(): ProviderAvailability {
|
|
const diagnostics = {
|
|
launcher_path: this.config.hopperLauncherPath,
|
|
platform: this.platform,
|
|
};
|
|
if (this.platform !== "darwin" && this.platform !== "linux")
|
|
return {
|
|
status: "unavailable",
|
|
code: "unsupported_host",
|
|
reason: `Hopper integration is not supported on ${this.platform}.`,
|
|
diagnostics,
|
|
};
|
|
try {
|
|
accessSync(this.config.hopperLauncherPath, constants.X_OK);
|
|
// A searchable directory, such as an .app bundle, also passes X_OK.
|
|
if (!statSync(this.config.hopperLauncherPath).isFile())
|
|
throw new TypeError("Hopper launcher is not a regular file");
|
|
return {
|
|
status: "available",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics,
|
|
};
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional launcher probing; failure means unavailable.
|
|
void cause;
|
|
return {
|
|
status: "unavailable",
|
|
code: "executable_missing",
|
|
reason: `Hopper launcher is missing or not executable: ${this.config.hopperLauncherPath}`,
|
|
diagnostics,
|
|
};
|
|
}
|
|
}
|
|
|
|
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport {
|
|
const diagnostics = {
|
|
target_kind: target.kind,
|
|
target_format: target.format,
|
|
architecture: target.architecture ?? null,
|
|
};
|
|
if (target.kind !== "executable" && target.kind !== "database")
|
|
return {
|
|
status: "unsupported",
|
|
code: "target_kind_unsupported",
|
|
reason: `Hopper cannot directly analyze ${target.kind} targets.`,
|
|
diagnostics,
|
|
};
|
|
if (target.kind === "database" && this.platform === "darwin")
|
|
return {
|
|
status: "unsupported",
|
|
code: "target_kind_unsupported",
|
|
reason:
|
|
"This Hopper macOS launcher cannot start REA's analysis bridge when opening a saved Hopper database. Open an executable target instead; saved-database analysis is unavailable until Hopper exposes a database launch route that runs the bridge script.",
|
|
diagnostics,
|
|
};
|
|
if (target.kind === "database")
|
|
return {
|
|
status: "supported",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics,
|
|
};
|
|
if (
|
|
isElfMetadataArchitecture(target.architecture) ||
|
|
target.architecture === "hexagon"
|
|
)
|
|
return {
|
|
status: "unsupported",
|
|
code: "architecture_unsupported",
|
|
reason: hopperDeclinedArchitectureReason(target.architecture),
|
|
diagnostics,
|
|
};
|
|
if (target.format === "raw-binary")
|
|
return {
|
|
status: "unsupported",
|
|
code: "target_format_unsupported",
|
|
reason:
|
|
"Headerless raw-binary analysis requires the Ghidra adapter with a caller-supplied architecture and entry point.",
|
|
diagnostics,
|
|
};
|
|
if (target.format === "dos-mz" || target.format === "dos-com")
|
|
return {
|
|
status: "unsupported",
|
|
code: "target_format_unsupported",
|
|
reason:
|
|
"DOS MZ/COM analysis requires the Ghidra 16-bit real-mode adapter.",
|
|
diagnostics,
|
|
};
|
|
return {
|
|
status: "supported",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
resolveAnalysisProfile(
|
|
target: BinaryTarget,
|
|
options?: AnalysisProfileResolutionOptions,
|
|
) {
|
|
return resolveHopperAnalysisProfile(target, {
|
|
launcherPath: this.config.hopperLauncherPath,
|
|
loaderArgsOverride: this.config.hopperLoaderArgs,
|
|
provider: HOPPER_PROVIDER_IDENTITY,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
}
|
|
|
|
createClient(
|
|
target: BinaryTarget,
|
|
profile?: AnalysisProfileCommitment,
|
|
context?: AnalysisClientContext,
|
|
): AnalysisClient {
|
|
if (target.kind !== "executable" && target.kind !== "database")
|
|
return {
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
HOPPER_PROVIDER_IDENTITY.id,
|
|
operation,
|
|
`Hopper cannot open ${target.kind} targets directly. Inventory or extract the artifact first.`,
|
|
),
|
|
),
|
|
),
|
|
close: () => Promise.resolve(ok(null)),
|
|
};
|
|
const preparation = profile?.parameters.prepared_image;
|
|
const parsedImage =
|
|
preparation === undefined
|
|
? undefined
|
|
: hopperMachOImageSchema.safeParse(preparation);
|
|
if (parsedImage?.success === false)
|
|
return {
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
HOPPER_PROVIDER_IDENTITY.id,
|
|
operation,
|
|
"Hopper prepared-image profile is malformed; resolve the analysis profile again.",
|
|
),
|
|
),
|
|
),
|
|
close: () => Promise.resolve(ok(null)),
|
|
};
|
|
const preparedImage = parsedImage?.data;
|
|
const container =
|
|
preparedImage === undefined
|
|
? profile?.parameters.macho_container
|
|
: "thin";
|
|
const derivedLoaderArgs = hopperLoaderArgsForTarget(
|
|
target,
|
|
container === "thin" || container === "fat32" || container === "fat64"
|
|
? container
|
|
: undefined,
|
|
);
|
|
if (!derivedLoaderArgs.ok)
|
|
return {
|
|
execute: () => Promise.resolve(err(derivedLoaderArgs.error)),
|
|
close: () => Promise.resolve(ok(null)),
|
|
};
|
|
const executionProvider = profile?.provider ?? HOPPER_PROVIDER_IDENTITY;
|
|
const client = new HopperClient({
|
|
launcher: new HopperApplicationLauncher({
|
|
environment: this.environment,
|
|
launcherPath: this.config.hopperLauncherPath,
|
|
targetPath: target.path,
|
|
targetKind: target.kind,
|
|
...(preparedImage === undefined
|
|
? {}
|
|
: {
|
|
preparedImage: {
|
|
image: preparedImage,
|
|
sourceSha256: target.sha256,
|
|
},
|
|
}),
|
|
loaderArgs:
|
|
this.config.hopperLoaderArgs.length > 0
|
|
? this.config.hopperLoaderArgs
|
|
: derivedLoaderArgs.value,
|
|
bridgeScriptPath: fileURLToPath(
|
|
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
|
),
|
|
...(this.platform === "linux"
|
|
? {
|
|
launchMode: "verified_linux_demo" as const,
|
|
demoHelperPath: fileURLToPath(
|
|
new URL("../../scripts/hopper-demo-x11.py", import.meta.url),
|
|
),
|
|
}
|
|
: { launchMode: "native" as const }),
|
|
}),
|
|
...(context === undefined ? {} : { runId: context.runId }),
|
|
startupTimeoutMs: this.config.hopperStartupTimeoutMs,
|
|
logger: this.logger.child({ layer: "bridge" }),
|
|
onDiagnostic: (diagnostic) =>
|
|
this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"),
|
|
});
|
|
const regexSearch = new HopperRegexSearch(client);
|
|
const projectExecution = (
|
|
operation: AnalysisOperation,
|
|
normalized: JsonValue,
|
|
rawResult: JsonValue | null = null,
|
|
) =>
|
|
createAnalysisExecution(normalized, executionProvider, {
|
|
// Only the file-offset mapping differs from Hopper's reply.
|
|
rawResult,
|
|
...(profile === undefined ? {} : { analysisProfile: profile }),
|
|
...(operation === "procedure_callers" ||
|
|
operation === "procedure_callees" ||
|
|
operation === "analyze_function"
|
|
? { relationshipCoverage: { status: "complete" as const } }
|
|
: {}),
|
|
limitations: [
|
|
...(preparedImage === undefined
|
|
? []
|
|
: [
|
|
"REA loaded a verified thin Mach-O slice from the original FAT source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.",
|
|
]),
|
|
...(CAPABILITIES.find(
|
|
(descriptor) => descriptor.operation === operation,
|
|
)?.limitations ?? []),
|
|
...(operation === "read_bytes"
|
|
? [
|
|
"Hopper byte reads stop at the containing segment's exclusive end; a readable prefix is returned with complete=false when the requested range crosses it.",
|
|
]
|
|
: []),
|
|
...(operation === "procedure_references"
|
|
? [
|
|
"Native CallReference classifications and unresolved targets are preserved. Detailed reference flags and coverage beyond reported CallReference objects remain unknown.",
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
return {
|
|
execute: async (operation, parameters, options) => {
|
|
const result =
|
|
(operation === "search_strings" ||
|
|
operation === "search_procedures") &&
|
|
parameters.mode === "regex"
|
|
? await regexSearch.execute(operation, parameters, options)
|
|
: await client.callTool(operation, parameters, options);
|
|
if (!result.ok) return result;
|
|
const mapped =
|
|
operation === "address_to_file_offset"
|
|
? await mapHopperFileOffset(target, result.value, options?.signal)
|
|
: result;
|
|
return mapped.ok
|
|
? ok(
|
|
projectExecution(
|
|
operation,
|
|
mapped.value,
|
|
mapped === result ? null : result.value,
|
|
),
|
|
)
|
|
: mapped;
|
|
},
|
|
executeIfReady: async (operation, parameters, options) => {
|
|
if (operation !== "list_documents" && operation !== "list_procedures")
|
|
return undefined;
|
|
const result = await client.callToolIfReady(
|
|
operation,
|
|
parameters,
|
|
options?.signal === undefined ? {} : { signal: options.signal },
|
|
);
|
|
if (result === undefined || !result.ok) return result;
|
|
return ok(projectExecution(operation, result.value));
|
|
},
|
|
runtimeLineageSnapshots: () => {
|
|
const observation = client.runtimeLineage();
|
|
return observation === null
|
|
? []
|
|
: [{ provider: executionProvider, observation }];
|
|
},
|
|
requestActivitySnapshots: () => {
|
|
const activity = client.requestActivity();
|
|
return [
|
|
{
|
|
provider: executionProvider,
|
|
active:
|
|
activity === null
|
|
? null
|
|
: {
|
|
requestId: activity.requestId,
|
|
operation: activity.operation,
|
|
elapsedMs: activity.elapsedMs,
|
|
callerState: activity.callerState,
|
|
},
|
|
queuedRequests: activity?.queuedRequests ?? 0,
|
|
},
|
|
];
|
|
},
|
|
operationHealthSnapshot: () => client.operationHealth(),
|
|
close: async (options) => {
|
|
await regexSearch.close();
|
|
return client.close(options);
|
|
},
|
|
};
|
|
}
|
|
}
|