Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c048b853f8 | ||
|
|
0ac6832aa6 |
+35
-64
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
-91
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -157,6 +157,29 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<article class="article" aria-label="JavaScript & 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user