Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c048b853f8 docs(website): make the analysis guides easier to start
- Each guide opens with a short "Before you start" box: difficulty,
  what you need, a link to the step-by-step setup and a one-line
  explanation of Electron or native programs.
- The browser guide gives commands to open Chrome on Mac and Windows
  Command Prompt, not only Linux, and its prompt includes the
  debugging address.
- Multi-line terminal commands note that Windows Command Prompt needs
  them on one line without the backslashes.
- The guide hub lists guides from easiest to hardest, with what each
  one needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:26:15 +00:00
Kaoru0822andClaude Opus 5.5 0ac6832aa6 docs(website): add a step-by-step setup guide for first-time users
People who have only used AI chat in a web browser found the existing
setup page hard to follow. The new guides/setup page walks through each
step with plain wording and pictures of what the terminal shows:

- getting a desktop AI app (Claude Desktop, Codex or an existing agent)
- installing Node.js and checking it with node -v
- opening the terminal on Mac, Windows (Command Prompt) and Linux
- running setup, ticking an app, reviewing the plan and restarting
- asking a first question about the supplied Notes example
- fixes for the common first-run errors

The setup screens were recorded from rea-agents 6.3.0 in a throwaway
home folder and are kept as selectable HTML. The guide hub now starts
with this page, and Get started links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:23:18 +00:00
44 changed files with 1259 additions and 4579 deletions
+35 -64
View File
@@ -1,25 +1,16 @@
# Optional NativeAOT metadata recovery
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
9.1 metadata. It parses the exact admitted Ghidra session snapshot and uses an
in-memory overlay for rehydrated metadata; it does not define Ghidra DataTypes,
labels, functions, or memory bytes. The hydration semantics are adapted from
[Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot) at
`effeb734fc570c32650f88b159608979dc7b423e` (MIT), cross-checked against
.NET's `MethodTable.h` at runtime commit
`a3b2d40328be0be3f8dd43f0e5cc925b8827b044` (MIT). The required notice ships in
[`licenses/nativeaot-parser-MIT.txt`](../licenses/nativeaot-parser-MIT.txt).
REA integrates [Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot)
through an optional headless adapter. The original source is an opt-in submodule,
pinned to `effeb734fc570c32650f88b159608979dc7b423e`; its MIT license and existing
source notices are retained. No upstream source or algorithms are rewritten.
The optional headless Ghidra adapter remains available for database annotations
and broader recovery. It uses the same pinned upstream commit as an opt-in
submodule and is a separate, more capable workflow.
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
frozen strings. Other RTR versions and PE architectures are reported as
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
run. This does not establish Windows Ghidra import or decompiler behavior.
The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**,
x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using
Ghidra **12.1.4** and JDK **21**. The build accepts any Ghidra 12.1.x release and
javac 21 or newer, and it compiles with `--release 21`. Real verification of
this layout remains the Ghidra 12.1.4 and JDK 21 pair. Other NativeAOT layouts,
architectures, and hosts are unsupported.
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
@@ -52,62 +43,42 @@ arbitrary caller-supplied bytes. The bounded loader accepts regular JARs up to
## Use existing tools
Open the executable with provider `ghidra`. For an x64 PE, `inspect_native_load_image`
returns `observations.metadata_recovery` inline from the captured source snapshot,
including the RTR header, MethodTable addresses, frozen strings, derived overlay
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
status means independent PE load-image verification was not performed; it does
not mean the metadata parser failed or the executable failed to load.
Open the executable with provider `ghidra`. `inspect_native_load_image` returns
optional `observations.metadata_recovery` inline, including the detected header,
layout, recovery status, type category paths/addresses, diagnostics, annotation
coverage and derived-memory SHA. The enclosing `unsupported` load-image status
for PE/ELF concerns independent DOS loader verification; metadata recovery has
its own status. It is not a failed import.
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
query also derives the snapshot metadata from the authenticated Ghidra session
handshake, so it does not require a preceding load-image call. In a persistent
MCP session, load-image followed by type inspection reuses that same derivation.
The database result remains `unavailable` when Ghidra has no DataType at that
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
carries related type, interface, slot, and target observations. Generated
address identities do not claim original managed names. The parser does not
merge frozen literals into Ghidra's database string inventory. Follow slot
targets with `analyze_function` and ordinary cross-references.
The built-in CLI path uses a MethodTable address returned by the parser or
another observation:
Pass one returned type path or its typed address to `inspect_native_data_type`.
Its optional `metadata_recovery` includes related/base type, implemented
interfaces and virtual slots with targets and known procedure names. Follow
these with `analyze_function`, string searches and ordinary cross-references.
For CLI use:
```sh
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
rea inspect-native-data-type ./NativeAotFixture --address 0xMETHODTABLE_ADDRESS --provider ghidra --json
rea inspect-native-data-type ./NativeAotFixture --type /NativeAOT/MethodTables/Class_ADDRESS_MT --provider ghidra --json
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
```
The generated `/NativeAOT/...` category path belongs to the optional headless
adapter. Use `--type /NativeAOT/MethodTables/Class_ADDRESS_MT` only when that
adapter is configured and has created the corresponding Ghidra DataType.
Built-in PE analysis includes its parser contract and resource policy in the
Ghidra analysis profile; non-PE profiles do not include that contract.
No new MCP workflow or approval flag is required. Configuration opts into the
adapter; without it ordinary Ghidra profiles/results remain unchanged.
## Evidence boundaries
The built-in parser uses an initialized non-executable PE signature scan and
requires a unique RTR 9.1 header and exactly one DEHYDRATED_DATA row. Rehydrated
bytes live only in a parser-owned overlay, identified by virtual address, size,
and SHA-256; the caller's source Buffer remains unchanged. Limits derive from
captured section extents and format field bounds. Separate resource policies
admit up to 128 MiB of captured PE bytes, 64 MiB-equivalent parser work, 64 MiB
of exact parser-owned overlay/index Buffers, and 4 MiB of serialized NativeAOT
report JSON. These are local parser limits, independent of MCP delivery; the
buffer counter does not claim to cap V8 heap or total process memory. Rows that
do not fit the report budget are omitted with explicit partial status and
coverage. Coverage counts describe the pointer scan, recovered MethodTables,
and frozen-string candidates examined, not every possible runtime type or
object. Cancellation is honored during snapshot I/O and checked immediately
before and after parsing; the synchronous parser call itself is bounded but
cannot be interrupted mid-call. Parser output never claims that a Ghidra
database type was defined.
Discovery prefers an imported RTR symbol, otherwise the upstream initialized,
non-executable-memory signature heuristic. REA rejects ambiguous candidates,
unknown layout versions, malformed directory rows, unsupported flags and
unmapped ranges before recovery. It requires the verified dehydrated layout.
The optional adapter performs its annotations in an ephemeral Program
transaction. That workflow may change analysis-memory bytes, never the original
executable file. It is unavailable on Windows P0, which has no database
mutation authority.
The adapter performs rehydration, method-table recovery and frozen-object
annotation in one ephemeral Program transaction. Fatal failure/cancellation
rolls it back. Recovery may change analysis-memory bytes, never the original
executable file. Derived ranges have a digest and **no original file offset**;
they are not captured runtime state. Candidate/committed instance annotation
counts distinguish partial frozen-object coverage. Coverage concerns the
recovered candidate set, not every possible object or every runtime type.
REA preserves pre-recovery calling conventions and uses the loaded compiler
specification default for newly created methods. The upstream universal
+3 -3
View File
@@ -8,9 +8,9 @@ Executable bytes remain unchanged.
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
configuration, startup deadlines, resource controls, and cleanup recovery.
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
[NativeAOT observations](ghidra-nativeaot.md) include a built-in read-only PE
snapshot parser and a separate optional annotation extension. Check the
[release boundary](installation.md#released-package-and-main) when using npm.
[NativeAOT recovery](ghidra-nativeaot.md) is an optional, separately verified
extension. Check the [release boundary](installation.md#released-package-and-main)
when using npm.
See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds)
to override the header-detected language or seed analysis.
@@ -247,46 +247,6 @@ while locally shadowed receivers can still contribute network endpoint
candidates. A template recovered with a parser error and no cooked value stays
dynamic; its raw spelling is not treated as a valid JavaScript string.
RPC client syntax also contributes network endpoint candidates, in both
`analyze_javascript_application` and `analyze_web_bundle`, distinguished by
`mechanism`:
- `trpc:procedure:<method>` records the static member path between the root
expression and the method, such as `invoice.list` in
`root.invoice.list.useQuery(...)`. tRPC proxies keep these property names
through identifier minification. React hooks and TanStack option factories
(`useQuery`, `useMutation`, `useInfiniteQuery`, `useSubscription`, suspense
and prefetch variants, `queryOptions`, `infiniteQueryOptions`,
`mutationOptions`, `subscriptionOptions`) are recognized directly. The
vanilla `query` and `mutate` methods are recognized on a root bound to
`createTRPCClient` or `createTRPCProxyClient` in the same lexical binding, or on a
path of at least two segments in a source containing `TRPCClientError`,
`trpc-accept`, or `/trpc`. Factory bindings must have one unconditional
initializer and no binding assignments; shadowed locals do not inherit client
identity. `subscribe` additionally requires a tRPC observer
argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or
`onConnectionStateChange`). `this` roots, browser globals, and any path
containing an RTK Query `endpoints` segment are never procedures; cache-key
helpers such as `queryKey` issue no request and are excluded.
- `trpc:link:<link>` records the literal `url` option of `httpLink`,
`httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the
link's callee name survives bundling, including `(0, module.httpBatchLink)`
import-call wrappers.
- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a
named operation found at the start of a string or cooked template literal —
the name must be followed by variable definitions (`($`), a directive, or a
selection set starting with a field name — or in a precompiled
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
not reported twice. Anonymous operations are not reported.
A procedure path is the server-side procedure name, not a resolved URL; the
transport URL depends on the link configuration and batching. When the proxy is
reached through a member expression, such as a webpack module binding
(`l.S.invoice.list`) or a property holding the proxy (`o.api.post.byId`), the
recorded path keeps those leading carrier segments; the procedure path is its
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
and links whose callee was renamed by a minifier are not recognized.
Each recovered bundle module retains the exact factory-source digest. A complete
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
ordinary identifier names while retaining syntax, literals, operators, object
-7
View File
@@ -672,13 +672,6 @@ boundaries and budgets.
## Optional NativeAOT Ghidra analysis
The source parser lane is independent of the optional annotation JAR. Its
synthetic RTR 9.1 PE golden checks captured-byte digest admission, rehydration
into an immutable parser overlay, MethodTable/slot relationships, and frozen
literal extraction; provider tests exercise `inspect_native_load_image` and
address-based `inspect_native_data_type` with the database result still
unavailable. These tests do not establish Windows Ghidra host behavior.
This lane is separate from the default native lane. `build:fixtures:nativeaot`
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
+3 -6
View File
@@ -21,12 +21,9 @@ additional permission flag or degraded mode is required.
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
PE load-image inspection returns an explicit unsupported attestation status;
independent load-image verification currently supports DOS MZ only. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation. A real Windows-host NativeAOT Ghidra run is still required
to establish the complete host workflow.
PE load-image inspection returns measurements with an explicit unsupported
attestation status; independent format-specific verification currently supports
DOS MZ only.
This boundary does not establish general Windows feature parity. Ghidra GUI
state, annotations, other target architectures and formats, and Hopper-only
-28
View File
@@ -1,28 +0,0 @@
The MIT License (MIT)
=====================
Copyright © 2023-2025 Washi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The read-only NativeAOT parser adapts format and hydration semantics from
Washi1337/ghidra-nativeaot commit
effeb734fc570c32650f88b159608979dc7b423e (MIT). MethodTable field layout was
cross-checked against .NET runtime src/coreclr/nativeaot/Runtime/inc/MethodTable.h
at dotnet/runtime commit a3b2d40328be0be3f8dd43f0e5cc925b8827b044 (MIT).
+1 -2
View File
@@ -81,8 +81,7 @@
"bridge/pwndbg/launch_impl.py",
"bridge/pwndbg/core_context.py",
"third_party/pwndbg/README.md",
"third_party/pwndbg/LICENSE.md",
"licenses/nativeaot-parser-MIT.txt"
"third_party/pwndbg/LICENSE.md"
],
"scripts": {
"release:notes": "node scripts/release-notes.mjs",
-24
View File
@@ -14,24 +14,6 @@ const flags = [
"workflows",
...Object.keys(SCOPES),
];
// Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([
".github/workflows/release.yml",
".github/workflows/real-ghidra-windows.yml",
]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications.
const FIXTURE_SOURCES = [
"tests/conformance/c/**",
"tests/conformance/napi/**",
"tests/conformance/objc/**",
"tests/conformance/swift/**",
"tests/conformance/versions/**",
];
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
const matchesAny = (file, patterns) =>
patterns.some((pattern) => matchesGlob(file, pattern));
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
const reasons = [];
const full = (reason) => {
@@ -140,7 +122,6 @@ if (
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
@@ -150,11 +131,6 @@ if (
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
+17 -21
View File
@@ -99,14 +99,6 @@ const scenarioFor = (browser, origin) =>
capture: { after_each_step: ["dom"], at_end: ["dom"] },
});
const raceWithTimeout = (operation, timeoutMs, onTimeout = () => undefined) => {
const controller = new AbortController();
return Promise.race([
operation,
delay(timeoutMs, undefined, { signal: controller.signal }).then(onTimeout),
]).finally(() => controller.abort());
};
const storagePage = (kind, port) => {
if (kind === "main")
return `<!doctype html><html><body><pre id="main-report"></pre><pre id="frame-report"></pre><iframe src="http://localhost:${port}/storage-frame"></iframe><script>
@@ -311,10 +303,11 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
await verifyCallerDebuggerPause(observer, origin, scenario);
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
} finally {
await raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
() => undefined,
);
await raceWithTimeout(observer.close(), 1_000).catch(() => undefined);
await Promise.race([
restoreTarget(observer, target.url),
delay(1_000),
]).catch(() => undefined);
await Promise.race([observer.close(), delay(1_000)]).catch(() => undefined);
}
};
@@ -507,11 +500,14 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
unsubscribe();
});
try {
const callFrameId = await raceWithTimeout(paused, 10_000, () => {
throw new Error(
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
);
});
const callFrameId = await Promise.race([
paused,
delay(10_000).then(() => {
throw new Error(
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
);
}),
]);
const pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
callFrameId,
expression: "window.reaDebuggerAfter === true",
@@ -540,10 +536,10 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
} catch (cause) {
if (!pauseObserved) controller.abort(cause);
else await caller.send("Debugger.resume").catch(() => undefined);
const completion = await raceWithTimeout(
const completion = await Promise.race([
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
5_000,
);
delay(5_000).then(() => undefined),
]);
if (completion === undefined)
throw new Error("Capture remained pending after cancellation", { cause });
const completionError =
@@ -583,7 +579,7 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
});
} finally {
unsubscribe();
await raceWithTimeout(caller.send("Debugger.disable"), 1_000).catch(
await Promise.race([caller.send("Debugger.disable"), delay(1_000)]).catch(
() => undefined,
);
}
@@ -33,7 +33,7 @@ export const inspectElectronBrowserNode = (
context: JavaScriptFindingContext,
): void => {
if (t.isNewExpression(node)) inspectBrowserWindow(node, context);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
if (!t.isCallExpression(node)) return;
inspectContextBridge(node, context);
inspectUtilityProcess(node, context);
};
@@ -162,7 +162,7 @@ const collectWebPreferences = (
};
const inspectContextBridge = (
node: t.CallExpression | t.OptionalCallExpression,
node: t.CallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -208,7 +208,7 @@ const inspectContextBridge = (
};
const inspectUtilityProcess = (
node: t.CallExpression | t.OptionalCallExpression,
node: t.CallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -1,76 +0,0 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("Electron API identity", () => {
it("recovers APIs through an ES module default import of Electron", () => {
const analysis = analyzeJavaScriptStaticSource(`
import electron from "electron";
const { ipcMain } = electron;
new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } });
electron.contextBridge.exposeInMainWorld("api", { ping: true });
electron.ipcRenderer.invoke("default:ping");
ipcMain.handle("destructured:ping", () => "pong");
electron.default.ipcMain.handle("nested-default", () => "local");
`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: "./preload.js" },
]);
expect(analysis.electron.context_bridge_apis).toMatchObject([
{ api_key: "api", members: ["ping"] },
]);
expect(
analysis.electron.ipc.map(({ side, operation, channel }) => ({
side,
operation,
channel,
})),
).toEqual([
{ side: "renderer", operation: "invoke", channel: "default:ping" },
{ side: "main", operation: "handle", channel: "destructured:ping" },
]);
});
it("recovers APIs and sender checks written with optional chaining", () => {
const analysis = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcMain, utilityProcess } = require("electron");
contextBridge?.exposeInMainWorld("api", {});
utilityProcess?.fork("./worker.js");
ipcMain?.handle("optional:ping", (event) => {
if (!event.senderFrame?.url.startsWith("file://")) return null;
return event.sender?.getURL() === "file:///index.html";
});
`);
expect(analysis.electron.context_bridge_apis).toMatchObject([
{ api_key: "api" },
]);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: "./worker.js" },
]);
expect(analysis.electron.ipc).toMatchObject([
{ side: "main", operation: "handle", channel: "optional:ping" },
]);
expect(
analysis.electron.sender_validations.map(
({ subject, mechanism, expected }) => ({
subject,
mechanism,
expected: expected.value,
}),
),
).toEqual([
{
subject: "sender-url",
mechanism: "call:startsWith",
expected: "file://",
},
{
subject: "sender-url",
mechanism: "comparison:===",
expected: "file:///index.html",
},
]);
});
});
@@ -26,7 +26,7 @@ export const inspectElectronIpcNode = (
node: t.Node,
context: JavaScriptFindingContext,
): void => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
if (t.isCallExpression(node)) {
inspectIpcCall(node, context);
inspectValidationCall(node, context);
}
@@ -34,7 +34,7 @@ export const inspectElectronIpcNode = (
};
const inspectIpcCall = (
node: t.CallExpression | t.OptionalCallExpression,
node: t.CallExpression,
context: JavaScriptFindingContext,
): void => {
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
@@ -117,7 +117,7 @@ const inspectValidationComparison = (
};
const inspectValidationCall = (
node: t.CallExpression | t.OptionalCallExpression,
node: t.CallExpression,
context: JavaScriptFindingContext,
): void => {
if (
@@ -225,10 +225,9 @@ const ipcDescriptor = (
const senderSubject = (
node: t.Node,
): ElectronSenderValidationFinding["subject"] | undefined => {
const name =
t.isCallExpression(node) || t.isOptionalCallExpression(node)
? calleeName(node.callee)
: calleeName(node);
const name = t.isCallExpression(node)
? calleeName(node.callee)
: calleeName(node);
if (/(?:^|\.)sender\.getURL$/u.test(name)) return "sender-url";
if (/(?:^|\.)senderFrame\.origin$/u.test(name)) return "sender-origin";
if (/(?:^|\.)senderFrame\.url$/u.test(name)) return "sender-url";
@@ -12,16 +12,6 @@ import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
export const ELECTRON_MODULE =
/^electron(?:\/(?:common|main|renderer|utility))?$/u;
/**
* Electron's CommonJS exports define no `default` member, so a leading
* `default` segment can only denote the module object itself, as an ES module
* default import (`import electron from "electron"`) binds it.
*/
export const electronExportPath = (
importedPath: readonly string[],
): readonly string[] =>
importedPath[0] === "default" ? importedPath.slice(1) : importedPath;
/** Static identity recovery cannot establish the absence of Electron usage. */
export const ELECTRON_IDENTITY_LIMITATION =
"Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
@@ -1,329 +0,0 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const endpoints = (source: string) =>
analyzeJavaScriptStaticSource(source).endpoints.map(
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
);
const rpcEndpoints = (source: string) =>
endpoints(source).filter(
({ mechanism }) =>
mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"),
);
describe("tRPC procedure recognition", () => {
it("recognizes React hook procedures on a renamed proxy root", () => {
expect(
rpcEndpoints(`
const a = n.post.byId.useQuery({ id: 1 });
const b = n.post.add.useMutation();
n.feed.useInfiniteQuery({ limit: 10 });
n.onEvent.useSubscription(undefined, { onData() {} });
n.invoice.list.useSuspenseQuery();
`),
).toEqual([
{
kind: "network",
value: "post.byId",
mechanism: "trpc:procedure:useQuery",
},
{
kind: "network",
value: "post.add",
mechanism: "trpc:procedure:useMutation",
},
{
kind: "network",
value: "feed",
mechanism: "trpc:procedure:useInfiniteQuery",
},
{
kind: "network",
value: "onEvent",
mechanism: "trpc:procedure:useSubscription",
},
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:useSuspenseQuery",
},
]);
});
it("recognizes TanStack React Query integration option factories", () => {
expect(
rpcEndpoints(`
useQuery(e.invoice.list.queryOptions({ page: 1 }));
useMutation(e.invoice.create.mutationOptions());
client.invalidateQueries({ queryKey: e.invoice.list.queryKey() });
`),
).toEqual([
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:queryOptions",
},
{
kind: "network",
value: "invoice.create",
mechanism: "trpc:procedure:mutationOptions",
},
]);
});
it("recognizes vanilla client calls and link URLs only in sources carrying a tRPC marker", () => {
expect(
rpcEndpoints(`
const client = createTRPCClient({
links: [httpBatchLink({ url: "/api/trpc" })],
});
const bilbo = await client.getUser.query("id_bilbo");
await client.post.add.mutate({ title: "x" });
client.onPost.subscribe(undefined, { onData() {} });
`),
).toEqual([
{
kind: "network",
value: "/api/trpc",
mechanism: "trpc:link:httpBatchLink",
},
{ kind: "network", value: "getUser", mechanism: "trpc:procedure:query" },
{
kind: "network",
value: "post.add",
mechanism: "trpc:procedure:mutate",
},
{
kind: "network",
value: "onPost",
mechanism: "trpc:procedure:subscribe",
},
]);
expect(
rpcEndpoints(`
const rows = await pool.users.query("select 1");
emitter.changes.subscribe(listener);
store.cart.mutate(update);
`),
).toEqual([]);
});
it("requires corroboration for request methods shared with Apollo, RxJS and stores", () => {
// The source carries a tRPC marker, so only the call shape can decide.
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
this.client.query({ query: q });
e.queryManager.query(options);
this.untypedClient.query("path", input);
this.route.params.subscribe((params) => params);
t.current.subscribe(listener);
r.store.subscribe(() => {});
n.cache.mutate(update);
o.k.endpoints.getPosts.useQuery();
e.api.endpoints.list.useQuery();
this.trpc.invoice.list.useQuery();
`),
).toEqual([]);
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
e.invoice.list.query({ page: 1 });
e.invoice.onChange.subscribe(undefined, { onData() {} });
`),
).toEqual([
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:query",
},
{
kind: "network",
value: "invoice.onChange",
mechanism: "trpc:procedure:subscribe",
},
]);
});
it("never reports browser globals or RTK Query endpoints as procedures", () => {
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
navigator.permissions.query({ name: "geolocation" });
window.navigator.permissions.query({ name: "camera" });
api.endpoints.getPosts.useQuery();
api.useGetPostsQuery();
useQuery({ queryKey: ["x"] });
`),
).toEqual([]);
});
});
describe("tRPC binding identity and bundled syntax", () => {
it("keeps client bindings local to their lexical declaration", () => {
expect(
rpcEndpoints(`
function first() { const c = createTRPCClient({}); c.users.query(); }
function unrelated(c) { c.users.query("select 1"); }
const c = createTRPCClient({});
function closure() { c.posts.query(); }
function shadowed() { c.users.query(); var c = pool; }
{ const c = pool; c.users.query(); }
try {} catch (c) { c.users.query(); }
`),
).toEqual([
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
]);
});
it("does not corroborate overwritten or destructured client bindings", () => {
expect(
rpcEndpoints(`
let c = createTRPCClient({}); c = pool; c.users.query();
const { client } = createTRPCClient({}); client.users.query();
`),
).toEqual([]);
expect(
rpcEndpoints(`
var c = createTRPCClient({}); c.users.query();
`),
).toEqual([
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
]);
});
it("recognizes surviving factory names inside bundled call wrappers", () => {
expect(
rpcEndpoints(`
const c = (0, t.createTRPCClient)({
links: [(0, t.httpBatchLink)({ url: "/api/rpc" })],
});
c.users.query();
const p = (t.createTRPCProxyClient as any)({});
p.posts.query();
(0, t.httpLink)({ url: "/api/other" });
`),
).toEqual([
{
kind: "network",
value: "/api/rpc",
mechanism: "trpc:link:httpBatchLink",
},
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "/api/other", mechanism: "trpc:link:httpLink" },
]);
});
it("preserves procedure paths beyond the former nesting cutoff", () => {
const path = Array.from({ length: 64 }, (_, i) => `router${i}`).join(".");
expect(rpcEndpoints(`root.${path}.useQuery();`)).toEqual([
{ kind: "network", value: path, mechanism: "trpc:procedure:useQuery" },
]);
});
});
describe("GraphQL operation recognition", () => {
it("recognizes named operations in tagged templates and string literals", () => {
expect(
rpcEndpoints(`
const GET_USER = gql\`
# comment
query GetUser($id: ID!) { user(id: $id) { name } }
\`;
const UPDATE = "mutation UpdateInvoice($input: InvoiceInput!) { updateInvoice(input: $input) { id } }";
const ON = \`subscription OnInvoice { invoiceChanged { id } } \${fragment}\`;
`),
).toEqual([
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
{
kind: "network",
value: "UpdateInvoice",
mechanism: "graphql:mutation",
},
{
kind: "network",
value: "OnInvoice",
mechanism: "graphql:subscription",
},
]);
});
it("retains operations after long ignored prefixes and long headers", () => {
const name = "Operation" + "x".repeat(512);
const text =
"#" +
"comment".repeat(700) +
"\nquery " +
name +
" ".repeat(600) +
"{ users { id } }";
expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual([
{ kind: "network", value: name, mechanism: "graphql:query" },
]);
});
it("recognizes precompiled GraphQL document objects", () => {
expect(
rpcEndpoints(`
const doc = { kind: "Document", definitions: [{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "ListInvoices" },
selectionSet: { kind: "SelectionSet", selections: [] },
}] };
`),
).toEqual([
{ kind: "network", value: "ListInvoices", mechanism: "graphql:query" },
]);
});
it("ignores anonymous operations and ordinary prose", () => {
expect(
rpcEndpoints(`
const a = "query { viewer { id } }";
const b = "query string parameters";
const c = "mutation observer started";
const d = { kind: "OperationDefinition", operation: "query" };
const e = "mutation Observer {";
const f = "query Search(";
const g = "subscription Plan (monthly)";
`),
).toEqual([]);
});
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
expect(
rpcEndpoints(`
const doc = { kind: "Document", definitions: [{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "GetUser" },
selectionSet: { kind: "SelectionSet", selections: [] },
}], loc: { start: 0, end: 40, source: {
body: "query GetUser($id: ID!) { user(id: $id) { name } }",
name: "GraphQL request",
locationOffset: { line: 1, column: 1 },
} } };
`),
).toEqual([
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
]);
});
});
it("keeps existing network endpoint recognition unchanged", () => {
expect(
endpoints(`fetch("/api/invoices"); n.invoice.list.useQuery();`),
).toEqual([
{ kind: "network", value: "/api/invoices", mechanism: "call:fetch" },
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:useQuery",
},
]);
});
@@ -1,322 +0,0 @@
import * as t from "@babel/types";
import {
unwrapJavaScriptExpression,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { calleeName } from "./javascriptStaticAnalysisHelpers.js";
/** One RPC operation or transport URL recovered from client-side syntax. */
export interface JavaScriptRpcEndpoint {
readonly value: string;
readonly mechanism: string;
}
/** Per-source RPC recognition state; feed it every AST node in pre-order. */
export interface JavaScriptRpcScan {
inspect(node: t.Node): JavaScriptRpcEndpoint | undefined;
}
// tRPC proxies preserve router and procedure names as property names, so they
// survive identifier minification. Method names follow @trpc/react-query and
// @trpc/tanstack-react-query 11.19 documentation; cache-key helpers such as
// queryKey are excluded because they never issue a request.
const TRPC_HOOK_METHODS: ReadonlySet<string> = new Set([
"useQuery",
"useSuspenseQuery",
"useInfiniteQuery",
"useSuspenseInfiniteQuery",
"usePrefetchQuery",
"usePrefetchInfiniteQuery",
"useMutation",
"useSubscription",
"queryOptions",
"infiniteQueryOptions",
"mutationOptions",
"subscriptionOptions",
]);
// @trpc/client 11.19 clientCallTypeMap methods; common outside tRPC (Apollo,
// database pools, stores), so they need corroborating evidence.
const TRPC_REQUEST_METHODS: ReadonlySet<string> = new Set(["query", "mutate"]);
// @trpc/client 11.19 TRPCSubscriptionObserver callbacks; RxJS observers use
// next/error/complete instead.
const TRPC_SUBSCRIPTION_CALLBACKS: ReadonlySet<string> = new Set([
"onStarted",
"onData",
"onError",
"onStopped",
"onComplete",
"onConnectionStateChange",
]);
// @trpc/client 11.19 client factories (createTRPCProxyClient is the
// deprecated alias of createTRPCClient).
const TRPC_CLIENT_FACTORIES: ReadonlySet<string> = new Set([
"createTRPCClient",
"createTRPCProxyClient",
]);
// Built-in HTTP links documented by tRPC 11.19 that carry a `url` option.
const TRPC_URL_LINKS: ReadonlySet<string> = new Set([
"httpLink",
"httpBatchLink",
"httpBatchStreamLink",
"httpSubscriptionLink",
]);
// Literals retained by @trpc/client 11.19 bundles (`this.name`, the default
// accept header key) or by the conventional transport path.
const TRPC_SOURCE_MARKERS = ["TRPCClientError", "trpc-accept", "/trpc"];
// Browser-owned roots whose methods share tRPC names, such as
// navigator.permissions.query.
const BROWSER_GLOBAL_NAMES: ReadonlySet<string> = new Set([
"window",
"self",
"globalThis",
"document",
"navigator",
"location",
"history",
"console",
"performance",
"screen",
"top",
"parent",
"frames",
]);
const PROCEDURE_SEGMENT = /^[A-Za-z_$][\w$]*$/u;
interface MemberPath {
readonly root: t.Node;
/** Non-computed property names from the root outward. */
readonly properties: readonly string[];
}
/** Read a static member path and the expression it starts from. */
const memberPath = (callee: t.Node): MemberPath | undefined => {
const reversed: string[] = [];
let current: t.Node = callee;
while (
t.isMemberExpression(current) ||
t.isOptionalMemberExpression(current)
) {
const property = semanticStaticPropertyKey(
current.property,
current.computed,
);
if (property === null || !PROCEDURE_SEGMENT.test(property))
return undefined;
reversed.push(property);
current = current.object;
}
return { root: current, properties: reversed.reverse() };
};
const objectKeys = (node: t.Node | undefined): readonly string[] =>
t.isObjectExpression(node)
? node.properties.flatMap((property) => {
if (!t.isObjectProperty(property) && !t.isObjectMethod(property))
return [];
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === null ? [] : [key];
})
: [];
const objectStringProperty = (
node: t.ObjectExpression,
key: string,
): string | undefined => {
for (const property of node.properties)
if (
t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) === key &&
t.isStringLiteral(property.value)
)
return property.value.value;
return undefined;
};
const objectProperty = (
node: t.ObjectExpression,
key: string,
): t.Node | undefined => {
for (const property of node.properties)
if (
t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) === key
)
return property.value;
return undefined;
};
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
"query",
"mutation",
"subscription",
]);
// A named GraphQL operation header (GraphQL October 2021, OperationDefinition):
// type, name, then variable definitions starting with `$`, a directive, or a
// selection set starting with a field name.
const GRAPHQL_OPERATION_HEADER =
/^(query|mutation|subscription)[\s,]+([_A-Za-z][_0-9A-Za-z]*)[\s,]*(?:\([\s,]*\$|@[_A-Za-z]|\{[\s,]*[_A-Za-z])/u;
const isGraphqlIgnoredCharacter = (character: string): boolean =>
character === "," || character === "" || /\s/u.test(character);
/** Recover a named operation from GraphQL document text. */
export const graphqlOperationFromText = (
text: string,
): JavaScriptRpcEndpoint | undefined => {
// Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments.
const limit = text.length;
let index = 0;
while (index < limit) {
const character = text.charAt(index);
if (isGraphqlIgnoredCharacter(character)) index += 1;
else if (character === "#") {
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
} else break;
}
if (index >= limit) return undefined;
const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index));
const type = header?.[1];
const name = header?.[2];
return type === undefined || name === undefined
? undefined
: { value: name, mechanism: `graphql:${type}` };
};
/** Recover a named operation from a precompiled GraphQL document object. */
export const graphqlOperationFromDocumentNode = (
node: t.ObjectExpression,
): JavaScriptRpcEndpoint | undefined => {
if (objectStringProperty(node, "kind") !== "OperationDefinition")
return undefined;
const type = objectStringProperty(node, "operation");
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
return undefined;
const name = objectProperty(node, "name");
if (
!t.isObjectExpression(name) ||
objectStringProperty(name, "kind") !== "Name"
)
return undefined;
const value = objectStringProperty(name, "value");
return value === undefined
? undefined
: { value, mechanism: `graphql:${type}` };
};
/** Bundlers use `(0, imported.factory)` to call an import without a receiver. */
const rpcFactoryName = (callee: t.Node): string => {
let current = unwrapJavaScriptExpression(callee).node;
while (t.isSequenceExpression(current)) {
const last = current.expressions.at(-1);
if (last === undefined) return "";
current = unwrapJavaScriptExpression(last).node;
}
return calleeName(current);
};
/** Whether an initializer has the syntax of a documented tRPC client factory. */
export const isJavaScriptRpcClientFactory = (node: t.Node): boolean => {
const call = unwrapJavaScriptExpression(node).node;
return (
(t.isCallExpression(call) || t.isOptionalCallExpression(call)) &&
TRPC_CLIENT_FACTORIES.has(
rpcFactoryName(call.callee).split(".").pop() ?? "",
)
);
};
/** Recover the literal `url` option passed to a documented tRPC HTTP link. */
const trpcLinkUrl = (
node: t.CallExpression | t.OptionalCallExpression,
): JavaScriptRpcEndpoint | undefined => {
const name = rpcFactoryName(node.callee);
const factory = name.slice(name.lastIndexOf(".") + 1);
if (!TRPC_URL_LINKS.has(factory)) return undefined;
const options = node.arguments[0];
if (!t.isObjectExpression(options)) return undefined;
const url = objectStringProperty(options, "url");
return url === undefined
? undefined
: { value: url, mechanism: `trpc:link:${factory}` };
};
/**
* Create the per-source scan. Hook and option-factory methods are distinctive.
* `query`/`mutate` need a root bound to a tRPC client factory, or a tRPC source
* marker with a router path of at least two segments; `subscribe` needs the
* marker and a tRPC observer argument. `this` roots, browser globals and RTK
* Query `endpoints` chains are never procedures.
*/
export const createJavaScriptRpcScan = (
source: string,
clientRoots: ReadonlySet<t.Node>,
): JavaScriptRpcScan => {
let marker: boolean | undefined;
const hasMarker = (): boolean =>
(marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value)));
// graphql-tag documents retain their source text in loc.source.body; that
// text repeats the OperationDefinition already recovered from the object.
const documentSourceBodies = new WeakSet<t.Node>();
const procedure = (
node: t.CallExpression | t.OptionalCallExpression,
): JavaScriptRpcEndpoint | undefined => {
const path = memberPath(node.callee);
if (path === undefined || t.isThisExpression(path.root)) return undefined;
if (t.isIdentifier(path.root) && BROWSER_GLOBAL_NAMES.has(path.root.name))
return undefined;
const method = path.properties[path.properties.length - 1];
const segments = path.properties.slice(0, -1);
const first = segments[0];
if (method === undefined || first === undefined) return undefined;
if (BROWSER_GLOBAL_NAMES.has(first) || segments.includes("endpoints"))
return undefined;
const bound = clientRoots.has(path.root);
const recognized = TRPC_HOOK_METHODS.has(method)
? true
: TRPC_REQUEST_METHODS.has(method)
? bound || (segments.length >= 2 && hasMarker())
: method === "subscribe" &&
(bound || hasMarker()) &&
objectKeys(node.arguments[1]).some((key) =>
TRPC_SUBSCRIPTION_CALLBACKS.has(key),
);
return recognized
? { value: segments.join("."), mechanism: `trpc:procedure:${method}` }
: undefined;
};
return {
inspect: (node) => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
return trpcLinkUrl(node) ?? procedure(node);
if (t.isObjectExpression(node)) {
const body = objectProperty(node, "body");
if (
t.isStringLiteral(body) &&
objectProperty(node, "locationOffset") !== undefined
) {
documentSourceBodies.add(body);
return undefined;
}
return graphqlOperationFromDocumentNode(node);
}
if (t.isStringLiteral(node))
return documentSourceBodies.has(node)
? undefined
: graphqlOperationFromText(node.value);
if (t.isTemplateLiteral(node)) {
// A template recovered without a cooked value stays dynamic.
const cooked = node.quasis[0]?.value.cooked;
return cooked === undefined || cooked === null
? undefined
: graphqlOperationFromText(cooked);
}
return undefined;
},
};
};
@@ -47,7 +47,6 @@ import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDeri
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
import {
collectElectronMemberWritesSteps,
electronExportPath,
electronMemberPath,
ELECTRON_MODULE,
} from "./javascriptElectronMemberWrites.js";
@@ -60,8 +59,6 @@ import {
type ParsedJavaScriptSource,
} from "./javascriptSourceParser.js";
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
interface BindPatternInput {
readonly pattern: t.Node;
readonly initializer: t.Node | null;
@@ -184,40 +181,6 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
};
}
/** Resolve tRPC client roots by lexical identity, excluding overwritten bindings. */
export function* classifyParsedJavaScriptRpcClientRootsSteps(
file: ParsedJavaScriptSource,
): Generator<void, ReadonlySet<t.Node>> {
const state = createState(file.program);
yield* collectDefinitionsSteps(file.program, state);
const roots = new Set<t.Node>();
const clients = new Map<string, boolean>();
yield* traverseJavaScriptAstSteps(file.program, {
enter: (node) => {
if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node))
return;
const root = node.object;
if (!t.isIdentifier(root)) return;
const binding = resolveSemanticBindingState(state, root, root.name);
if (binding === undefined) return;
let client = clients.get(binding.bindingId);
if (client === undefined) {
const [initializer] = binding.initializers;
client =
initializer !== undefined &&
initializer.projection.length === 0 &&
binding.initializers.length === 1 &&
!binding.definitions.some(({ kind }) => kind === "assignment") &&
!state.conditionalInitializers.has(initializer.node) &&
isJavaScriptRpcClientFactory(initializer.node);
clients.set(binding.bindingId, client);
}
if (client) roots.add(root);
},
});
return roots;
}
/** Receiver identity facts for the overloaded `.open` syntax only. */
export type JavaScriptOpenReceiverFact =
| "window"
@@ -386,7 +349,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
}
const origin = semanticRequireOrigin(root, state);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? electronExportPath(origin.importedPath)
? origin.importedPath
: undefined;
};
const writes = yield* collectElectronMemberWritesSteps(
@@ -437,7 +400,7 @@ const electronExport = (
? provenance.origins[0]
: undefined);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? electronExportPath(origin.importedPath)
? origin.importedPath
: null;
};
@@ -23,7 +23,6 @@ import {
inspectCall,
inspectRouteProperty,
inspectRoleProperty,
inspectRpcNode,
} from "./javascriptStaticAnalysisCalls.js";
import {
inspectBundlerRegistration,
@@ -38,7 +37,6 @@ import {
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
import {
classifyParsedJavaScriptElectronBindingsSteps,
classifyParsedJavaScriptRpcClientRootsSteps,
classifyParsedJavaScriptOpenReceivers,
type JavaScriptOpenReceiverFact,
} from "./javascriptSemanticAnalysis.js";
@@ -47,8 +45,6 @@ import {
type ParsedJavaScriptSource,
} from "./javascriptSourceParser.js";
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
/** Parse one JavaScript artifact and recover static structure only. */
export const analyzeJavaScriptStaticSource = (
source: string,
@@ -82,13 +78,11 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
const electronBindings =
yield* classifyParsedJavaScriptElectronBindingsSteps(file);
const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file);
yield* traverseStaticSourceSteps(file, {
source,
accumulator,
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
electronBindings,
rpcScan: createJavaScriptRpcScan(source, clientRoots),
});
addSourceMapDirectives(source, file.comments ?? [], accumulator);
return finalizeStaticAnalysis(source, file, accumulator);
@@ -229,5 +223,4 @@ const inspectNode = (
inspectRouteProperty(node, findings);
inspectRoleProperty(node, findings);
}
inspectRpcNode(node, findings);
};
@@ -201,13 +201,6 @@ const inspectEndpointCall = (
});
};
/** Record tRPC procedures, tRPC link URLs and named GraphQL operations. */
export const inspectRpcNode = (node: t.Node, context: FindingContext): void => {
const rpc = context.rpcScan.inspect(node);
if (rpc !== undefined)
addEndpoint(context, { node, kind: "network", ...rpc });
};
export const inspectRouteProperty = (
node: t.ObjectProperty,
context: FindingContext,
@@ -1,5 +1,4 @@
import type * as t from "@babel/types";
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import type {
JavaScriptBundlerRegistration,
@@ -58,7 +57,6 @@ export interface JavaScriptAnalysisAccumulator {
/** Shared source and accumulator for helper inspections. */
export interface JavaScriptFindingContext {
readonly rpcScan: JavaScriptRpcScan;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly openReceiverFacts?: ReadonlyMap<
@@ -1,176 +0,0 @@
import { describe, expect, it } from "vitest";
import type { NativeAotParserLimits } from "./nativeAotPe.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
parseNativeAotPe,
} from "./nativeAotPe.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
} from "../../../tests/fixtures/nativeaotPe.js";
const REPORT_BUDGET = NATIVE_AOT_MAX_REPORT_BYTES;
describe("NativeAOT parser resource budgets", () => {
it("reports source-cap rejection without hashing or inventing omitted-byte coverage", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const limits: NativeAotParserLimits = {
sourceBytes: bytes.length - 1,
workUnits: 1024,
workingMemoryBytes: 1024,
};
const report = parseNativeAotPe(
bytes,
sha,
REPORT_BUDGET,
undefined,
limits,
).summary;
expect(report).toMatchObject({
status: "partial",
analysis_artifact_sha256: sha,
header_address: null,
coverage: {
source_bytes_examined: 0,
source_byte_budget_bytes: bytes.length - 1,
truncation_reason: "source-byte-budget",
},
});
expect(report.coverage.source_bytes_omitted).toBeUndefined();
});
});
describe("NativeAOT parser report admission", () => {
it("never returns an over-budget report from early exits", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
expect(() =>
parseNativeAotPe(bytes, sha, 0, undefined, {
sourceBytes: bytes.length - 1,
workUnits: 1024,
workingMemoryBytes: 1024,
}),
).toThrow(/minimum truthful report/iu);
const nonPe = Buffer.from("not a PE image");
expect(() => parseNativeAotPe(nonPe, nativeAotPeDigest(nonPe), 0)).toThrow(
/minimum truthful report/iu,
);
});
it("does not claim a missing RTR header when signature coverage hits the work limit", () => {
const bytes = nativeAotPeFixture();
const limits: NativeAotParserLimits = {
sourceBytes: bytes.length,
workUnits: 8,
workingMemoryBytes: 1024,
};
const report = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
REPORT_BUDGET,
undefined,
limits,
).summary;
expect(report).toMatchObject({
status: "partial",
header_address: null,
truncated: true,
coverage: {
rtr_signature_scan_bytes_examined: 8,
rtr_signature_scan_bytes_omitted: expect.any(Number),
truncation_reason: "work-unit-budget",
},
});
expect(report.reason).not.toMatch(/No supported ReadyToRun directory/iu);
});
it("does not claim a missing root when table discovery is incomplete", () => {
const bytes = nativeAotPeFixture();
const report = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
REPORT_BUDGET,
undefined,
{
sourceBytes: bytes.length,
workUnits: 10_000,
workingMemoryBytes: 1024,
},
).summary;
expect(report.truncated).toBe(true);
expect(report.reason).toMatch(/before determining whether.*root exists/iu);
expect(report.reason).not.toMatch(
/no unique System\.Object.*fully examined/iu,
);
});
});
describe("NativeAOT detail and summary byte accounting", () => {
it("admits serialized MethodTable memory descriptors before emitting them", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const detail = (budget: number) =>
parseNativeAotPe(bytes, sha, REPORT_BUDGET).readTypeDetail(
"0x140002280",
budget,
);
const complete = detail(REPORT_BUDGET);
expect(complete?.derived_memory).not.toBeNull();
let low = 0;
let high = Buffer.byteLength(JSON.stringify(complete));
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (detail(middle)?.derived_memory != null) high = middle;
else low = middle + 1;
}
const atBoundary = detail(low);
const belowBoundary = detail(low - 1);
expect(atBoundary?.derived_memory).not.toBeNull();
expect(Buffer.byteLength(JSON.stringify(atBoundary))).toBeLessThanOrEqual(
low,
);
expect(belowBoundary?.derived_memory ?? null).toBeNull();
expect(belowBoundary?.diagnostics.join(" ")).toContain(
"omitted because the final serialized report exceeded its byte budget",
);
expect(
belowBoundary === undefined
? 0
: Buffer.byteLength(JSON.stringify(belowBoundary)),
).toBeLessThanOrEqual(low - 1);
});
it("truncates report rows before exceeding its JSON byte budget", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const complete = parseNativeAotPe(bytes, sha, REPORT_BUDGET);
const fullBytes = Buffer.byteLength(JSON.stringify(complete.summary));
let budget = fullBytes - 1;
let summary: ReturnType<typeof parseNativeAotPe>["summary"] | undefined;
while (budget > 0) {
try {
const candidate = parseNativeAotPe(bytes, sha, budget);
if (candidate.summary.truncated) {
summary = candidate.summary;
break;
}
} catch {
// Budgets below the fixed report header cannot carry an honest summary.
}
budget--;
}
if (summary === undefined)
throw new Error("No partial summary budget found");
expect(summary.truncated).toBe(true);
expect(
(summary.types_omitted ?? 0) +
(summary.frozen_strings_omitted ?? 0) +
(summary.coverage.scan_bytes_omitted ?? 0) +
(summary.coverage.frozen_scan_bytes_omitted ?? 0),
).toBeGreaterThan(0);
expect(Buffer.byteLength(JSON.stringify(summary))).toBeLessThanOrEqual(
budget,
);
});
});
-105
View File
@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
parseNativeAotPe,
} from "./nativeAotPe.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
} from "../../../tests/fixtures/nativeaotPe.js";
const SUMMARY_BUDGET = NATIVE_AOT_MAX_REPORT_BYTES;
describe("read-only NativeAOT PE parser", () => {
it("recovers a bounded RTR9.1 type graph and frozen literal from captured bytes", () => {
const bytes = nativeAotPeFixture();
const before = Buffer.from(bytes);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
"0x140000000",
);
expect(bytes).toEqual(before);
expect(parsed.summary).toMatchObject({
format: "dotnet-nativeaot",
status: "complete",
analysis_mode: "read-only-derived-overlay",
format_major: 9,
format_minor: 1,
method_tables: 3,
frozen_strings: [
{ address: "0x140002300", value: "REA_NATIVEAOT_FROZEN" },
],
derived_overlay: { address: "0x140002380", size_bytes: 1 },
coverage: {
basis: "raw-image-and-rehydrated-pointer-scan",
method_tables_recovered: 3,
frozen_string_candidates: 1,
},
});
expect(parsed.readTypeDetail("0x140002280", SUMMARY_BUDGET)).toMatchObject({
source: "read-only-derived-overlay",
method_table_address: "0x140002280",
related_type: { address: "0x140002200", type: null },
virtual_slots: [{ slot: 0, target_address: "0x140001040" }],
});
expect(nativeMetadataRecoverySummarySchema.parse(parsed.summary)).toEqual(
parsed.summary,
);
});
it("fails closed on snapshot identity mismatch", () => {
const bytes = nativeAotPeFixture();
expect(() =>
parseNativeAotPe(bytes, "0".repeat(64), SUMMARY_BUDGET),
).toThrow(/digest/iu);
});
it("reports a detected but unsupported RTR version as partial without guessed types", () => {
const bytes = nativeAotPeFixture(10, 0);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
);
expect(parsed.summary).toMatchObject({
status: "partial",
format_major: 10,
format_minor: 0,
method_tables: 0,
types: [],
});
});
it("does not claim an image mapping when Ghidra rebased the image", () => {
const bytes = nativeAotPeFixture();
expect(
parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
"0x180000000",
).summary.reason,
).toMatch(/preferred image base/iu);
});
it("reports an unsupported hydration opcode as partial without mutating the snapshot", () => {
const bytes = nativeAotPeFixture();
bytes.writeUInt8(6, 0x704);
const before = Buffer.from(bytes);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
);
expect(bytes).toEqual(before);
expect(parsed.summary).toMatchObject({
status: "partial",
derived_overlay: null,
diagnostics: [
expect.stringContaining("Unsupported DEHYDRATED_DATA command"),
],
});
});
});
File diff suppressed because it is too large Load Diff
+11 -85
View File
@@ -6,8 +6,6 @@ const relatedType = z.strictObject({ address, type: z.string().nullable() });
/** Runtime-format metadata recovered in an analysis image, with explicit inference boundaries. */
export const nativeMetadataRecoverySchema = z.strictObject({
format: z.literal("dotnet-nativeaot"),
/** Identifies parser-only facts without claiming Ghidra defined a DataType. */
source: z.enum(["analysis-database", "read-only-derived-overlay"]).optional(),
status: z.enum(["complete", "partial"]),
header_address: address,
format_major: z.number().int().nonnegative(),
@@ -27,29 +25,19 @@ export const nativeMetadataRecoverySchema = z.strictObject({
basis: z.literal("method-table-pointer"),
}),
),
derived_memory: z
.strictObject({
address,
size_bytes: z.number().int().nonnegative(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
file_offset: z.null(),
})
.nullable(),
truncated: z.boolean().optional(),
virtual_slots_omitted: z.number().int().nonnegative().optional(),
interfaces_omitted: z.number().int().nonnegative().optional(),
output_budget_bytes: z.number().int().nonnegative().optional(),
derived_memory: z.strictObject({
address,
size_bytes: z.number().int().nonnegative(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
file_offset: z.null(),
}),
diagnostics: z.array(z.string()),
limitations: z.array(z.string()),
});
export type NativeMetadataRecovery = z.infer<
typeof nativeMetadataRecoverySchema
>;
/** Discover a recovered runtime format and reusable type identities from the loaded image. */
export const nativeMetadataRecoverySummarySchema = z.strictObject({
format: z.literal("dotnet-nativeaot"),
analysis_mode: z.literal("read-only-derived-overlay").optional(),
status: z.enum(["complete", "partial", "not_applicable"]),
reason: z.string().nullable(),
analysis_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
@@ -59,76 +47,14 @@ export const nativeMetadataRecoverySummarySchema = z.strictObject({
format_minor: z.number().int().nonnegative().nullable(),
method_tables: z.number().int().nonnegative(),
types: z.array(relatedType),
frozen_strings: z
.array(z.strictObject({ address, value: z.string() }))
.optional(),
truncated: z.boolean().optional(),
types_omitted: z.number().int().nonnegative().optional(),
frozen_strings_omitted: z.number().int().nonnegative().optional(),
output_budget_bytes: z.number().int().nonnegative().optional(),
derived_overlay: z
.strictObject({
address,
size_bytes: z.number().int().nonnegative(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
})
.nullable()
.optional(),
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory,
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory.nullable(),
coverage: z
.strictObject({
frozen_object_candidates: z.number().int().nonnegative().optional(),
frozen_objects_annotated: z.number().int().nonnegative().optional(),
pointer_candidates_examined: z.number().int().nonnegative().optional(),
pointer_slots_examined: z.number().int().nonnegative().optional(),
scan_bytes_examined: z.number().int().nonnegative().optional(),
scan_bytes_omitted: z.number().int().nonnegative().optional(),
rtr_signature_scan_bytes_examined: z
.number()
.int()
.nonnegative()
.optional(),
rtr_signature_scan_bytes_omitted: z
.number()
.int()
.nonnegative()
.optional(),
method_table_validation_items_examined: z
.number()
.int()
.nonnegative()
.optional(),
work_units_examined: z.number().int().nonnegative().optional(),
method_tables_recovered: z.number().int().nonnegative().optional(),
frozen_string_candidates: z.number().int().nonnegative().optional(),
frozen_scan_bytes_examined: z.number().int().nonnegative().optional(),
frozen_scan_bytes_omitted: z.number().int().nonnegative().optional(),
frozen_string_data_bytes_examined: z
.number()
.int()
.nonnegative()
.optional(),
source_bytes_examined: z.number().int().nonnegative().optional(),
source_bytes_omitted: z.number().int().nonnegative().optional(),
source_byte_budget_bytes: z.number().int().nonnegative().optional(),
work_budget_units: z.number().int().nonnegative().optional(),
working_memory_budget_bytes: z.number().int().nonnegative().optional(),
working_memory_bytes_peak: z.number().int().nonnegative().optional(),
truncation_reason: z
.enum([
"source-byte-budget",
"work-unit-budget",
"working-memory-budget",
"response-budget",
])
.optional(),
basis: z.enum([
frozen_object_candidates: z.number().int().nonnegative(),
frozen_objects_annotated: z.number().int().nonnegative(),
basis: z.literal(
"rehydrated-pointer-candidates-and-committed-instance-types",
"raw-image-pointer-scan",
"raw-image-and-rehydrated-pointer-scan",
"unsupported-layout",
"no-rtr-header",
]),
),
})
.nullable(),
diagnostics: z.array(z.string()),
-53
View File
@@ -603,56 +603,3 @@ it("retains direct and literal computed call names", () => {
expect.objectContaining({ name: "literal-tool" }),
]);
});
it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => {
const result = analyzeCapturedWebBundle(
inspection(`
const n = createTRPCClient({ links: [httpBatchLink({ url: "/api/trpc" })] });
e.invoice.list.useQuery({ competencia: "2026-09" });
n.empresa.byCnpj.query("00000000000191");
const q = "query ListarNotas($competencia: String!) { notas { id } }";
this.client.query({ query: q });
`),
);
expect(
result.observations.endpoints.map(({ value, mechanism }) => ({
value,
mechanism,
})),
).toEqual([
{ value: "/api/trpc", mechanism: "trpc:link:httpBatchLink" },
{ value: "invoice.list", mechanism: "trpc:procedure:useQuery" },
{ value: "empresa.byCnpj", mechanism: "trpc:procedure:query" },
{ value: "ListarNotas", mechanism: "graphql:query" },
]);
});
it("keeps RPC lexical identity and bundled factory recognition in captured scripts", () => {
const longOperation = "Operation" + "x".repeat(512);
const document =
"#" +
"comment".repeat(700) +
"\nquery " +
longOperation +
" { users { id } }";
const result = analyzeCapturedWebBundle(
inspection(`
function first() { const c = createTRPCClient({}); }
function unrelated(c) { c.users.query("select 1"); }
const c = (0, t.createTRPCClient)({ links: [(0, t.httpBatchLink)({ url: "/api/rpc" })] });
c.posts.query();
const doc = ${JSON.stringify(document)};
`),
);
expect(
result.observations.endpoints.map(({ value, mechanism }) => ({
value,
mechanism,
})),
).toEqual([
{ value: "/api/rpc", mechanism: "trpc:link:httpBatchLink" },
{ value: "posts", mechanism: "trpc:procedure:query" },
{ value: longOperation, mechanism: "graphql:query" },
]);
expect(result.completeness.status).toBe("complete");
});
+2 -23
View File
@@ -5,14 +5,8 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascript/javascriptSemanticTraversal.js";
import {
classifyParsedJavaScriptOpenReceivers,
classifyParsedJavaScriptRpcClientRootsSteps,
} from "./javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
import {
@@ -28,7 +22,6 @@ import {
resolveSpecifier,
isUrlLikeModuleSpecifier,
} from "./webBundleAnalyzerAst.js";
import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js";
type BundleObservations = WebBundleAnalysis["observations"];
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
@@ -73,24 +66,10 @@ export const analyzeScript = (
accumulator.parsedScripts += 1;
detectVendorFingerprints(script, accumulator);
const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file);
const rpc = createJavaScriptRpcScan(
script.source.artifact.text,
completeSemanticSteps(classifyParsedJavaScriptRpcClientRootsSteps(file)),
);
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator, openReceiverFacts);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: endpoint.value,
mechanism: endpoint.mechanism,
node,
accumulator,
});
},
});
};
-14
View File
@@ -17,13 +17,6 @@ import {
ghidraMipsProfileParameters,
ghidraMipsUnsupportedReason,
} from "./GhidraMipsProfile.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
NATIVE_AOT_MAX_SOURCE_BYTES,
NATIVE_AOT_MAX_WORKING_MEMORY_BYTES,
NATIVE_AOT_MAX_WORK_UNITS,
NATIVE_AOT_PROFILE_CONTRACT_REVISION,
} from "../domain/native/nativeAotPe.js";
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
export const resolveGhidraAnalysisProfile = (
@@ -76,13 +69,6 @@ export const resolveGhidraAnalysisProfile = (
? {
executable_role: target.executableRole ?? null,
managed: target.managed ?? null,
native_aot_metadata: {
contract_revision: NATIVE_AOT_PROFILE_CONTRACT_REVISION,
source_bytes: NATIVE_AOT_MAX_SOURCE_BYTES,
work_units: NATIVE_AOT_MAX_WORK_UNITS,
working_memory_bytes: NATIVE_AOT_MAX_WORKING_MEMORY_BYTES,
report_bytes: NATIVE_AOT_MAX_REPORT_BYTES,
},
}
: {}),
architecture: target.architecture ?? null,
+3 -95
View File
@@ -43,21 +43,6 @@ import {
} from "./GhidraSessionValues.js";
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
import { readFile } from "node:fs/promises";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import {
openRegularFile,
RegularFileChangedError,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
export type GhidraTargetSnapshot =
| { readonly kind: "captured"; readonly bytes: Buffer }
| {
readonly kind: "over-capacity";
readonly sourceBytesAtLeast: number;
readonly maximumBytes: number;
};
import { connectGhidraSocket } from "./GhidraSocketConnection.js";
import {
createGhidraEndpoint,
@@ -94,7 +79,6 @@ export class GhidraClient {
#socketRoot: PrivateRuntimeRoot | undefined;
#endpointPath: string | undefined;
#snapshotPath: string | undefined;
readonly #snapshotOwners = new Set<OwnedFileHandle>();
#targetAdmission: JsonValue | undefined;
#token: string | undefined;
// Retain authentication identities for diagnostics from late request settlement.
@@ -243,10 +227,7 @@ export class GhidraClient {
}
/** Read the private immutable source for independent import verification. */
async readTargetSnapshot(
maximumBytes?: number,
signal?: AbortSignal,
): Promise<Result<GhidraTargetSnapshot, GhidraSessionError>> {
async readTargetSnapshot(): Promise<Result<Buffer, GhidraSessionError>> {
if (this.#snapshotPath === undefined)
return err(
this.#failure(
@@ -255,80 +236,11 @@ export class GhidraClient {
),
);
try {
if (maximumBytes !== undefined) {
signal?.throwIfAborted();
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
throw new RangeError("Snapshot byte limit must be a safe integer");
const handle = await openRegularFile(this.#snapshotPath, {
symlinks: "reject",
});
const owner = new OwnedFileHandle(handle);
this.#snapshotOwners.add(owner);
let result: GhidraTargetSnapshot;
let readFailure: unknown;
try {
const metadata = await handle.stat();
signal?.throwIfAborted();
if (metadata.size > maximumBytes)
result = {
kind: "over-capacity",
sourceBytesAtLeast: metadata.size,
maximumBytes,
};
else {
const bytes = await readBoundedFileBytes(
handle,
maximumBytes,
signal,
metadata.size,
);
signal?.throwIfAborted();
const after = await handle.stat();
if (
bytes !== undefined &&
bytes.length === metadata.size &&
sameRegularFileState(metadata, after)
) {
result = { kind: "captured", bytes };
} else if (
bytes === undefined &&
after.isFile() &&
after.size > maximumBytes
) {
result = {
kind: "over-capacity",
sourceBytesAtLeast: after.size,
maximumBytes,
};
} else {
throw new RegularFileChangedError(this.#snapshotPath);
}
}
} catch (cause: unknown) {
readFailure = cause;
}
try {
await owner.close();
this.#snapshotOwners.delete(owner);
} catch (closeFailure: unknown) {
throw new AggregateError(
readFailure === undefined
? [closeFailure]
: [readFailure, closeFailure],
"Ghidra target snapshot read or descriptor cleanup failed",
);
}
if (readFailure !== undefined) throw readFailure;
return ok(result!);
}
return ok({
kind: "captured",
bytes: await readFile(this.#snapshotPath),
});
return ok(await readFile(this.#snapshotPath));
} catch (cause: unknown) {
return err(
this.#failure(
signal?.aborted === true ? "cancelled" : "protocol",
"protocol",
"Ghidra target snapshot could not be read for load-image verification",
cause,
),
@@ -690,10 +602,6 @@ export class GhidraClient {
this.#lastDiagnostics = this.#diagnostics();
this.#process = undefined;
this.#launch = undefined;
for (const owner of this.#snapshotOwners) {
await owner.close();
this.#snapshotOwners.delete(owner);
}
try {
await this.#removeRuntimeRoots();
} catch (cause: unknown) {
+9 -87
View File
@@ -1,5 +1,4 @@
import type { AnalysisOperation } from "../application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -13,91 +12,23 @@ import {
attestGhidraDosComLoadImage,
attestGhidraDosLoadImage,
} from "./GhidraLoadImageValues.js";
import { type NativeAotPeResult } from "../domain/native/nativeAotPe.js";
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
import type { GhidraSessionError } from "./GhidraSessionError.js";
type LoadImageSnapshotClient = {
readonly readTargetSnapshot?: (
maximumBytes?: number,
signal?: AbortSignal,
) => Promise<Result<GhidraTargetSnapshot, GhidraSessionError>>;
readonly readTargetSnapshot?: () => Promise<
Result<Buffer, GhidraSessionError>
>;
};
type NativeLoadImageAttestationInput = {
readonly target: BinaryTarget;
readonly operation: AnalysisOperation;
readonly measured: JsonValue;
readonly client: LoadImageSnapshotClient;
readonly mapSessionError: (failure: GhidraSessionError) => AnalysisError;
readonly nativeAotResult?: NativeAotPeResult | undefined;
readonly signal?: AbortSignal | undefined;
};
const isSignalAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
/** Independently verify measured load-image observations for admitted DOS targets. */
export const attestGhidraNativeLoadImage = async (
input: NativeLoadImageAttestationInput,
target: BinaryTarget,
operation: AnalysisOperation,
measured: JsonValue,
client: LoadImageSnapshotClient,
mapSessionError: (failure: GhidraSessionError) => AnalysisError,
): Promise<Result<JsonValue, AnalysisError>> => {
const {
target,
operation,
measured,
client,
mapSessionError,
nativeAotResult,
signal,
} = input;
if (isSignalAborted(signal))
return err(new AnalysisCancelledError(operation));
const observations = nativeLoadImageObservationSchema.parse(measured);
if (target.format === "pe") {
if (nativeAotResult === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The Ghidra provider did not produce read-only NativeAOT metadata for this admitted PE snapshot.",
},
}),
);
if (isSignalAborted(signal))
return err(new AnalysisCancelledError(operation));
try {
return ok(
jsonValueSchema.parse(
nativeLoadImageSchema.parse({
status: "unsupported",
reason:
"Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.",
observations: {
...observations,
metadata_recovery: [
...(observations.metadata_recovery ?? []),
nativeAotResult.summary,
],
},
limitations: [
"Load-image verification is not performed for PE targets; NativeAOT metadata observations are derived separately from the exact admitted snapshot.",
],
}),
),
);
} catch (cause: unknown) {
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
cause instanceof Error
? cause.message
: "NativeAOT metadata could not be represented in the load-image result",
},
}),
);
}
}
if (target.format !== "dos-mz" && target.format !== "dos-com") {
return ok(
jsonValueSchema.parse(
@@ -124,20 +55,11 @@ export const attestGhidraNativeLoadImage = async (
);
const snapshot = await client.readTargetSnapshot();
if (!snapshot.ok) return err(mapSessionError(snapshot.error));
if (snapshot.value.kind !== "captured")
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The uncapped DOS load-image snapshot was unexpectedly not captured.",
},
}),
);
const attested = (
target.format === "dos-com"
? attestGhidraDosComLoadImage
: attestGhidraDosLoadImage
)(snapshot.value.bytes, target.sha256 ?? "", observations);
)(snapshot.value, target.sha256 ?? "", observations);
if (!attested.ok)
return err(
new ProviderAdapterError("ghidra", operation, {
+1 -522
View File
@@ -10,22 +10,16 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
} from "../../tests/fixtures/nativeaotPe.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { parseConfig } from "../config/parseConfig.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { parseExecutableHeader } from "../domain/binaryTarget.js";
import { GhidraProvider } from "./GhidraProvider.js";
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import { err, ok } from "../domain/result.js";
import type { Result } from "../domain/result.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
import { silentLogger } from "../logger.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
@@ -225,25 +219,10 @@ describe("Ghidra platform support", () => {
expect(profile.parameters).toMatchObject({
executable_role: target.executableRole,
managed: target.managed,
native_aot_metadata: {
contract_revision: "rtr-9.1-x64-pe-read-only-v1",
source_bytes: 128 * 1024 * 1024,
work_units: 64 * 1024 * 1024,
working_memory_bytes: 64 * 1024 * 1024,
report_bytes: 4 * 1024 * 1024,
},
});
digests.add(profile.digest);
}
expect(digests.size).toBe(3);
const nonPe = await ghidra.resolveAnalysisProfile(
executableTarget("elf", "x86_64"),
);
if (!nonPe.ok || nonPe.value.profile === null)
throw new Error("Expected a non-PE analysis profile");
expect(nonPe.value.profile.parameters).not.toHaveProperty(
"native_aot_metadata",
);
});
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
@@ -738,8 +717,7 @@ describe("Ghidra measured load-image projection", () => {
...(state === "missing-snapshot"
? {}
: {
readTargetSnapshot: () =>
Promise.resolve(ok({ kind: "captured", bytes: fixture.bytes })),
readTargetSnapshot: () => Promise.resolve(ok(fixture.bytes)),
}),
});
const ghidra = provider(installationHost(), factory);
@@ -779,505 +757,6 @@ describe("Ghidra measured load-image projection", () => {
);
});
it("returns read-only NativeAOT metadata inline and drills down by derived MethodTable address", async () => {
const bytes = nativeAotPeFixture();
const observations = fixtureDosLoadImage().observation;
observations.image_base = "0x140000000";
const factory: GhidraProviderClientFactory = () => ({
start: () =>
Promise.resolve(
ok({
...sessionInfo(),
target: { ...sessionInfo().target, image_base: "0x140000000" },
}),
),
callTool: (operation) =>
Promise.resolve(
ok(
jsonValueSchema.parse(
operation === "inspect_native_load_image"
? observations
: {
status: "unavailable",
reason: "No Ghidra DataType is defined at this address.",
id: null,
name: null,
kind: "unavailable",
source: "analysis-database",
source_archive: null,
address: null,
size_bytes: null,
alignment_bytes: null,
packing_enabled: null,
referenced_type: null,
array_count: null,
array_stride_bytes: null,
fields: [],
members: [],
total_fields: 0,
truncated: false,
limitations: [],
},
),
),
),
readTargetSnapshot: () => Promise.resolve(ok({ kind: "captured", bytes })),
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const image = await client.execute("inspect_native_load_image", {});
expect(image).toMatchObject({
ok: true,
value: {
result: {
status: "unsupported",
observations: {
metadata_recovery: [
{
status: "complete",
analysis_mode: "read-only-derived-overlay",
method_tables: 3,
frozen_strings: [{ value: "REA_NATIVEAOT_FROZEN" }],
},
],
},
},
rawResult: observations,
},
});
const detail = await client.execute("inspect_native_data_type", {
address: "0x140002280",
});
expect(detail).toMatchObject({
ok: true,
value: {
result: {
status: "unavailable",
source: "analysis-database",
metadata_recovery: {
source: "read-only-derived-overlay",
method_table_address: "0x140002280",
related_type: { address: "0x140002200", type: null },
},
},
},
});
await client.close();
});
it("derives PE metadata for a direct type-address query without a load-image call", async () => {
const bytes = nativeAotPeFixture();
const operations: string[] = [];
const factory: GhidraProviderClientFactory = () => ({
start: () =>
Promise.resolve(
ok({
...sessionInfo(),
target: { ...sessionInfo().target, image_base: "0x140000000" },
}),
),
callTool: (operation) => {
operations.push(operation);
return Promise.resolve(
ok(
jsonValueSchema.parse({
status: "unavailable",
reason: "No Ghidra DataType is defined at this address.",
id: null,
name: null,
kind: "unavailable",
source: "analysis-database",
source_archive: null,
address: null,
size_bytes: null,
alignment_bytes: null,
packing_enabled: null,
referenced_type: null,
array_count: null,
array_stride_bytes: null,
fields: [],
members: [],
total_fields: 0,
truncated: false,
limitations: [],
}),
),
);
},
readTargetSnapshot: () => Promise.resolve(ok({ kind: "captured", bytes })),
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const detail = await client.execute("inspect_native_data_type", {
address: "0x140002280",
});
expect(detail).toMatchObject({
ok: true,
value: {
result: {
metadata_recovery: {
source: "read-only-derived-overlay",
method_table_address: "0x140002280",
related_type: { address: "0x140002200", type: null },
},
},
},
});
expect(operations).toEqual(["inspect_native_data_type"]);
await client.close();
});
it("shares one session snapshot across callers while isolating cancellation and rejecting a changed base", async () => {
const bytes = nativeAotPeFixture();
const baseObservation = fixtureDosLoadImage().observation;
baseObservation.image_base = "0x140000000";
let calls = 0;
let snapshotReads = 0;
let snapshotSignal: AbortSignal | undefined;
let releaseSnapshot:
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
| undefined;
let markSnapshotStarted: (() => void) | undefined;
const snapshotStarted = new Promise<void>((resolve) => {
markSnapshotStarted = resolve;
});
const snapshotResult = new Promise<
Result<GhidraTargetSnapshot, GhidraSessionError>
>((resolve) => {
releaseSnapshot = resolve;
});
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: (operation) => {
calls += 1;
const observation = {
...baseObservation,
source_files: [
{ ...baseObservation.source_files[0]!, name: `caller-${calls}` },
],
};
return Promise.resolve(
ok(
jsonValueSchema.parse(
operation === "inspect_native_load_image"
? observation
: {
status: "unavailable",
reason: "No Ghidra DataType is defined at this address.",
id: null,
name: null,
kind: "unavailable",
source: "analysis-database",
source_archive: null,
address: null,
size_bytes: null,
alignment_bytes: null,
packing_enabled: null,
referenced_type: null,
array_count: null,
array_stride_bytes: null,
fields: [],
members: [],
total_fields: 0,
truncated: false,
limitations: [],
},
),
),
);
},
readTargetSnapshot: (_maximumBytes, signal) => {
snapshotReads += 1;
snapshotSignal = signal;
markSnapshotStarted?.();
return snapshotResult;
},
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const firstCaller = new AbortController();
const secondCaller = new AbortController();
const first = client.execute(
"inspect_native_load_image",
{},
{
signal: firstCaller.signal,
},
);
const second = client.execute(
"inspect_native_load_image",
{},
{
signal: secondCaller.signal,
},
);
await snapshotStarted;
expect(snapshotReads).toBe(1);
expect(snapshotSignal).toBeDefined();
expect(snapshotSignal).not.toBe(firstCaller.signal);
expect(snapshotSignal).not.toBe(secondCaller.signal);
firstCaller.abort();
await expect(first).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
releaseSnapshot?.(ok({ kind: "captured", bytes }));
const secondResult = await second;
expect(secondResult).toMatchObject({
ok: true,
value: {
rawResult: {
source_files: [{ name: "caller-2" }],
},
result: {
observations: {
source_files: [{ name: "caller-2" }],
},
},
},
});
expect(snapshotReads).toBe(1);
expect(calls).toBe(2);
baseObservation.image_base = "0x150000000";
const conflictingBase = await client.execute("inspect_native_load_image", {});
expect(conflictingBase).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
diagnostics: {
reason: expect.stringContaining("different loaded image base"),
},
},
});
expect(snapshotReads).toBe(1);
await client.close();
expect(snapshotSignal?.aborted).toBe(true);
});
it("retries a failed NativeAOT snapshot read without retaining a failed derivation", async () => {
const bytes = nativeAotPeFixture();
const observations = fixtureDosLoadImage().observation;
observations.image_base = "0x140000000";
let snapshotReads = 0;
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
readTargetSnapshot: () => {
snapshotReads += 1;
return Promise.resolve(
snapshotReads === 1
? err(
new GhidraSessionError(
"process",
"temporary snapshot read failure",
),
)
: ok({ kind: "captured", bytes }),
);
},
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
await expect(
client.execute("inspect_native_load_image", {}),
).resolves.toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
await expect(
client.execute("inspect_native_load_image", {}),
).resolves.toMatchObject({
ok: true,
value: {
result: {
observations: {
metadata_recovery: [{ status: "complete" }],
},
},
},
});
expect(snapshotReads).toBe(2);
await client.close();
});
it("rejects all waiters when the loaded image base changes during derivation", async () => {
const bytes = nativeAotPeFixture();
const observation = fixtureDosLoadImage().observation;
let calls = 0;
let resolveSnapshot:
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
| undefined;
let markSnapshotStarted: (() => void) | undefined;
const snapshotStarted = new Promise<void>((resolve) => {
markSnapshotStarted = resolve;
});
const snapshot = new Promise<
Result<GhidraTargetSnapshot, GhidraSessionError>
>((resolve) => {
resolveSnapshot = resolve;
});
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => {
calls += 1;
return Promise.resolve(
ok(
jsonValueSchema.parse({
...observation,
image_base: calls === 1 ? "0x140000000" : "0x150000000",
}),
),
);
},
readTargetSnapshot: () => {
markSnapshotStarted?.();
return snapshot;
},
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const originalBase = client.execute("inspect_native_load_image", {});
await snapshotStarted;
const changedBase = await client.execute("inspect_native_load_image", {});
expect(changedBase).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
diagnostics: {
observed_image_base: "0x150000000",
measured_observation: { image_base: "0x150000000" },
},
},
});
resolveSnapshot?.(ok({ kind: "captured", bytes }));
await expect(originalBase).resolves.toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
diagnostics: {
reason: expect.stringContaining("conflicting loaded image bases"),
measured_observation: { image_base: "0x140000000" },
conflicting_observation: { image_base: "0x150000000" },
},
},
});
expect(calls).toBe(2);
await client.close();
});
it("cancels an in-flight NativeAOT waiter when its owning session closes", async () => {
const bytes = nativeAotPeFixture();
const observations = fixtureDosLoadImage().observation;
observations.image_base = "0x140000000";
let resolveSnapshot:
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
| undefined;
let snapshotSignal: AbortSignal | undefined;
let markSnapshotStarted: (() => void) | undefined;
const snapshotStarted = new Promise<void>((resolve) => {
markSnapshotStarted = resolve;
});
const snapshot = new Promise<
Result<GhidraTargetSnapshot, GhidraSessionError>
>((resolve) => {
resolveSnapshot = resolve;
});
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
readTargetSnapshot: (_maximumBytes, signal) => {
snapshotSignal = signal;
markSnapshotStarted?.();
return snapshot;
},
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const pending = client.execute("inspect_native_load_image", {});
await snapshotStarted;
await client.close();
expect(snapshotSignal?.aborted).toBe(true);
resolveSnapshot?.(ok({ kind: "captured", bytes }));
await expect(pending).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
describe("Ghidra NativeAOT snapshot budget", () => {
it("reports snapshot-capacity omissions inline through the load-image result", async () => {
const observations = fixtureDosLoadImage().observation;
const target = peTarget("x86_64");
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
readTargetSnapshot: () =>
Promise.resolve(
ok({
kind: "over-capacity",
sourceBytesAtLeast: 128 * 1024 * 1024 + 1,
maximumBytes: 128 * 1024 * 1024,
}),
),
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
const image = await client.execute("inspect_native_load_image", {});
expect(image).toMatchObject({
ok: true,
value: {
result: {
observations: {
metadata_recovery: [
{
status: "partial",
truncated: true,
coverage: { truncation_reason: "source-byte-budget" },
},
],
},
},
},
});
await client.close();
});
});
const sessionInfo = () => ({
name: "REA Ghidra bridge" as const,
run_id: "11111111-1111-4111-8111-111111111111",
+6 -337
View File
@@ -8,6 +8,7 @@ import {
type AnalysisOperation,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config/types.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ghidraSeedCommitmentSchema,
ghidraSeedFailure,
@@ -29,7 +30,6 @@ import {
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { type AnalysisError } from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import type { Logger } from "pino";
@@ -52,18 +52,6 @@ import {
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
NATIVE_AOT_MAX_SOURCE_BYTES,
nativeAotSnapshotOverCapacity,
parseNativeAotPe,
type NativeAotPeResult,
} from "../domain/native/nativeAotPe.js";
import {
ABORTED,
waitForAbortable,
} from "../application/binary/AbortablePromise.js";
import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js";
import {
GHIDRA_PROVIDER_IDENTITY,
healthLimitations,
@@ -89,250 +77,6 @@ export type GhidraProviderClientFactory = (
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
type NativeAotSnapshotOwner = {
readonly derive: (
imageBase: string,
operation: AnalysisOperation,
callerSignal: AbortSignal | undefined,
observations: JsonValue,
) => Promise<Result<NativeAotPeResult, AnalysisError>>;
readonly readTypeDetail: (
address: string,
) => ReturnType<NativeAotPeResult["readTypeDetail"]>;
readonly close: () => void;
};
/** Own one session's captured PE snapshot and immutable NativeAOT derivation. */
const createNativeAotSnapshotOwner = (input: {
readonly client: GhidraProviderClientInstance;
readonly target: BinaryTarget;
readonly startupTimeoutMs: number;
}): NativeAotSnapshotOwner => {
const { client, target, startupTimeoutMs } = input;
const controller = new AbortController();
let identity: string | undefined;
let result: NativeAotPeResult | undefined;
let pending:
| {
readonly identity: string;
readonly promise: Promise<Result<NativeAotPeResult, AnalysisError>>;
}
| undefined;
let closed = false;
let identityConflict = false;
let identityConflictObservation:
| { readonly imageBase: string; readonly observations: JsonValue }
| undefined;
const identityError = (
operation: AnalysisOperation,
reason: string,
observedBase: string,
observations: JsonValue,
): AnalysisError =>
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason,
artifact_sha256: target.sha256,
bound_image_base:
identity === undefined
? null
: `0x${identity.slice(target.sha256.length + 1)}`,
observed_image_base: observedBase,
measured_observation: observations,
conflicting_image_base: identityConflictObservation?.imageBase ?? null,
conflicting_observation:
identityConflictObservation?.observations ?? null,
},
});
const identityConflictFailure = (
operation: AnalysisOperation,
observedBase: string,
observations: JsonValue,
): Result<NativeAotPeResult, AnalysisError> => {
return err(
identityError(
operation,
"The Ghidra session reported conflicting loaded image bases; reopen the analysis session to establish a new identity.",
observedBase,
observations,
),
);
};
const derive = async (
imageBase: string,
operation: AnalysisOperation,
callerSignal: AbortSignal | undefined,
observations: JsonValue,
): Promise<Result<NativeAotPeResult, AnalysisError>> => {
if (callerSignal?.aborted || closed)
return err(new AnalysisCancelledError(operation));
if (identityConflict)
return identityConflictFailure(operation, imageBase, observations);
if (controller.signal.aborted)
return err(new AnalysisCancelledError(operation));
let normalizedBase: string;
try {
normalizedBase = BigInt(imageBase).toString(16);
} catch (cause: unknown) {
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"Ghidra returned an invalid loaded image base for NativeAOT identity binding.",
artifact_sha256: target.sha256,
observed_image_base: imageBase,
measured_observation: observations,
},
cause,
}),
);
}
const key = `${target.sha256}:${normalizedBase}`;
if (identity !== undefined && identity !== key) {
identityConflict = true;
identityConflictObservation = { imageBase, observations };
result = undefined;
controller.abort();
return err(
identityError(
operation,
"The Ghidra session reported a different loaded image base after NativeAOT metadata was bound to its captured snapshot; reopen the analysis session to establish a new identity.",
imageBase,
observations,
),
);
}
identity ??= key;
if (result !== undefined) {
const settled = await waitForAbortable(
Promise.resolve(ok(result)),
callerSignal,
);
if (closed) return err(new AnalysisCancelledError(operation));
if (identityConflict)
return identityConflictFailure(operation, imageBase, observations);
if (controller.signal.aborted)
return err(new AnalysisCancelledError(operation));
return settled === ABORTED
? err(new AnalysisCancelledError(operation))
: settled;
}
if (pending !== undefined && pending.identity !== key)
return err(
identityError(
operation,
"A NativeAOT snapshot derivation is already bound to a different loaded image base.",
imageBase,
observations,
),
);
if (pending === undefined) {
const promise: Promise<Result<NativeAotPeResult, AnalysisError>> =
(async () => {
try {
if (client.readTargetSnapshot === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The Ghidra client does not expose its immutable target snapshot for NativeAOT metadata inspection.",
},
}),
);
const snapshot = await client.readTargetSnapshot(
NATIVE_AOT_MAX_SOURCE_BYTES,
controller.signal,
);
if (!snapshot.ok)
return err(
projectSessionError(
operation,
snapshot.error,
startupTimeoutMs,
),
);
if (closed) return err(new AnalysisCancelledError(operation));
if (identityConflict)
return identityConflictFailure(
operation,
imageBase,
observations,
);
if (controller.signal.aborted)
return err(new AnalysisCancelledError(operation));
const parsed =
snapshot.value.kind === "over-capacity"
? {
summary: nativeAotSnapshotOverCapacity(
target.sha256,
snapshot.value.sourceBytesAtLeast,
NATIVE_AOT_MAX_REPORT_BYTES,
),
readTypeDetail: () => undefined,
}
: parseNativeAotPe(
snapshot.value.bytes,
target.sha256,
NATIVE_AOT_MAX_REPORT_BYTES,
imageBase,
);
if (closed) return err(new AnalysisCancelledError(operation));
if (identityConflict)
return identityConflictFailure(
operation,
imageBase,
observations,
);
if (controller.signal.aborted)
return err(new AnalysisCancelledError(operation));
return ok(parsed);
} catch (cause: unknown) {
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
cause instanceof Error
? cause.message
: "NativeAOT metadata could not be derived from the captured target snapshot.",
},
cause,
}),
);
}
})();
const current = { identity: key, promise };
pending = current;
void promise.then((settled) => {
if (pending !== current) return;
pending = undefined;
if (settled.ok && !closed && !identityConflict) result = settled.value;
});
}
const settled = await waitForAbortable(pending.promise, callerSignal);
if (closed) return err(new AnalysisCancelledError(operation));
if (identityConflict)
return identityConflictFailure(operation, imageBase, observations);
if (controller.signal.aborted)
return err(new AnalysisCancelledError(operation));
return settled === ABORTED
? err(new AnalysisCancelledError(operation))
: settled;
};
return {
derive,
readTypeDetail: (address) =>
result?.readTypeDetail(address, NATIVE_AOT_MAX_REPORT_BYTES),
close: () => {
closed = true;
controller.abort();
result = undefined;
pending = undefined;
},
};
};
/** Build one AnalysisClient for an admitted Ghidra target and profile. */
export const createGhidraProviderClient = (input: {
readonly config: AppConfig;
@@ -514,14 +258,6 @@ export const createGhidraProviderClient = (input: {
...seedLimitations,
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
];
const nativeAotOwner =
target.format === "pe"
? createNativeAotSnapshotOwner({
client,
target,
startupTimeoutMs: config.ghidraStartupTimeoutMs,
})
: undefined;
return {
execute: async (operation, parameters, options) => {
if (extensionFailure !== undefined) return err(extensionFailure);
@@ -598,83 +334,19 @@ export const createGhidraProviderClient = (input: {
if (!result.ok) return result;
normalized = result.value;
}
if (operation === "inspect_native_data_type") {
const address = input.value.address;
if (typeof address === "string") {
if (target.format === "pe" && nativeAotOwner !== undefined) {
const started = await client.start(options?.signal);
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
),
);
const derived = await nativeAotOwner.derive(
started.value.target.image_base,
operation,
options?.signal,
normalized,
);
if (!derived.ok) return derived;
}
let metadata:
| ReturnType<NativeAotPeResult["readTypeDetail"]>
| undefined;
metadata = nativeAotOwner?.readTypeDetail(address);
if (
metadata !== undefined &&
normalized !== null &&
typeof normalized === "object" &&
!Array.isArray(normalized)
) {
const nativeResult = normalized as Record<string, JsonValue>;
if (nativeResult.metadata_recovery === undefined)
normalized = jsonValueSchema.parse({
...nativeResult,
metadata_recovery: metadata,
});
}
}
}
if (operation === "inspect_native_load_image") {
let derived: NativeAotPeResult | undefined;
if (target.format === "pe") {
const observations =
nativeLoadImageObservationSchema.parse(normalized);
if (nativeAotOwner === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The admitted PE session has no read-only NativeAOT snapshot owner.",
},
}),
);
const result = await nativeAotOwner.derive(
observations.image_base,
operation,
options?.signal,
normalized,
);
if (!result.ok) return result;
derived = result.value;
}
const attested = await attestGhidraNativeLoadImage({
const attested = await attestGhidraNativeLoadImage(
target,
operation,
measured: normalized,
normalized,
client,
mapSessionError: (failure) =>
(failure) =>
projectSessionError(
operation,
failure,
config.ghidraStartupTimeoutMs,
),
nativeAotResult: derived,
signal: options?.signal,
});
);
if (!attested.ok) return attested;
normalized = attested.value;
}
@@ -696,10 +368,7 @@ export const createGhidraProviderClient = (input: {
? []
: [{ provider: committedProfile.provider, observation }];
},
close: async () => {
nativeAotOwner?.close();
return client.close();
},
close: () => client.close(),
};
};
+41 -58
View File
@@ -5,9 +5,7 @@ import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import type { InspectPeResourcesInput } from "../../domain/native/peResources.js";
@@ -39,62 +37,47 @@ export class PeResourcesProvider implements PeResourcesPort {
),
);
} catch (cause) {
return err(peResourcesFailure(cause, input, options?.signal));
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError("inspect_pe_resources", { cause }),
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
return err(
new AnalysisResourceConstraintError(
"inspect_pe_resources",
"memory",
cause.message,
{
max_file_bytes: input.max_file_bytes,
max_entries: input.max_entries,
},
{ cause },
),
);
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
return err(
new AnalysisAccessDeniedError(
"inspect_pe_resources",
input.path,
cause.code,
{ cause },
),
);
return err(
new ArtifactOperationError(
"inspect_pe_resources",
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
undefined,
cause instanceof Error
? cause.message
: "PE artifact inspection failed.",
{ cause },
),
);
}
}
}
const errorCode = (cause: unknown): unknown =>
cause instanceof Error && "code" in cause ? cause.code : undefined;
// A missing or non-regular caller selection is an input error, as for the
// EVM bytecode carrier read; only other I/O remains an artifact fault.
const peResourcesFailure = (
cause: unknown,
input: InspectPeResourcesInput,
signal: AbortSignal | undefined,
) => {
const operation = "inspect_pe_resources";
if (signal?.aborted) return new AnalysisCancelledError(operation, { cause });
const reason = cause instanceof ArtifactReaderFailure ? cause.reason : "io";
const message = cause instanceof Error ? cause.message : String(cause);
if (reason === "limit")
return new AnalysisResourceConstraintError(
operation,
"memory",
message,
{
max_file_bytes: input.max_file_bytes,
max_entries: input.max_entries,
},
{ cause },
);
if (reason === "path")
return new AnalysisInputError(operation, { cause }, [
{ path: ["path"], reason: "invalid_format", message },
]);
if (reason === "integrity")
return new AnalysisArtifactChangedError(operation, input.path, message, {
cause,
});
const code = errorCode(cause);
if (code === "ENOENT" || code === "ENOTDIR")
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected PE image could not be read (${code}): ${input.path}.`,
},
]);
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(operation, input.path, code, {
cause,
});
return new ArtifactOperationError(
operation,
reason,
undefined,
cause instanceof Error ? cause.message : "PE artifact inspection failed.",
{ cause },
);
};
+7 -33
View File
@@ -1,53 +1,27 @@
import type { FileHandle } from "node:fs/promises";
/** Read through EOF, returning undefined on overflow; leave the handle open. */
/** Read through EOF, returning undefined on overflow; consume at most maxBytes + 1 bytes without closing the handle. */
export const readBoundedFileBytes = async (
handle: FileHandle,
maxBytes: number,
signal?: AbortSignal,
expectedSize?: number,
): Promise<Buffer | undefined> => {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
throw new RangeError("File byte limit must be a nonnegative safe integer");
if (
expectedSize !== undefined &&
(!Number.isSafeInteger(expectedSize) ||
expectedSize < 0 ||
expectedSize > maxBytes)
)
throw new RangeError("Expected file size must fit the byte limit");
if (expectedSize === undefined) {
const chunks: Buffer[] = [];
const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1));
let total = 0;
for (;;) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(
buffer,
0,
Math.min(buffer.length, maxBytes - total + 1),
null,
);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) return undefined;
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
}
}
const buffer = Buffer.allocUnsafe(expectedSize);
if (expectedSize === 0) return buffer;
const chunks: Buffer[] = [];
const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1));
let total = 0;
for (;;) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(
buffer,
total,
expectedSize - total,
0,
Math.min(buffer.length, maxBytes - total + 1),
null,
);
if (bytesRead === 0) return buffer.subarray(0, total);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) return undefined;
if (total === expectedSize) return buffer;
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
}
};
@@ -368,30 +368,6 @@ it.each([
{ code: true, docs: true, evm: true, package: false },
],
["scripts/unknown-website-helper.mjs", { code: true, package: true }],
[
".github/workflows/release.yml",
{ workflows: true, code: true, package: false, website: false },
],
[
".github/workflows/real-ghidra-windows.yml",
{ workflows: true, code: true, package: false, website: false },
],
[
"tests/conformance/c/fixture.c",
{ code: true, fixtures: true, readiness: true, package: false },
],
[
"tests/conformance/swift/fixture.swift",
{ code: true, fixtures: true, readiness: true, package: false },
],
[
"tests/conformance/readiness/javascript-cli/package.json",
{ code: true, fixtures: false, readiness: true, package: false },
],
[
"tests/conformance/ghidra/no-return.c",
{ code: true, fixtures: false, readiness: false, package: false },
],
])("preserves scope boundaries for %s", async (path, expected) => {
const { directory, base } = await fixture();
const head = await change(directory, [path]);
+3 -18
View File
@@ -6,32 +6,17 @@ import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("PE resource stable filesystem boundary", () => {
it("rejects symlinks, missing files and directories as caller input without inventing empty inventory", async () => {
it("rejects symlinks, missing files and directories without inventing empty inventory", async () => {
const root = await createTestTempDirectory("rea-pe-resource-files-");
const path = join(root, "image.exe");
const alias = join(root, "alias.exe");
await writeFile(path, peResourceFixture().bytes);
await symlink(path, alias);
const service = createPeResourcesService();
for (const [candidate, reason] of [
[root, "invalid_format"],
[alias, "invalid_format"],
[join(root, "missing.exe"), "invalid_value"],
[join(path, "child.exe"), "invalid_value"],
] as const) {
for (const candidate of [root, alias, join(root, "missing.exe")]) {
const result = await service.inspect({ path: candidate });
if (result.ok) throw new Error("Expected file acquisition failure");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
operation: "inspect_pe_resources",
issues: [
{
path: ["path"],
reason,
message: expect.stringContaining(candidate),
},
],
});
expect(result.error._tag).toBe("ArtifactOperationError");
}
});
@@ -146,7 +146,7 @@ afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
describe("GhidraClient captured source snapshot", () => {
describe("GhidraClient", () => {
it("exposes the immutable import snapshot only during the live session", async () => {
const client = clientFor(new FixtureLauncher());
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
@@ -154,43 +154,16 @@ describe("GhidraClient captured source snapshot", () => {
error: { kind: "protocol" },
});
await expect(client.start()).resolves.toMatchObject({ ok: true });
const abortedRead = new AbortController();
abortedRead.abort();
await expect(
client.readTargetSnapshot(1024, abortedRead.signal),
).resolves.toMatchObject({ ok: false, error: { kind: "cancelled" } });
const snapshot = await client.readTargetSnapshot();
expect(snapshot.ok).toBe(true);
const expected = readFileSync(fixturePath);
if (snapshot.ok)
expect(snapshot.value).toEqual({
kind: "captured",
bytes: expected,
});
await expect(
client.readTargetSnapshot(expected.length),
).resolves.toMatchObject({
ok: true,
value: { kind: "captured", bytes: expected },
});
const bounded = await client.readTargetSnapshot(0);
expect(bounded).toMatchObject({
ok: true,
value: {
kind: "over-capacity",
maximumBytes: 0,
sourceBytesAtLeast: readFileSync(fixturePath).length,
},
});
if (snapshot.ok) expect(snapshot.value).toEqual(readFileSync(fixturePath));
await client.close();
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
});
});
describe("GhidraClient", () => {
it("completes an exact, fragmented post-analysis handshake", async () => {
const launcher = new FixtureLauncher("fragmented");
const client = clientFor(launcher);
-91
View File
@@ -1,91 +0,0 @@
import { createHash } from "node:crypto";
const IMAGE_BASE = 0x140000000n;
const address = (rva: number): bigint => IMAGE_BASE + BigInt(rva);
export const nativeAotPeDigest = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
export const nativeAotPeFixture = (major = 9, minor = 1): Buffer => {
const bytes = Buffer.alloc(0xa00);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(0x80, 0x3c);
bytes.write("PE\0\0", 0x80, "ascii");
bytes.writeUInt16LE(0x8664, 0x84);
bytes.writeUInt16LE(2, 0x86);
bytes.writeUInt16LE(0xf0, 0x94);
const optional = 0x98;
bytes.writeUInt16LE(0x20b, optional);
bytes.writeBigUInt64LE(IMAGE_BASE, optional + 24);
bytes.writeUInt32LE(0x3000, optional + 56);
bytes.writeUInt32LE(0x400, optional + 60);
const sectionTable = optional + 0xf0;
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(0x200, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x400, sectionTable + 20);
bytes.writeUInt32LE(0x60000020, sectionTable + 36);
const data = sectionTable + 40;
bytes.write(".rdata", data, "ascii");
bytes.writeUInt32LE(0x400, data + 8);
bytes.writeUInt32LE(0x2000, data + 12);
bytes.writeUInt32LE(0x400, data + 16);
bytes.writeUInt32LE(0x600, data + 20);
bytes.writeUInt32LE(0x40000040, data + 36);
bytes.writeUInt8(0xc3, 0x410);
bytes.writeUInt8(0xc3, 0x420);
bytes.writeUInt8(0xc3, 0x430);
const header = 0x600;
bytes.writeUInt32LE(0x00525452, header);
bytes.writeUInt16LE(major, header + 4);
bytes.writeUInt16LE(minor, header + 6);
bytes.writeUInt16LE(2, header + 12);
bytes.writeUInt8(24, header + 14);
bytes.writeUInt8(1, header + 15);
bytes.writeUInt32LE(207, header + 16);
bytes.writeUInt32LE(1, header + 20);
bytes.writeBigUInt64LE(address(0x2100), header + 24);
bytes.writeBigUInt64LE(address(0x2105), header + 32);
bytes.writeUInt32LE(206, header + 40);
bytes.writeBigUInt64LE(address(0x2300), header + 48);
bytes.writeBigUInt64LE(address(0x2380), header + 56);
bytes.writeInt32LE(0x280, 0x700);
bytes.writeUInt8(0x09, 0x704);
const writeMethodTable = (
rva: number,
flags: number,
baseSize: number,
related: bigint,
slots: readonly bigint[],
) => {
const offset = 0x600 + rva - 0x2000;
bytes.writeUInt32LE(flags, offset);
bytes.writeUInt32LE(baseSize, offset + 4);
bytes.writeBigUInt64LE(related, offset + 8);
bytes.writeUInt16LE(slots.length, offset + 16);
bytes.writeUInt16LE(0, offset + 18);
bytes.writeUInt32LE(0x1234, offset + 20);
slots.forEach((slot, index) =>
bytes.writeBigUInt64LE(slot, offset + 24 + index * 8),
);
};
const object = address(0x2200);
writeMethodTable(0x2200, 0x50000000, 0x18, 0n, [
address(0x1010),
address(0x1020),
address(0x1030),
]);
writeMethodTable(0x2280, 0x50000000, 0x20, object, [address(0x1040)]);
writeMethodTable(0x22c0, 0x50000000, 0x16, object, []);
const frozen = 0x600 + 0x2300 - 0x2000;
bytes.writeBigUInt64LE(address(0x22c0), frozen);
const value = Buffer.from("REA_NATIVEAOT_FROZEN", "utf16le");
bytes.writeUInt32LE(value.length / 2, frozen + 8);
value.copy(bytes, frozen + 12);
bytes.writeUInt16LE(0, frozen + 12 + value.length);
return bytes;
};
+1 -1
View File
@@ -123,7 +123,7 @@ describe its recorded investigation; update them only when repeating that work.
- `public/showcase/ctf/index.html`: DownUnderCTF's masked-squares flag checker, extracted equations and real process captures.
- `public/get-started/index.html`: agent setup, first CLI result and provider guides.
- `public/faq/index.html`: concise answers about setup, updates, analysis and troubleshooting.
- `public/guides/`: a guide hub and native, JavaScript/Electron and browser examples.
- `public/guides/`: a guide hub, a step-by-step setup with recorded setup screens for first-time users, and native, JavaScript/Electron and browser examples.
- `public/examples/`: downloadable Electron source and an interactive Notes browser app.
- `public/assets/`: shared styles, interactions, favicon and explanatory figures.
+151
View File
@@ -1443,6 +1443,32 @@ pre code {
border-radius: 3px;
color: #414b55;
}
.guide-entry .guide-needs {
margin-bottom: 0.9rem;
color: var(--ink);
font-size: 0.9rem;
}
.before-start {
margin-bottom: 2.5rem;
padding: 1.2rem 1.4rem;
border: 1px solid #ced9e5;
border-left: 3px solid var(--accent);
border-radius: 5px;
background: #fafbfc;
}
.before-start-title {
margin-bottom: 0.6rem;
font-weight: 650;
}
.before-start ul {
margin: 0;
}
.before-start li + li {
margin-top: 0.45rem;
}
.before-start .caption {
margin: 0.8rem 0 0;
}
.example-files {
display: flex;
flex-wrap: wrap;
@@ -1454,6 +1480,121 @@ pre code {
.example-files li + li {
margin: 0;
}
.step-label {
margin: 0 0 0.4rem;
color: var(--accent);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.choice-list,
.numbered-steps {
margin-block: 0 1.4rem;
}
.choice-list li + li,
.numbered-steps li + li {
margin-top: 0.55rem;
}
.numbered-steps ul {
margin-top: 0.45rem;
}
kbd {
display: inline-block;
padding: 0 0.4em;
border: 1px solid #c9d2db;
border-bottom-width: 2px;
border-radius: 4px;
background: var(--soft);
font-family: inherit;
font-size: 0.85em;
line-height: 1.5;
white-space: nowrap;
}
.word-list {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.6rem 1.2rem;
margin: 0;
}
.word-list dt {
font-weight: 600;
}
.word-list dd {
margin: 0;
color: var(--muted);
}
.screen-figure {
margin: 1.5rem 0 1.8rem;
}
.screen {
overflow: hidden;
border: 1px solid #303d4a;
border-radius: 8px;
background: #202933;
color: #f1f4f8;
}
.screen-bar {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid #384551;
color: #bdc8d4;
font-size: 0.8rem;
}
.screen-body {
padding: 1.1rem 1.2rem;
font-size: 0.8rem;
line-height: 1.6;
}
.screen mark {
padding: 0.05em 0.25em;
border-radius: 3px;
background: #3f6593;
color: #ffffff;
}
.chat-screen {
border-color: var(--line);
background: #fafbfc;
color: var(--ink);
}
.chat-screen .screen-bar {
border-color: var(--line);
color: var(--muted);
}
.chat-screen .terminal-dots i {
background: #b8c2cc;
}
.chat-screen mark {
background: #dce8f6;
color: var(--ink);
}
.chat-body {
display: grid;
gap: 0.9rem;
padding: 1.2rem;
font-size: 0.9rem;
}
.chat-message {
max-width: 85%;
margin: 0;
padding: 0.75rem 1rem;
border-radius: 10px;
}
.chat-you {
justify-self: end;
background: #e8eef5;
}
.chat-ai {
border: 1px solid var(--line);
background: var(--page);
}
.screen-figure figcaption {
margin-top: 0.7rem;
color: var(--muted);
font-size: 0.8rem;
}
.example-app {
max-width: 720px;
padding-block: 3.5rem;
@@ -2289,6 +2430,16 @@ pre code {
}
@media (max-width: 640px) {
.word-list {
grid-template-columns: minmax(0, 1fr);
gap: 0.2rem;
}
.word-list dd + dt {
margin-top: 0.6rem;
}
.chat-message {
max-width: 100%;
}
.home-definition {
grid-template-columns: 1fr;
gap: 0.8rem;
+3 -1
View File
@@ -142,7 +142,9 @@
terminal.
</p>
<p class="requirements">
You'll need Node.js 22 (22.19+), 24 (24.11+) or 26+, and npm.
You'll need Node.js 22 (22.19+), 24 (24.11+) or 26+, and npm. New to
the terminal or AI apps?
<a href="../guides/setup/">Follow the step-by-step setup</a>.
</p>
</div>
<div class="article-layout">
+67 -10
View File
@@ -157,6 +157,27 @@
</ul>
</nav>
<article class="article" aria-label="Browser observation">
<aside class="before-start" aria-labelledby="browser-before">
<p class="before-start-title" id="browser-before">
Before you start
</p>
<ul>
<li>
<strong>About 10 minutes.</strong> You paste one command to open
Chrome, then click a button.
</li>
<li>
<strong>You need</strong> REA connected to an AI app and Google
Chrome. New to this?
<a href="../setup/">Set up REA step by step</a>.
</li>
<li>
<strong>Your normal browsing stays separate.</strong> The
command opens Chrome with an empty profile, without your tabs,
logins or history.
</li>
</ul>
</aside>
<section class="article-section" id="example">
<h2>What happens when you click Export?</h2>
<p>
@@ -207,16 +228,48 @@
<h2>Open Chrome with remote debugging</h2>
<p>
Open the hosted Notes example with a separate browser profile and
a local debugging port. On Linux:
a local debugging port. Copy the command for your computer into
the terminal and press <kbd>Enter</kbd>.
</p>
<div class="code-block">
<div class="code-header">
<span>Start the example browser</span
<span>Start the example browser · Mac</span
><button
class="copy-button"
type="button"
data-copy="browser-start-mac"
aria-label="Copy start the example browser on a Mac"
>
Copy
</button>
</div>
<pre><code id="browser-start-mac">open -na "Google Chrome" --args \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/rea-notes-browser \
https://rea.tools/examples/notes-web/</code></pre>
</div>
<div class="code-block">
<div class="code-header">
<span>Start the example browser · Windows Command Prompt</span
><button
class="copy-button"
type="button"
data-copy="browser-start-windows"
aria-label="Copy start the example browser on Windows"
>
Copy
</button>
</div>
<pre><code id="browser-start-windows">start chrome --remote-debugging-port=9222 --user-data-dir="%TEMP%\rea-notes-browser" https://rea.tools/examples/notes-web/</code></pre>
</div>
<div class="code-block">
<div class="code-header">
<span>Start the example browser · Linux</span
><button
class="copy-button"
type="button"
data-copy="browser-start"
aria-label="Copy start the example browser"
aria-label="Copy start the example browser on Linux"
>
Copy
</button>
@@ -227,9 +280,8 @@
https://rea.tools/examples/notes-web/</code></pre>
</div>
<p>
Use your installed browser's executable path on macOS or Windows.
You can replace the page URL with another page you want to
inspect.
A new Chrome window opens on the Notes example. You can replace
the page URL with another page you want to inspect.
</p>
<p>
REA connects to this existing browser. You keep control of the
@@ -256,15 +308,16 @@
<p class="terminal-prompt">
<span class="prompt-symbol" aria-hidden="true">›</span
><span id="browser-prompt"
>Use REA to inspect this page while I click Export notes. What
>Use REA with the browser at http://127.0.0.1:9222 to inspect
the Notes example page while I click Export notes. What
request does it make, and how is the CSV built?</span
><span class="terminal-cursor" aria-hidden="true"></span>
</p>
</div>
<p class="caption">
Example prompt. Give your agent the debugging endpoint,
<code>http://127.0.0.1:9222</code>, and the page URL. Let it start
the capture before you click.
Example prompt. <code>http://127.0.0.1:9222</code> is the
debugging address from the command above. Keep that Chrome window
open, and let your agent start the capture before you click.
</p>
<p>
Your agent discovers the page target, starts an observation
@@ -290,6 +343,10 @@
<pre><code id="browser-targets">npx -y rea-agents@latest list-browser-targets \
http://127.0.0.1:9222 --json</code></pre>
</div>
<p class="caption">
In Windows Command Prompt, put the command on one line and leave
out the <code>\</code> characters.
</p>
<p>
Choose the target with the Notes example URL. Replace
<code>TARGET_ID</code> below with its ID, then run:
+40 -18
View File
@@ -6,7 +6,7 @@
<title>Reverse Engineering Guides — REA</title>
<meta
name="description"
content="Learn REA through short worked examples: a native calculation, an Electron CSV export and a browser request."
content="Learn REA through short worked examples: an Electron CSV export, a browser request and a native calculation."
/>
<!-- website:sharing:start -->
<!-- prettier-ignore -->
@@ -18,7 +18,7 @@
<!-- prettier-ignore -->
<meta property="og:title" content="Reverse Engineering Guides — REA" />
<!-- prettier-ignore -->
<meta property="og:description" content="Learn REA through short worked examples: a native calculation, an Electron CSV export and a browser request." />
<meta property="og:description" content="Learn REA through short worked examples: an Electron CSV export, a browser request and a native calculation." />
<!-- prettier-ignore -->
<meta property="og:url" content="https://rea.tools/guides/" />
<!-- prettier-ignore -->
@@ -39,7 +39,7 @@
<!-- prettier-ignore -->
<meta name="twitter:title" content="Reverse Engineering Guides — REA" />
<!-- prettier-ignore -->
<meta name="twitter:description" content="Learn REA through short worked examples: a native calculation, an Electron CSV export and a browser request." />
<meta name="twitter:description" content="Learn REA through short worked examples: an Electron CSV export, a browser request and a native calculation." />
<!-- prettier-ignore -->
<meta name="twitter:image" content="https://rea.tools/assets/social-card.png" />
<!-- prettier-ignore -->
@@ -138,26 +138,31 @@
<p class="breadcrumb"><a href="../">Home</a> / Guides</p>
<h1>REA guides.</h1>
<p class="lede">
Analyze a native function, an Electron export or a browser request.
Each guide includes a prompt, code and results.
Follow an Electron export, observe a browser request or analyze a
native function. Each guide includes a prompt, code and results.
</p>
</div>
<div class="guide-index">
<section class="guide-entry">
<p class="eyebrow">Native binaries</p>
<p class="eyebrow">Start here</p>
<div>
<h2>Recover a sound-pan calculation</h2>
<h2>Set up REA, step by step</h2>
<p>
Start with a Windows game executable. Inspect instructions, follow
a caller, read numeric constants and check the recovered C.
Only used AI in a web browser? Install an AI app and Node.js, run
one command and ask your first question. Each step shows what you
should see.
</p>
<div class="guide-route">
<span>Instructions</span> <b aria-hidden="true">→</b>
<span>Caller</span> <b aria-hidden="true">→</b>
<span>Constants</span> <b aria-hidden="true">→</b>
<span>C function</span>
<span>AI app</span> <b aria-hidden="true">→</b>
<span>Node.js</span> <b aria-hidden="true">→</b>
<span>REA setup</span> <b aria-hidden="true">→</b>
<span>First question</span>
</div>
<a class="text-link" href="native/">Native analysis guide</a>
<a class="text-link" href="setup/">Step-by-step setup</a>
<p>
Already use a coding agent? Follow the
<a href="../get-started/">short setup</a>.
</p>
</div>
</section>
<section class="guide-entry">
@@ -174,6 +179,9 @@
<span>Preload API</span> <b aria-hidden="true">→</b>
<span>IPC</span> <b aria-hidden="true">→</b> <span>CSV file</span>
</div>
<p class="guide-needs">
<strong>Good first guide.</strong> Needs only REA setup.
</p>
<a class="text-link" href="javascript/"
>JavaScript &amp; Electron guide</a
>
@@ -196,15 +204,29 @@
<span>Captured request</span> <b aria-hidden="true">→</b>
<span>Export script</span>
</div>
<p class="guide-needs">Needs REA setup and Google Chrome.</p>
<a class="text-link" href="browser/">Browser observation guide</a>
</div>
</section>
<section class="guide-entry">
<p class="eyebrow">First investigation</p>
<p class="eyebrow">Native binaries</p>
<div>
<h2>Set up REA</h2>
<p>Connect your coding agent or use REA from the terminal.</p>
<a class="text-link" href="../get-started/">Get started with REA</a>
<h2>Recover a sound-pan calculation</h2>
<p>
Start with a Windows game executable. Inspect instructions, follow
a caller, read numeric constants and check the recovered C.
</p>
<div class="guide-route">
<span>Instructions</span> <b aria-hidden="true">→</b>
<span>Caller</span> <b aria-hidden="true">→</b>
<span>Constants</span> <b aria-hidden="true">→</b>
<span>C function</span>
</div>
<p class="guide-needs">
<strong>Advanced.</strong> Needs REA setup and Ghidra, Hopper or
IDA.
</p>
<a class="text-link" href="native/">Native analysis guide</a>
</div>
</section>
</div>
+30 -2
View File
@@ -157,6 +157,29 @@
</ul>
</nav>
<article class="article" aria-label="JavaScript &amp; Electron">
<aside class="before-start" aria-labelledby="javascript-before">
<p class="before-start-title" id="javascript-before">
Before you start
</p>
<ul>
<li>
<strong>Good first guide.</strong> About 10 minutes, no coding
experience needed.
</li>
<li>
<strong>You need</strong> REA connected to an AI app. New to
this? <a href="../setup/">Set up REA step by step</a>.
</li>
<li>
<strong>You don't need</strong> Electron or the app running. REA
reads the app's files.
</li>
</ul>
<p class="caption">
An Electron app is a desktop app built with web technology, like
VS Code, Discord or Notion.
</p>
</aside>
<section class="article-section" id="example">
<h2>Where does “Export notes” go?</h2>
<p>
@@ -236,8 +259,9 @@
</p>
</div>
<p class="caption">
Example prompt. Include the absolute path to the application
folder or <code>app.asar</code>.
Example prompt. Add the full path to the application folder or
<code>app.asar</code> at the end;
<a href="../setup/#ask">here's how to copy a path</a>.
</p>
<p>
REA reads the application files and builds a map of entrypoints,
@@ -371,6 +395,10 @@
<pre><code id="javascript-command">npx -y rea-agents@latest analyze-javascript-application \
/absolute/path/to/notes-example --json</code></pre>
</div>
<p class="caption">
In Windows Command Prompt, put the command on one line and leave
out the <code>\</code> characters.
</p>
<p>
Replace the path with the folder you unzipped. You should find one
IPC channel and one matching handler in
+33
View File
@@ -156,6 +156,35 @@
</ul>
</nav>
<article class="article" aria-label="Native analysis">
<aside class="before-start" aria-labelledby="native-before">
<p class="before-start-title" id="native-before">
Before you start
</p>
<ul>
<li>
<strong>Advanced guide.</strong> New to REA? Start with the
<a href="../javascript/">JavaScript guide</a>; it needs no extra
tools.
</li>
<li>
<strong>You need</strong> REA connected to an AI app (<a
href="../setup/"
>step-by-step setup</a
>) and a native analysis tool: Ghidra, Hopper or IDA, connected
as in
<a href="../../get-started/#native">Set up native analysis</a>.
</li>
<li>
<strong>To follow along,</strong> use your own copy of the
Windows game DX-Ball 1.07, or any other program you want to
study.
</li>
</ul>
<p class="caption">
A native program is compiled to machine code, such as a Windows
<code>.exe</code> file. Its original source code isn't included.
</p>
</aside>
<section class="article-section" id="example">
<h2>Calculate left/right sound panning</h2>
<p>
@@ -343,6 +372,10 @@ return (DxBallInt)pan;</code></pre>
<pre><code id="native-command">npx -y rea-agents@latest function /absolute/path/to/program main \
--provider ghidra --json</code></pre>
</div>
<p class="caption">
In Windows Command Prompt, put the command on one line and leave
out the <code>\</code> characters.
</p>
<p>
Replace the target path and <code>main</code> with your own
function name or address. Each CLI invocation imports and analyzes
+779
View File
@@ -0,0 +1,779 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Set Up REA Step by Step — REA</title>
<meta
name="description"
content="Set up REA with pictures, starting from a web-browser AI chat: install an AI app and Node.js, run one command and ask your first question."
/>
<!-- website:sharing:start -->
<!-- prettier-ignore -->
<link rel="canonical" href="https://rea.tools/guides/setup/" />
<!-- prettier-ignore -->
<meta property="og:type" content="website" />
<!-- prettier-ignore -->
<meta property="og:site_name" content="REA" />
<!-- prettier-ignore -->
<meta property="og:title" content="Set Up REA Step by Step — REA" />
<!-- prettier-ignore -->
<meta property="og:description" content="Set up REA with pictures, starting from a web-browser AI chat: install an AI app and Node.js, run one command and ask your first question." />
<!-- prettier-ignore -->
<meta property="og:url" content="https://rea.tools/guides/setup/" />
<!-- prettier-ignore -->
<meta property="og:image" content="https://rea.tools/assets/social-card.png" />
<!-- prettier-ignore -->
<meta property="og:image:type" content="image/png" />
<!-- prettier-ignore -->
<meta property="og:image:width" content="1200" />
<!-- prettier-ignore -->
<meta property="og:image:height" content="630" />
<!-- prettier-ignore -->
<meta
property="og:image:alt"
content="REA — Find out how software works. Reverse engineering with your coding agent."
/>
<!-- prettier-ignore -->
<meta name="twitter:card" content="summary_large_image" />
<!-- prettier-ignore -->
<meta name="twitter:title" content="Set Up REA Step by Step — REA" />
<!-- prettier-ignore -->
<meta name="twitter:description" content="Set up REA with pictures, starting from a web-browser AI chat: install an AI app and Node.js, run one command and ask your first question." />
<!-- prettier-ignore -->
<meta name="twitter:image" content="https://rea.tools/assets/social-card.png" />
<!-- prettier-ignore -->
<meta
name="twitter:image:alt"
content="REA — Find out how software works. Reverse engineering with your coding agent."
/>
<!-- website:sharing:end -->
<meta name="theme-color" content="#ffffff" />
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="../../assets/styles.css" />
<script src="../../assets/main.js" defer></script>
</head>
<body id="top">
<a class="skip-link" href="#main">Skip to content</a>
<!-- website:header:start -->
<!-- prettier-ignore -->
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="../../" aria-label="REA home"
><span class="brand-name">REA</span
><span class="brand-caption">Reverse Engineer Anything</span></a
>
<nav class="navigation" aria-label="Main navigation">
<a href="../../showcase/">Showcases</a>
<a href="../../blog/">Blog</a>
<a href="../../get-started/" class="nav-start">Get started</a>
<a href="../../guides/" aria-current="page">Guides</a>
<a href="../../faq/">FAQ</a>
</nav>
<div class="header-social">
<a
class="nav-github"
href="https://github.com/morluto/rea"
aria-label="REA on GitHub"
title="REA on GitHub"
>
<svg
width="22"
height="22"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.65 7.65 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
/>
</svg>
<span class="nav-github-count" hidden aria-hidden="true"></span>
</a>
<a
class="nav-discord"
href="https://discord.gg/GkcryMnJDM"
aria-label="Join REA on Discord"
title="Join REA on Discord"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
/>
</svg>
</a>
<a
class="nav-x"
href="https://x.com/reatools"
aria-label="Follow REA on X"
title="Follow REA on X"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/>
</svg>
</a>
</div>
</div>
</header>
<!-- website:header:end -->
<main id="main" class="container">
<div class="page-heading">
<p class="breadcrumb">
<a href="../../">Home</a> / <a href="../">Guides</a> / Setup, step by
step
</p>
<h1>Set up REA,<br />one step at a time.</h1>
<p class="lede">
Only used AI in a web browser? Start here. Each step says what to
click, what to type and what you should see.
</p>
<p class="page-meta">
About 15 minutes · Mac, Windows or Linux · No coding needed
</p>
</div>
<div class="article-layout">
<nav class="contents" aria-label="On this page">
<p>Steps</p>
<ul>
<li><a href="#pieces">How the pieces fit</a></li>
<li><a href="#app">1. Get an AI app</a></li>
<li><a href="#node">2. Install Node.js</a></li>
<li><a href="#terminal">3. Open the terminal</a></li>
<li><a href="#run">4. Run REA setup</a></li>
<li><a href="#choose">5. Choose your AI app</a></li>
<li><a href="#approve">6. Approve the changes</a></li>
<li><a href="#restart">7. Restart your AI app</a></li>
<li><a href="#ask">8. Ask your first question</a></li>
<li><a href="#trouble">If something goes wrong</a></li>
</ul>
</nav>
<article class="article" aria-label="Step-by-step REA setup">
<section class="article-section" id="pieces">
<h2>How the pieces fit</h2>
<p>
The AI chat in your web browser can't open programs or files on
your computer. A desktop AI app can, once a tool such as REA is
connected to it. REA is the part that looks inside a program and
reports what it finds.
</p>
<figure class="workflow-figure">
<p class="figure-title">Who does what</p>
<ol class="flow-steps">
<li>
<span class="flow-label">You</span
><span class="flow-node">Ask a question in plain words</span
><span class="flow-note"
>“What happens when I click Export in this app?”</span
>
</li>
<li>
<span class="flow-label">Your AI app</span
><span class="flow-node">Decides what to look at</span
><span class="flow-note"
>Claude, ChatGPT's Codex or another agent on your
computer.</span
>
</li>
<li>
<span class="flow-label">REA</span
><span class="flow-node">Reads the program's code</span
><span class="flow-note"
>Returns the code and where it was found, so the AI can
explain it.</span
>
</li>
</ol>
<figcaption>
You need two things on your computer: an AI app and REA. The
steps below install both and connect them.
</figcaption>
</figure>
<details class="evidence-details">
<summary>Words you'll see on this page</summary>
<dl class="word-list">
<dt>Terminal</dt>
<dd>
A window where you type instructions instead of clicking.
Every computer has one.
</dd>
<dt>Command</dt>
<dd>
One line of text you paste into the terminal and run by
pressing Enter.
</dd>
<dt>Path</dt>
<dd>
The address of a file or folder, such as
<code>/Users/sam/Downloads/notes-example</code>. Step 8 shows
how to copy one.
</dd>
<dt>MCP</dt>
<dd>
The standard way AI apps connect to tools like REA. Setup
configures it for you; you never edit it by hand.
</dd>
</dl>
</details>
</section>
<section class="article-section" id="app">
<p class="step-label">Step 1</p>
<h2>Get an AI app for your computer</h2>
<p>Pick the one that matches the AI you already use:</p>
<ul class="choice-list">
<li>
<strong>You use Claude in the browser.</strong> Install
<a href="https://claude.ai/download">Claude Desktop</a> and sign
in with the same account.
</li>
<li>
<strong>You use ChatGPT.</strong> Install
<a href="https://developers.openai.com/codex">Codex</a>,
OpenAI's coding agent, and sign in with your ChatGPT account.
</li>
<li>
<strong>You already use a coding agent</strong> such as Claude
Code, Cursor or Gemini CLI. Keep it and go to step 2.
</li>
</ul>
<p>
Open the app once to finish signing in, then leave it. This guide
shows Claude Desktop; the other apps follow the same steps.
</p>
</section>
<section class="article-section" id="node">
<p class="step-label">Step 2</p>
<h2>Install Node.js</h2>
<p>
REA runs on Node.js, a free program that many developer tools use.
REA doesn't install it for you, so do this once:
</p>
<ol class="numbered-steps">
<li>
Open the
<a href="https://nodejs.org/en/download"
>Node.js download page</a
>.
</li>
<li>
Choose the <strong>LTS</strong> version (“long-term support”)
and the <strong>installer</strong> for your computer: a
<code>.pkg</code> file on a Mac or an <code>.msi</code> file on
Windows.
</li>
<li>
Open the downloaded file and click <strong>Continue</strong> or
<strong>Next</strong> until it finishes. Keep the default
options.
</li>
</ol>
<p class="caption">
On Linux, use the instructions for your distribution on the same
page. REA works with Node.js 22 (22.19 or newer), 24 (24.11 or
newer) and 26 or newer.
</p>
</section>
<section class="article-section" id="terminal">
<p class="step-label">Step 3</p>
<h2>Open the terminal</h2>
<ul class="choice-list">
<li>
<strong>Mac:</strong> press <kbd>⌘ Command</kbd> +
<kbd>Space</kbd>, type <code>Terminal</code> and press
<kbd>Return</kbd>.
</li>
<li>
<strong>Windows:</strong> press the <kbd>Windows</kbd> key, type
<code>cmd</code> and press <kbd>Enter</kbd> to open
<strong>Command Prompt</strong>. Use Command Prompt rather than
PowerShell for these steps.
</li>
<li>
<strong>Linux:</strong> press <kbd>Ctrl</kbd> + <kbd>Alt</kbd> +
<kbd>T</kbd>, or open Terminal from your apps.
</li>
</ul>
<p>
Check that Node.js is installed. Type this line, then press
<kbd>Enter</kbd>:
</p>
<div class="code-block code-inline">
<code id="setup-node-version">node -v</code>
<button
class="copy-button"
type="button"
data-copy="setup-node-version"
aria-label="Copy Node.js version check"
>
Copy
</button>
</div>
<figure class="screen-figure">
<div class="screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Terminal
</div>
<pre class="screen-body"><code>$ node -v
<mark>v24.11.1</mark></code></pre>
</div>
<figcaption>
Example result. A number starting with <code>v22</code>,
<code>v24</code> or <code>v26</code> means you're ready. The
<code>$</code> stands for the text your terminal shows before
the cursor; it differs on every computer.
</figcaption>
</figure>
<p>
If the terminal says <code>command not found</code> or
<code>is not recognized</code>, close the terminal, open a new one
and try again. A terminal opened before the installation can't see
Node.js yet.
</p>
</section>
<section class="article-section" id="run">
<p class="step-label">Step 4</p>
<h2>Run REA setup</h2>
<p>
Click <strong>Copy</strong>, paste the command into the terminal
and press <kbd>Enter</kbd>:
</p>
<div class="code-block code-inline">
<code id="setup-command">npx rea-agents@latest setup</code>
<button
class="copy-button"
type="button"
data-copy="setup-command"
aria-label="Copy REA setup command"
>
Copy
</button>
</div>
<p class="caption">
To paste: <kbd>⌘ Command</kbd> + <kbd>V</kbd> on a Mac,
<kbd>Ctrl</kbd> + <kbd>V</kbd> in Windows Command Prompt, or
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd> in most Linux
terminals.
</p>
<p>
The first time, npm asks before downloading REA. Type
<kbd>y</kbd> and press <kbd>Enter</kbd>.
</p>
<figure class="screen-figure">
<div class="screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Terminal
</div>
<pre class="screen-body"><code>$ npx rea-agents@latest setup
Need to install the following packages:
rea-agents@6.3.0
Ok to proceed? (y) <mark>y</mark></code></pre>
</div>
<figcaption>
The version number will be newer when REA releases an update.
This only downloads REA; nothing on your computer changes until
step 6.
</figcaption>
</figure>
</section>
<section class="article-section" id="choose">
<p class="step-label">Step 5</p>
<h2>Choose your AI app</h2>
<p>Setup shows a list of AI apps. Use the keyboard:</p>
<ol class="numbered-steps">
<li>
Press <kbd>↓</kbd> to move to your app, for example
<strong>Claude Desktop</strong>.
</li>
<li>
Press <kbd>Space</kbd> to tick it. The empty box
<code>◻</code> turns into a filled box <code>◼</code>.
</li>
<li>Press <kbd>Enter</kbd> to continue.</li>
</ol>
<figure class="screen-figure">
<div class="screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Terminal
</div>
<pre class="screen-body"><code>┌ REA setup
│ Understand local apps and binaries from your terminal or agent.
│
◆ No agents detected. Choose any supported agent below.
│
│ Keys: ↑/↓ navigate · Space toggle · Enter confirm · Ctrl-C cancel
│
◆ Which agents should use REA?
│ ◻ Claude Code
│ <mark>◼ Claude Desktop</mark> (Not detected · configuration is still available)
│ ◻ Codex
│ ◻ Cursor
│ ◻ Gemini CLI
│ …
│ ↑/↓ to navigate • Space: select • Enter: confirm
└</code></pre>
</div>
<figcaption>
Recorded from REA 6.3.0 with Claude Desktop ticked; the full
list is longer. “Not detected” is fine: setup can still connect
an app it didn't find.
</figcaption>
</figure>
<p>
On a Mac or Linux, setup may also ask
<strong>“Install Hopper for deep binary analysis?”</strong> Press
<kbd>Enter</kbd> to keep <strong>No</strong>. You only need Hopper
for compiled programs, and you can add it later from the
<a href="../../get-started/#native">native analysis setup</a>.
</p>
</section>
<section class="article-section" id="approve">
<p class="step-label">Step 6</p>
<h2>Approve the changes</h2>
<p>
Before it changes anything, setup lists exactly what it will do.
</p>
<figure class="screen-figure">
<div class="screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Terminal
</div>
<pre class="screen-body"><code>◆ Ready to review
│
│ <mark>CREATE Claude Desktop</mark>
│ ~/Library/Application Support/Claude/claude_desktop_config.json
│ Add the REA MCP registration for Claude Desktop;
│ preserve unrelated configuration.
│
│ <mark>INSTALL REA reverse-engineering skill</mark>
│ ~/.agents/skills/reverse-engineer-anything
│ Install or update the bundled REA reverse-engineering
│ skill and on-demand references.
│
◆ Apply these 2 changes?
│ <mark>● Yes, apply</mark> / ○ No, cancel
└</code></pre>
</div>
<figcaption>
Shortened from a REA 6.3.0 recording on a Mac: some detail lines
are left out, long lines are wrapped and your home folder is
shown as <code>~</code>.
</figcaption>
</figure>
<ul class="choice-list">
<li>
<strong>CREATE</strong> adds REA to your AI app's settings file.
Your other settings stay as they are; if the file already
exists, setup keeps a backup.
</li>
<li>
<strong>INSTALL</strong> adds a short instruction set that
teaches coding agents how to use REA.
</li>
</ul>
<p>
<strong>Yes, apply</strong> is already selected. Press
<kbd>Enter</kbd>. To stop instead, press <kbd>Ctrl</kbd> +
<kbd>C</kbd>; nothing is changed.
</p>
</section>
<section class="article-section" id="restart">
<p class="step-label">Step 7</p>
<h2>Restart your AI app</h2>
<p>When setup finishes, it tells you what to restart:</p>
<figure class="screen-figure">
<div class="screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Terminal
</div>
<pre class="screen-body"><code>◆ What you can do now
│ Agent access: Claude Desktop
│ Guided reverse-engineering workflows: installed
│ CLI: npx rea-agents capabilities
│ Try in Claude Desktop: "Explain how a feature works in
│ /path/to/app and show the evidence."
│
└ <mark>Restart Claude Desktop to load REA.</mark></code></pre>
</div>
<figcaption>Recorded from REA 6.3.0 on a Mac.</figcaption>
</figure>
<p>
Quit the app completely, then open it again. Closing its window
isn't enough:
</p>
<ul class="choice-list">
<li>
<strong>Mac:</strong> click the app, then press
<kbd>⌘ Command</kbd> + <kbd>Q</kbd>.
</li>
<li>
<strong>Windows:</strong> choose <strong>Quit</strong> or
<strong>Exit</strong> from the app's menu, or right-click its
icon next to the clock and choose <strong>Quit</strong>.
</li>
<li>
<strong>A terminal agent</strong> such as Claude Code or Codex:
exit it and start it again.
</li>
</ul>
<p>You can close the terminal now.</p>
</section>
<section class="article-section" id="ask">
<p class="step-label">Step 8</p>
<h2>Ask your first question</h2>
<p>
Try REA on a small example app with an
<strong>Export notes</strong> button. You don't need to run it;
REA reads its code.
</p>
<ol class="numbered-steps">
<li>
<a
href="../../examples/notes-example.zip"
download="notes-example.zip"
>Download the example ZIP</a
>
and unzip it: double-click it on a Mac, or right-click it on
Windows and choose <strong>Extract All</strong>.
</li>
<li>
Copy the prompt below and paste it into your AI app. Don't send
it yet.
</li>
<li>
Copy the path of the <code>notes-example</code> folder:
<ul>
<li>
<strong>Mac:</strong> right-click the folder, hold
<kbd>⌥ Option</kbd> and choose
<strong>Copy “notes-example” as Pathname</strong>.
</li>
<li>
<strong>Windows:</strong> right-click the folder and choose
<strong>Copy as path</strong>. On Windows 10, hold
<kbd>Shift</kbd> while you right-click.
</li>
</ul>
</li>
<li>
Paste the path at the end of your message and send it. If your
app asks whether it may use a REA tool, allow it.
</li>
</ol>
<div class="agent-terminal">
<div class="terminal-header">
<span class="terminal-title"
><span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Your AI app</span
>
<button
class="copy-button"
type="button"
data-copy="setup-first-prompt"
aria-label="Copy first question"
>
Copy
</button>
</div>
<p class="terminal-prompt">
<span class="prompt-symbol" aria-hidden="true">›</span
><span id="setup-first-prompt"
>Use REA to look at the small app in the folder below. What
happens when I click Export notes, and where is notes.csv
saved? Show the files and lines. Folder:</span
><span class="terminal-cursor" aria-hidden="true"></span>
</p>
</div>
<figure class="screen-figure">
<div class="screen chat-screen">
<div class="screen-bar">
<span class="terminal-dots" aria-hidden="true"
><i></i><i></i><i></i></span
>Your AI app
</div>
<div class="chat-body">
<p class="chat-message chat-you">
Use REA to look at the small app in the folder below. What
happens when I click Export notes, and where is notes.csv
saved? Show the files and lines. Folder:
<mark>/Users/sam/Downloads/notes-example</mark>
</p>
<p class="chat-message chat-ai">
The button calls <code>notes.exportCsv()</code>, which sends
a <code>notes:export</code> message to the main process.
There, <code>main.js</code> (lines 8–12) formats the notes
as CSV and writes <code>notes.csv</code> to your Downloads
folder…
</p>
</div>
</div>
<figcaption>
Illustration with an example path. Your path and the AI's
wording will differ; look for the
<code>notes:export</code> message, <code>main.js</code> and the
Downloads folder.
</figcaption>
</figure>
<p>
<strong>You're set up.</strong> To understand that answer and
check one detail yourself, continue with
<a href="../../first-investigation/#answer"
>your first investigation</a
>. To try your own programs, pick a guide:
</p>
<ul class="reading-list">
<li>
<a href="../javascript/">Trace an Electron CSV export</a
><span
>Desktop apps built with web technology. No extra tools
needed.</span
>
</li>
<li>
<a href="../browser/">See what happens when you click</a
><span
>Websites, using Chrome or another Chromium browser.</span
>
</li>
<li>
<a href="../native/">Recover a calculation from a game</a
><span
>Compiled programs such as <code>.exe</code> files. Needs
Ghidra, Hopper or IDA.</span
>
</li>
</ul>
</section>
<section class="article-section" id="trouble">
<h2>If something goes wrong</h2>
<details class="evidence-details">
<summary>
“command not found: npx” or “'npx' is not recognized”
</summary>
<p>
The terminal can't find Node.js. Close every terminal window,
open a new one and run <code>node -v</code>. If it still fails,
install Node.js again from <a href="#node">step 2</a>.
</p>
</details>
<details class="evidence-details">
<summary>“running scripts is disabled on this system”</summary>
<p>
You're in Windows PowerShell, which blocks <code>npx</code> on
many computers. Open <strong>Command Prompt</strong> instead (<a
href="#terminal"
>step 3</a
>) and paste the command there.
</p>
</details>
<details class="evidence-details">
<summary>Setup says my Node.js version isn't supported</summary>
<p>
Install the current LTS version from
<a href="#node">step 2</a>, open a new terminal and run setup
again.
</p>
</details>
<details class="evidence-details">
<summary>My AI app doesn't use REA</summary>
<p>
Quit the app completely and open it again (<a href="#restart"
>step 7</a
>). If it still doesn't, check the connection from the terminal.
For Claude Desktop:
</p>
<div class="code-block code-inline">
<code id="setup-doctor"
>npx rea-agents@latest doctor --client claude_desktop</code
>
<button
class="copy-button"
type="button"
data-copy="setup-doctor"
aria-label="Copy connection check"
>
Copy
</button>
</div>
<p>
The first line should say <code>healthy: true</code>. Lines
about Hopper, Ghidra and IDA only matter for compiled programs.
For another app, replace <code>claude_desktop</code> with
<code>codex</code>, <code>claude_code</code>,
<code>cursor</code> or <code>gemini_cli</code>.
</p>
</details>
<details class="evidence-details">
<summary>I chose the wrong app, or want to add another</summary>
<p>
Run the setup command from <a href="#run">step 4</a> again. Apps
you already connected are ticked; tick the one you want and
press <kbd>Enter</kbd>.
</p>
</details>
<p>
Still stuck? Read the <a href="../../faq/">FAQ</a>, ask in the
<a href="https://discord.gg/GkcryMnJDM">REA community</a> or
<a href="https://github.com/morluto/rea/issues"
>report a problem</a
>. Include what you typed and what the terminal printed.
</p>
</section>
</article>
</div>
</main>
<!-- website:footer:start -->
<!-- prettier-ignore -->
<footer class="site-footer">
<div class="container footer-inner">
<p>REA · Reverse Engineer Anything</p>
<div class="footer-links">
<a href="https://github.com/morluto/rea">Source</a
><a href="https://github.com/morluto/rea/blob/main/LICENSE">MIT license</a
><a href="https://discord.gg/GkcryMnJDM">Join the community</a
><a href="https://x.com/reatools">Follow us on X</a>
</div>
</div>
</footer>
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
<a class="back-to-top" href="#top" aria-label="Back to top">
<span aria-hidden="true">↑</span> Top
</a>
<!-- website:footer:end -->
</body>
</html>