Compare commits

...
Author SHA1 Message Date
morluto 9ec8267bee docs(readme): remove CI status badge 2026-10-11 19:09:11 +08:00
morluto d3a39a9376 fix(javascript): preserve source identity in multi-input recovery 2026-10-11 18:29:19 +08:00
JingHao-Leon 5ddf8766b8 feat(javascript): recover code-split bundles from multiple inputs
recover_javascript_sources accepted a single path, and
validateModuleSet bound every module's provenance to that one snapshot.
A code-split app (runtime + chunks, or an Electron main/preload/renderer
set) had to be recovered one file per call, and modules recovered alone
could not resolve imports across chunks: cross-chunk require(id)
references stayed as numeric ids with property reads.

Add an optional additional_paths input. The listed files are
concatenated, in order after path, into the single engine snapshot, so
one Wakaru run sees every module and cross-chunk imports resolve.
validateModuleSet's single-snapshot binding is unchanged. Module
provenance is attributed back to the input file its first extraction
range starts in, with byte ranges rebased to that file's own offsets;
a module whose ranges cross an input boundary keeps the concatenated
snapshot identity and offsets, recorded as an explicit limitation.
Single-path behavior is byte-for-byte unchanged (one segment,
offset 0).

Fixes #1605
2026-10-11 11:24:09 +08:00
morluto e5bc4e85ec fix(ci): close speculative browser fixture connections 2026-10-11 11:05:08 +08:00
38221cb12e fix(native): bound PE resource RVAs by section virtual size (#1678)
The PE resource reader treated each section as covering
`max(VirtualSize, SizeOfRawData)` bytes, the mapping #1596 removed from
managed RVA reads. A resource payload in the raw alignment padding past
VirtualSize was reported with a hash of bytes the image loader never maps.
A later section whose virtual range starts inside that padding was refused
as an overlapping mapping.

Containment and overlap now use the loader's virtual extent: VirtualSize,
or SizeOfRawData when VirtualSize is zero. Ranges must still be file-backed.

Refs #1596

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:01:45 -07:00
3749ccec47 feat(ghidra): recover signed AArch64 relative jump tables (#1592)
* feat(ghidra): recover signed AArch64 relative jump tables

Generalise the AArch64 relative-table matcher to signed byte/halfword entries, UXTW indexes, byte or LSL #2 offsets, B.CS/B.HS bounds and a CBZ route for zero entries. Cross-check it against Ghidra's own load table when one exists, and add a fixture modelled on the RenderScript blend kernel.

Refs #487

* fix(ghidra): preserve typed labels for normalized switch selectors

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-11 10:45:35 +08:00
a67dbf6a48 fix: reject directory paths for the Wakaru limiter and mitmdump
`REA_JAVASCRIPT_PRLIMIT_COMMAND` and `REA_MITMDUMP_COMMAND` were admitted
after an access check alone, which a searchable directory passes. Admission
then succeeded, and the failure surfaced later as a launch-time adapter
error instead of the capability refusal that names the configured path.

Require a regular file, as #751 did for the firmware engine and limiter.
Linux-only tests mirror that change; both checks only run on Linux hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
morlutoandClaude Opus 5.5 bd3a9bbbaf fix(native): classify PE resource input failures
Report missing and non-file selections as input issues, malformed PE images as format errors, and unstable reads as artifact changes. Keep existing limit, permission, cancellation, and other I/O classifications.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
morlutoandClaude Opus 5.5 74050978be fix(javascript): keep dependency manifests out of application roots
Select the shallowest inventoried nondependency manifest and retain dependency package evidence without assigning Electron entry roles. Keep the artifact root when no application manifest is observed.

Adapted from PR #1392, with compiled CLI and stdio MCP regressions for root selection and dependency-only inputs.

Co-authored-by: Marnix Pluim <marnixpluim@users.noreply.github.com>

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
b212e92f9b ci: select semantic tests and fixture lanes for their guarded inputs (#1675)
Edits to `release.yml` or `real-ghidra-windows.yml` selected only the
workflow lane, so the shards holding releaseWorkflow, releaseCheckpoint
and realGhidraWindowsWorkflow tests were skipped. Actionlint checks
syntax only, so a PR could drop the package-before-publish gate, the
registry canary or the default-branch-only self-hosted runner restriction
without CI noticing.

Conformance fixture sources under tests/ set only the code lane, so the
conformance-fixtures and reconstruction-readiness jobs, the only callers
of `verify:fixtures` and `verify:readiness`, never ran for them. Route
the directories that build-conformance-fixtures.mjs compiles to both
lanes, and readiness applications to the readiness lane.

Both were raised by the Codex review of #1660 after it merged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:41:40 -07:00
morluto 990e1ad8c1 fix(browser): clear losing storage verifier timers 2026-10-11 10:35:42 +08:00
morluto 76d8f68cb9 feat(ghidra): inspect NativeAOT PE metadata read-only 2026-10-11 10:31:49 +08:00
fe574c0c9f fix(javascript): recover Electron APIs through default imports and optional calls (#1673)
`import electron from "electron"` bound the module with a leading `default`
export segment, so `electron.ipcMain.handle(...)`, `new
electron.BrowserWindow(...)` and destructuring from that binding matched no
Electron API. Electron's CommonJS exports define no `default` member, so the
segment can only denote the module object; drop it when classifying
Electron bindings.

The binding classifier already recorded optional-call roots, but the IPC,
context-bridge, utility-process and sender-validation visitors accepted only
plain calls. `ipcMain?.handle(...)`, `contextBridge?.exposeInMainWorld(...)`
and the common `event.senderFrame?.url.startsWith(...)` check were dropped
without a limitation.

Refs #1656

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:31:08 +08:00
3a7a3074e3 feat(javascript): recognize tRPC procedures and GraphQL operations (#1670)
* feat(javascript): recognize tRPC procedures and GraphQL operations

Record RPC client syntax as network endpoint candidates in both
analyze_javascript_application and analyze_web_bundle, distinguished by
mechanism:

- trpc:procedure:<method> keeps the static member path of
  root.<path>.<method>(...), which tRPC proxies preserve through
  identifier minification. React hooks and TanStack option factories are
  recognized directly. Vanilla query/mutate need a root bound to
  createTRPCClient/createTRPCProxyClient, 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; cache-key helpers are excluded.
- trpc🔗<link> records the literal url of documented HTTP links.
- graphql:<type> records named operations from string and cooked template
  literals with a valid operation header, and from precompiled
  OperationDefinition objects; a graphql-tag loc.source.body copy is not
  reported twice.

Method, callback and factory names follow @trpc/client and React/TanStack
integration sources and documentation for 11.19. Carrier segments before
the router path, useUtils helpers and minifier-renamed links remain
documented limitations.

Validation: typecheck; domain tests for recognition and false positives
(Apollo, RxJS, stores, RTK Query, browser globals, prose); web bundle
analyzer test; end-to-end analysis of a minified fixture through the
built MCP server on Windows 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): preserve RPC binding identity and bundled calls

* style(reference-source): fix CI formatting

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-11 10:28:52 +08:00
74 changed files with 6262 additions and 496 deletions
@@ -1,5 +1,13 @@
# JavaScript and Electron application artifacts
For captured or bundled JavaScript that needs readable source recovery, use
`recover_javascript_sources` with an absolute `path` and absent
`output_directory`. Supply related runtime/shared/lazy files in
`additional_paths` when advertised, then analyze the returned `analysis_input`
directory together. Each input retains its own snapshot, digest and byte-range
provenance; inputs and recovered code are never executed. Recovery requires a
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
extracted tree. When size is unknown or large and the connected schema advertises
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
-1
View File
@@ -9,7 +9,6 @@
**See a feature you like. Understand how it works, down to the binary level.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#what-you-can-analyze)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
+372 -167
View File
@@ -2004,9 +2004,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
RecoveredAArch64RelativeTable recoveredAArch64 =
loadTables.length == 0
? recoverAArch64RelativeTable(jumpTable)
: null;
recoverAArch64RelativeTable(jumpTable);
if (recoveredAArch64 != null && loadTables.length > 0) {
// A decoded table only adds evidence when Ghidra's own load table
// describes the same bytes; any disagreement keeps Ghidra's view.
boolean agrees = false;
for (JumpTable.LoadTable loadTable : loadTables) {
if (loadTable.getAddress().equals(recoveredAArch64.tableAddress) &&
loadTable.getSize() == recoveredAArch64.entrySize &&
loadTable.getNum() == recoveredAArch64.mappings.size()) {
agrees = true;
}
}
if (!agrees) recoveredAArch64 = null;
}
List<Address> referencedDataAddresses = loadTables.length == 0
? dispatchPathDataReferences(jumpTable)
: List.of();
@@ -2040,7 +2051,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
source.add("evidence", evidence);
dataSources.add(source);
}
if (recoveredAArch64 != null) {
if (recoveredAArch64 != null && loadTables.length == 0) {
JsonObject source = new JsonObject();
source.addProperty(
"address",
@@ -2055,9 +2066,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
"Decoded " + recoveredAArch64.mappings.size() +
"Decoded " + recoveredAArch64.mappings.size() + " " +
(recoveredAArch64.signedEntries ? "signed" : "unsigned") +
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
"; the AArch64 ADRP/ADD table, indexed load, ADR base, ADD" +
(recoveredAArch64.offsetShift == 0 ? "" : " LSL #" + recoveredAArch64.offsetShift) +
"/BR chain uses a selector bounded by CMP #" + recoveredAArch64.compareImmediate +
" and " + recoveredAArch64.guardMnemonic + " on one straight-line path" +
(recoveredAArch64.nullTarget == null
? "."
: "; zero entries are routed by CBZ to " + canonicalAddress(recoveredAArch64.nullTarget) + ".")
)
);
source.add("evidence", evidence);
@@ -2096,6 +2114,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("data_sources", dataSources);
Address[] targets = jumpTable.getCases();
TypedJumpTableCases typedCases = typedJumpTableCases(jumpTable, caseTokens);
// Table indices identify the register bounded by CMP. Typed labels may
// instead identify a selector before normalization (for example, SUB
// index, selector, #10). Do not merge those distinct value identities.
if (recoveredAArch64 != null && recoveredAArch64.priorSelectorDefinition != null) {
typedCases.limitations.add(
"REA decoded the relative table, but the compared selector has an earlier register definition at " +
canonicalAddress(recoveredAArch64.priorSelectorDefinition) + "; its table indices are not proven to use the typed switch selector's case values. Ghidra's typed labels are retained and additional case labels remain unknown."
);
recoveredAArch64 = null;
}
if (recoveredAArch64 != null) {
for (Map.Entry<Long, Address> typed : typedCases.cases.entrySet()) {
long value = typed.getKey();
if (value < 0 || value >= recoveredAArch64.mappings.size()) {
typedCases.limitations.add("Typed case labels and decoded table indices do not use a verified common selector; Ghidra's typed labels are retained.");
recoveredAArch64 = null;
break;
}
RecoveredAArch64Mapping decoded = recoveredAArch64.mappings.get((int) value);
boolean divertedZero = decoded.nullEntry && typed.getValue().equals(recoveredAArch64.unreachableZeroTarget);
if (!typed.getValue().equals(decoded.targetAddress) && !divertedZero) {
typedCases.limitations.add("Typed case evidence and the decoded relative table disagree on a destination; Ghidra's typed labels are retained.");
recoveredAArch64 = null;
break;
}
}
}
JsonArray mappings = new JsonArray();
JsonArray defaultTargets = new JsonArray();
for (Address target : typedCases.defaults) {
@@ -2103,27 +2148,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
Set<Address> coveredTargets = new HashSet<>(typedCases.cases.values());
coveredTargets.addAll(typedCases.defaults);
if (recoveredAArch64 != null && recoveredAArch64.unreachableZeroTarget != null) {
Address unreachable = recoveredAArch64.unreachableZeroTarget;
coveredTargets.add(unreachable);
for (RecoveredAArch64Mapping recovered : recoveredAArch64.mappings) {
if (recovered.nullEntry && unreachable.equals(typedCases.cases.get((long) recovered.tableIndex))) {
typedCases.cases.remove((long) recovered.tableIndex);
}
}
typedCases.limitations.add(
"Ghidra recovered destination " + canonicalAddress(unreachable) +
" as the zero-offset decode of the relative table, but the CBZ before the dispatch diverts zero entries to " +
canonicalAddress(recoveredAArch64.nullTarget) + ", so the indirect branch never reaches it for an in-range case."
);
}
if (recoveredAArch64 != null) {
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
monitor.checkCancelled();
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
if (
typedCases.unknownTargets.contains(recovered.targetAddress) ||
typedCases.ambiguousValues.contains((long) recovered.caseValue)
typedCases.ambiguousValues.contains((long) recovered.tableIndex)
) continue;
Address typedTarget = typedCases.cases.get((long) recovered.caseValue);
if (typedTarget != null && !typedTarget.equals(recovered.targetAddress)) {
typedCases.cases.remove((long) recovered.caseValue);
typedCases.ambiguousValues.add((long) recovered.caseValue);
typedCases.unknownTargets.add(typedTarget);
typedCases.unknownTargets.add(recovered.targetAddress);
typedCases.limitations.add(
"Typed case evidence and the verified relative table disagree on a destination; both destinations remain unknown for that case."
);
continue;
}
JsonObject mapping = new JsonObject();
mapping.addProperty("case_value", recovered.caseValue);
mapping.addProperty("case_value", recovered.tableIndex);
String targetAddress = canonicalAddress(recovered.targetAddress);
mapping.addProperty("target_address", targetAddress);
mapping.addProperty("confidence", "high");
@@ -2132,14 +2180,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
"Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
(recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the loaded unsigned entry selects a four-byte instruction offset from " +
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
"Case value " + recovered.tableIndex + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
(recovered.tableIndex * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
(recovered.nullEntry
? "; the entry is zero, so the CBZ before the dispatch branches to " + targetAddress + "."
: "; the loaded " + (recoveredAArch64.signedEntries ? "signed" : "unsigned") +
" entry " + recovered.encodedEntry + " selects a " +
(recoveredAArch64.offsetShift == 2 ? "four-byte instruction" : "byte") +
" offset from " + canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + ".")
)
);
mapping.add("evidence", evidence);
if (!typedCases.cases.containsKey((long) recovered.caseValue)) {
if (!typedCases.cases.containsKey((long) recovered.tableIndex)) {
mappings.add(mapping);
}
coveredTargets.add(recovered.targetAddress);
@@ -2213,7 +2265,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
}
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
limitations.add("REA did not verify a supported byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
limitations.add("REA did not verify a supported signed or unsigned byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
}
if (targets.length == 0) {
limitations.add(
@@ -2343,12 +2395,21 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private static final class RecoveredAArch64Mapping {
private final int caseValue;
private final int tableIndex;
private final Address targetAddress;
private final long encodedEntry;
private final boolean nullEntry;
private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
this.caseValue = caseValue;
private RecoveredAArch64Mapping(
int tableIndex,
Address targetAddress,
long encodedEntry,
boolean nullEntry
) {
this.tableIndex = tableIndex;
this.targetAddress = targetAddress;
this.encodedEntry = encodedEntry;
this.nullEntry = nullEntry;
}
}
@@ -2356,24 +2417,70 @@ public final class ReaGhidraBridge extends HeadlessScript {
private final Address tableAddress;
private final Address branchBase;
private final int entrySize;
private final Address priorSelectorDefinition;
private final boolean signedEntries;
private final int offsetShift;
private final String guardMnemonic;
private final long compareImmediate;
private final Address nullTarget;
private final Address unreachableZeroTarget;
private final List<RecoveredAArch64Mapping> mappings;
private RecoveredAArch64RelativeTable(
Address tableAddress,
Address branchBase,
int entrySize,
Address priorSelectorDefinition,
boolean signedEntries,
int offsetShift,
String guardMnemonic,
long compareImmediate,
Address nullTarget,
Address unreachableZeroTarget,
List<RecoveredAArch64Mapping> mappings
) {
this.tableAddress = tableAddress;
this.branchBase = branchBase;
this.entrySize = entrySize;
this.priorSelectorDefinition = priorSelectorDefinition;
this.signedEntries = signedEntries;
this.offsetShift = offsetShift;
this.guardMnemonic = guardMnemonic;
this.compareImmediate = compareImmediate;
this.nullTarget = nullTarget;
this.unreachableZeroTarget = unreachableZeroTarget;
this.mappings = mappings;
}
}
private static final Pattern AARCH64_DISPATCH_ADD = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*LSL\\s*#(?:0x)?([0-9a-f]+))?$"
);
private static final Pattern AARCH64_TABLE_LOAD = Pattern.compile(
"(?i)^([wx]\\d+),\\s*\\[([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*(LSL|UXTW)\\s*(?:#(?:0x)?([0-9a-f]+))?)?\\s*\\]$"
);
private static final Pattern AARCH64_REGISTER_AND_ADDRESS = Pattern.compile(
"(?i)^([wx]\\d+),\\s*(0x[0-9a-f]+)$"
);
private static final Pattern AARCH64_ADD_IMMEDIATE = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
);
private static final Pattern AARCH64_COMPARE_IMMEDIATE = Pattern.compile(
"(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
);
private static final Pattern AARCH64_MOVE_REGISTER = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+)$"
);
private static final Set<String> AARCH64_FLAG_PRESERVING =
Set.of("MOV", "ADRP", "ADR", "ADD");
/**
* Recover only the directly verified AArch64 byte/halfword-indexed relative branch
* table form. Other instruction sequences keep their case labels unknown.
* Recover only directly verified AArch64 relative branch tables:
* ADRP/ADD table base, LDRB/LDRH/LDRSB/LDRSH entry load with an exact
* index scale, ADR branch base, optional CBZ on the loaded entry, and
* ADD (byte or LSL #2 scale)/BR, with a selector bounded by CMP #imm and an
* unsigned B.HI or B.CS/B.HS guard on one straight-line path. Any other
* instruction sequence keeps its case labels unknown.
*/
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
JumpTable jumpTable
@@ -2395,167 +2502,265 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
}
int branchIndex = instructions.size() - 1;
if (branchIndex < 1 ||
!instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
if (branchIndex < 1 || !mnemonic(instructions.get(branchIndex)).equals("BR")) {
return null;
}
Set<String> recoveredTargetSet = new HashSet<>();
for (Address target : jumpTable.getCases()) recoveredTargetSet.add(canonicalAddress(target));
if (recoveredTargetSet.isEmpty()) return null;
return matchAArch64RelativeDispatch(
instructions,
branchIndex,
recoveredTargetSet,
dispatchPathDataReferences(jumpTable)
);
}
private RecoveredAArch64RelativeTable matchAArch64RelativeDispatch(
List<Instruction> instructions,
int branchIndex,
Set<String> recoveredTargetSet,
List<Address> dataAddresses
) throws Exception {
String branchRegister = normalizedRegister(operands(instructions.get(branchIndex)));
// ADD Xd, Xn, Xm{, LSL #s}; BR Xd. LSL applies to Xm only.
Instruction dispatchAdd = instructions.get(branchIndex - 1);
if (!mnemonic(dispatchAdd).equals("ADD")) return null;
java.util.regex.Matcher addMatcher = AARCH64_DISPATCH_ADD.matcher(operands(dispatchAdd));
if (!addMatcher.matches() || !addMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
if (!normalizedRegister(addMatcher.group(1)).equals(branchRegister)) return null;
int offsetShift = addMatcher.group(4) == null ? 0 : Integer.parseInt(addMatcher.group(4), 16);
if (offsetShift != 0 && offsetShift != 2) return null;
String firstSource = normalizedRegister(addMatcher.group(2));
String secondSource = normalizedRegister(addMatcher.group(3));
if (firstSource.equals(secondSource)) return null;
// The entry register is the source whose latest definition is the
// table load; the other source must be defined by ADR.
int loadIndex = -1;
String entryRegister = null;
String baseRegister = null;
for (String candidate : offsetShift == 0 ? List.of(secondSource, firstSource) : List.of(secondSource)) {
int writer = latestWriter(instructions, branchIndex - 1, candidate);
if (writer >= 0 && Set.of("LDRB", "LDRH", "LDRSB", "LDRSH").contains(mnemonic(instructions.get(writer)))) {
loadIndex = writer;
entryRegister = candidate;
baseRegister = candidate.equals(firstSource) ? secondSource : firstSource;
break;
}
}
if (loadIndex < 0) return null;
Instruction load = instructions.get(loadIndex);
String loadMnemonic = mnemonic(load);
int entrySize = loadMnemonic.endsWith("B") ? 1 : 2;
boolean signedEntries = loadMnemonic.startsWith("LDRS");
java.util.regex.Matcher loadMatcher = AARCH64_TABLE_LOAD.matcher(operands(load));
if (!loadMatcher.matches()) return null;
// A signed load into a W register sign-extends only to 32 bits; the
// 64-bit dispatch ADD would then read a zero-extended value.
if (signedEntries && !loadMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
String tableBaseRegister = normalizedRegister(loadMatcher.group(2));
String indexRegister = normalizedRegister(loadMatcher.group(3));
boolean wordIndex = loadMatcher.group(3).toLowerCase(Locale.ROOT).startsWith("w");
String extend = loadMatcher.group(4) == null ? null : loadMatcher.group(4).toUpperCase(Locale.ROOT);
int indexShift = loadMatcher.group(5) == null ? 0 : Integer.parseInt(loadMatcher.group(5), 16);
if (wordIndex ? !"UXTW".equals(extend) : "UXTW".equals(extend)) return null;
if (indexShift != (entrySize == 1 ? 0 : 1)) return null;
// Only ADR and one CBZ on the entry may sit between load and ADD.
Address nullTarget = null;
for (int index = loadIndex + 1; index < branchIndex - 1; index += 1) {
Instruction instruction = instructions.get(index);
String name = mnemonic(instruction);
if (name.equals("ADR")) continue;
if (name.equals("CBZ") && nullTarget == null) {
String[] parts = operands(instruction).split(",");
Address[] flows = instruction.getFlows();
if (parts.length != 2 || flows.length != 1 ||
!normalizedRegister(parts[0]).equals(entryRegister)) return null;
nullTarget = flows[0];
continue;
}
return null;
}
Address[] recoveredTargets = jumpTable.getCases();
Set<String> recoveredTargetSet = new HashSet<>();
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
List<Address> dataAddresses = dispatchPathDataReferences(jumpTable);
int baseIndex = latestWriter(instructions, branchIndex - 1, baseRegister);
if (baseIndex < 0 || !mnemonic(instructions.get(baseIndex)).equals("ADR")) return null;
java.util.regex.Matcher baseMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(baseIndex)));
if (!baseMatcher.matches()) return null;
Address branchBase = addressFromOperand(baseMatcher.group(2));
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
Instruction load = instructions.get(loadIndex);
int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
!instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
!instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
!instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
String loadOperands = operands(load);
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
.compile(entrySize == 1 ? "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$" : "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+),\\s*LSL\\s*#(?:0x)?1\\]$")
.matcher(loadOperands);
if (!loadMatcher.matches()) continue;
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
String indexRegister = normalizedRegister(loadMatcher.group(2));
int tableAddIndex = latestWriter(instructions, loadIndex, tableBaseRegister);
if (tableAddIndex < 0 || !mnemonic(instructions.get(tableAddIndex)).equals("ADD")) return null;
java.util.regex.Matcher tableAddMatcher = AARCH64_ADD_IMMEDIATE.matcher(operands(instructions.get(tableAddIndex)));
if (!tableAddMatcher.matches() ||
!normalizedRegister(tableAddMatcher.group(1)).equals(tableBaseRegister) ||
!normalizedRegister(tableAddMatcher.group(2)).equals(tableBaseRegister)) return null;
int tablePageIndex = latestWriter(instructions, tableAddIndex, tableBaseRegister);
if (tablePageIndex < 0 || !mnemonic(instructions.get(tablePageIndex)).equals("ADRP")) return null;
java.util.regex.Matcher pageMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(tablePageIndex)));
if (!pageMatcher.matches() || !normalizedRegister(pageMatcher.group(1)).equals(tableBaseRegister)) return null;
Address tableAddress = addressFromOperand(pageMatcher.group(2)).add(parseInteger(tableAddMatcher.group(3)));
if (!currentProgram.getMemory().contains(tableAddress) ||
(!dataAddresses.isEmpty() && !dataAddresses.contains(tableAddress))) return null;
Address resolvedTableAddress = null;
for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
if (mnemonic.equals("ADRP") &&
text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
String[] parts = text.split(",");
if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
resolvedTableAddress = addressFromOperand(parts[1]);
// Unsigned guard: CMP Rs, #imm, then B.HI (imm + 1 entries) or
// B.CS/B.HS (imm entries), with only flag-preserving moves and
// address arithmetic between the compare and the load.
int guardIndex = -1;
for (int index = loadIndex - 1; index >= 0; index -= 1) {
String name = mnemonic(instructions.get(index));
if (name.startsWith("B.")) {
guardIndex = index;
break;
}
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
}
else if (mnemonic.equals("ADD") && resolvedTableAddress != null &&
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*#?(?:0x[0-9a-f]+|[0-9]+)$")) {
String[] parts = text.split(",");
if (normalizedRegister(parts[0]).equals(tableBaseRegister) &&
normalizedRegister(parts[1]).equals(tableBaseRegister)) {
resolvedTableAddress = resolvedTableAddress.add(parseInteger(parts[2].trim().replaceFirst("^#", "")));
}
}
}
if (resolvedTableAddress == null || !currentProgram.getMemory().contains(resolvedTableAddress) ||
(!dataAddresses.isEmpty() && !dataAddresses.contains(resolvedTableAddress))) continue;
Address tableAddress = resolvedTableAddress;
Address branchBase = null;
boolean addressChain = false;
for (int index = loadIndex - 1; index < branchIndex; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
int comma = text.indexOf(',');
String destination = normalizedRegister(text.substring(0, comma));
if (!destination.equals(normalizedRegister(operands(instructions.get(branchIndex))))) continue;
branchBase = addressFromOperand(text.substring(comma + 1));
}
if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*[wx]\\d+,\\s*LSL\\s*#(?:0x)?2$")) {
String[] parts = text.split(",");
String destination = normalizedRegister(parts[0]);
String base = normalizedRegister(parts[1]);
String offset = normalizedRegister(parts[2]);
if (destination.equals(base) &&
base.equals(normalizedRegister(operands(instructions.get(branchIndex)))) &&
offset.equals(loadedRegister)) {
addressChain = true;
}
}
}
if (!addressChain || branchBase == null) continue;
int entryCount = -1;
Address defaultTarget = null;
for (int compareIndex = 0; compareIndex < loadIndex; compareIndex += 1) {
Instruction compare = instructions.get(compareIndex);
if (!compare.getMnemonicString().equalsIgnoreCase("CMP")) continue;
java.util.regex.Matcher compareMatcher = java.util.regex.Pattern
.compile("(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$")
.matcher(operands(compare));
if (!compareMatcher.matches()) continue;
String comparedRegister = normalizedRegister(compareMatcher.group(1));
long upperBound = parseInteger(compareMatcher.group(2));
if (upperBound < 0 || upperBound >= 4096) continue;
for (int guardIndex = compareIndex + 1; guardIndex < loadIndex; guardIndex += 1) {
if (guardIndex < 1) return null;
Instruction guard = instructions.get(guardIndex);
String mnemonic = guard.getMnemonicString().toUpperCase(Locale.ROOT);
if (!mnemonic.equals("B.HI")) continue;
Address[] flows = guard.getFlows();
if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
boolean supportedSequence = true;
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
String sequenceMnemonic = instructions.get(sequenceIndex)
.getMnemonicString().toUpperCase(Locale.ROOT);
if (!Set.of("B.HI", "MOV", "ADRP", "ADD", "ADR").contains(sequenceMnemonic)) {
supportedSequence = false;
String guardMnemonic = mnemonic(guard);
if (!Set.of("B.HI", "B.CS", "B.HS").contains(guardMnemonic)) return null;
Address[] guardFlows = guard.getFlows();
if (guardFlows.length != 1) return null;
Address defaultTarget = guardFlows[0];
int compareIndex = -1;
for (int index = guardIndex - 1; index >= 0; index -= 1) {
String name = mnemonic(instructions.get(index));
if (name.equals("CMP")) {
compareIndex = index;
break;
}
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
}
Set<String> selectorAliases = new HashSet<>();
selectorAliases.add(comparedRegister);
Set<String> boundedIndexes = new HashSet<>();
if (compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) boundedIndexes.add(comparedRegister);
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex++) {
Instruction instruction = instructions.get(sequenceIndex);
String[] parts = operands(instruction).split(",");
if (parts.length == 0) continue;
if (instruction.getMnemonicString().equalsIgnoreCase("MOV")) {
String destination = normalizedRegister(parts[0]);
String source = parts.length == 2 ? normalizedRegister(parts[1]) : "";
boolean aliasesSelector = selectorAliases.contains(source);
boolean fullWidthBounded = boundedIndexes.contains(source) ||
(parts[0].trim().toLowerCase(Locale.ROOT).startsWith("w") && aliasesSelector);
if (aliasesSelector) selectorAliases.add(destination);
else selectorAliases.remove(destination);
if (fullWidthBounded) boundedIndexes.add(destination);
else boundedIndexes.remove(destination);
} else if (Set.of("ADRP", "ADD", "ADR").contains(instruction.getMnemonicString().toUpperCase(Locale.ROOT))) {
selectorAliases.remove(normalizedRegister(parts[0]));
boundedIndexes.remove(normalizedRegister(parts[0]));
if (compareIndex < 0) return null;
java.util.regex.Matcher compareMatcher = AARCH64_COMPARE_IMMEDIATE.matcher(operands(instructions.get(compareIndex)));
if (!compareMatcher.matches()) return null;
String comparedRegister = normalizedRegister(compareMatcher.group(1));
boolean comparedFullWidth = compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x");
long compareImmediate = parseInteger(compareMatcher.group(2));
long entryCountLong = guardMnemonic.equals("B.HI") ? compareImmediate + 1 : compareImmediate;
if (entryCountLong <= 0 || entryCountLong > 4096) return null;
int entryCount = (int) entryCountLong;
// The loaded index must carry the compared value: either the compared
// register itself, unchanged since CMP, or a MOV copy made after CMP.
// A W compare bounds only the low 32 bits, so an X index needs a
// zero-extending W copy; a W index is zero-extended by UXTW.
int indexWriter = latestWriter(instructions, loadIndex, indexRegister);
boolean bounded = false;
if (indexWriter < compareIndex) {
bounded = indexRegister.equals(comparedRegister) && (wordIndex || comparedFullWidth);
}
else if (mnemonic(instructions.get(indexWriter)).equals("MOV")) {
java.util.regex.Matcher moveMatcher = AARCH64_MOVE_REGISTER.matcher(operands(instructions.get(indexWriter)));
if (moveMatcher.matches() &&
normalizedRegister(moveMatcher.group(2)).equals(comparedRegister) &&
latestWriter(instructions, indexWriter, comparedRegister) < compareIndex) {
boolean wordCopy = moveMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("w");
bounded = wordCopy || comparedFullWidth;
}
}
boolean selectorFeedsTable = boundedIndexes.contains(indexRegister);
if (compareIndex < loadIndex - 3 && selectorFeedsTable && supportedSequence) {
entryCount = (int) upperBound + 1;
defaultTarget = flows[0];
break;
if (!bounded) return null;
// Every fact above holds only on one straight-line fall-through path
// with no other entry into the window.
int windowStart = Math.min(Math.min(tablePageIndex, baseIndex), compareIndex);
for (int index = windowStart + 1; index <= branchIndex; index += 1) {
monitor.checkCancelled();
Instruction previous = instructions.get(index - 1);
Instruction current = instructions.get(index);
Address fallThrough = previous.getFallThrough();
if (fallThrough == null || !fallThrough.equals(current.getAddress())) return null;
ReferenceIterator incoming = currentProgram.getReferenceManager().getReferencesTo(current.getAddress());
while (incoming.hasNext()) {
if (incoming.next().getReferenceType().isFlow()) return null;
}
}
if (entryCount > 0) break;
}
if (entryCount <= 0 || entryCount > 4096 || defaultTarget == null) continue;
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
Set<String> mappedTargets = new HashSet<>();
boolean valid = true;
for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
Address entry = tableAddress.add((long) caseValue * entrySize);
int encodedOffset = entrySize == 1 ? currentProgram.getMemory().getByte(entry) & 0xff : currentProgram.getMemory().getShort(entry) & 0xffff;
Address target = branchBase.add((long) encodedOffset * 4L);
for (int tableIndex = 0; tableIndex < entryCount; tableIndex += 1) {
monitor.checkCancelled();
Address entry = tableAddress.add((long) tableIndex * entrySize);
if (!currentProgram.getMemory().contains(entry)) return null;
long encoded;
if (entrySize == 1) {
byte value = currentProgram.getMemory().getByte(entry);
encoded = signedEntries ? value : value & 0xffL;
}
else {
short value = currentProgram.getMemory().getShort(entry);
encoded = signedEntries ? value : value & 0xffffL;
}
if (encoded == 0 && nullTarget != null) {
mappings.add(new RecoveredAArch64Mapping(tableIndex, nullTarget, encoded, true));
continue;
}
Address target;
try {
target = branchBase.addNoWrap(encoded << offsetShift);
}
catch (ghidra.program.model.address.AddressOverflowException overflow) {
return null;
}
String canonicalTarget = canonicalAddress(target);
if (!recoveredTargetSet.contains(canonicalTarget)) {
valid = false;
break;
}
if (!recoveredTargetSet.contains(canonicalTarget)) return null;
mappedTargets.add(canonicalTarget);
mappings.add(new RecoveredAArch64Mapping(caseValue, target));
mappings.add(new RecoveredAArch64Mapping(tableIndex, target, encoded, false));
}
if (!valid || mappedTargets.contains(canonicalAddress(defaultTarget))) continue;
if (mappedTargets.contains(canonicalAddress(defaultTarget))) return null;
// Ghidra's recovered destinations must be fully accounted for by the
// decoded entries, the guard default, and the CBZ null path.
Set<String> accountedTargets = new HashSet<>(mappedTargets);
accountedTargets.add(canonicalAddress(defaultTarget));
if (!accountedTargets.equals(recoveredTargetSet)) continue;
return new RecoveredAArch64RelativeTable(tableAddress, branchBase, entrySize, List.copyOf(mappings));
if (nullTarget != null) accountedTargets.add(canonicalAddress(nullTarget));
// Ghidra does not model the CBZ, so it may decode a zero entry as a
// branch to the base itself. Only a zero entry encodes offset 0, and
// the CBZ diverts every zero entry, so the BR never reaches the base.
Address unreachableZeroTarget = null;
boolean divertedZeroEntry = mappings.stream().anyMatch(mapping -> mapping.nullEntry);
if (divertedZeroEntry &&
recoveredTargetSet.contains(canonicalAddress(branchBase)) &&
!mappedTargets.contains(canonicalAddress(branchBase)) &&
!branchBase.equals(defaultTarget) &&
!branchBase.equals(nullTarget)) {
unreachableZeroTarget = branchBase;
accountedTargets.add(canonicalAddress(branchBase));
}
return null;
if (!accountedTargets.containsAll(recoveredTargetSet)) return null;
int selectorWriter = latestWriter(instructions, compareIndex, comparedRegister);
return new RecoveredAArch64RelativeTable(
tableAddress,
branchBase,
entrySize,
selectorWriter < 0 ? null : instructions.get(selectorWriter).getAddress(),
signedEntries,
offsetShift,
guardMnemonic,
compareImmediate,
nullTarget,
unreachableZeroTarget,
List.copyOf(mappings)
);
}
private static String mnemonic(Instruction instruction) {
return instruction.getMnemonicString().toUpperCase(Locale.ROOT);
}
/** Index of the last instruction before {@code before} that writes {@code register}, or -1. */
private static int latestWriter(List<Instruction> instructions, int before, String register) {
for (int index = before - 1; index >= 0; index -= 1) {
for (Object result : instructions.get(index).getResultObjects()) {
if (result instanceof Register written &&
normalizedRegister(written.getBaseRegister().getName()).equals(register)) {
return index;
}
}
}
return -1;
}
private static String operands(Instruction instruction) {
+64 -35
View File
@@ -1,16 +1,25 @@
# Optional NativeAOT metadata recovery
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.
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).
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.
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.
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
@@ -43,42 +52,62 @@ arbitrary caller-supplied bytes. The bounded loader accepts regular JARs up to
## Use existing tools
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.
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.
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:
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:
```sh
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
rea inspect-native-data-type ./NativeAotFixture --type /NativeAOT/MethodTables/Class_ADDRESS_MT --provider ghidra --json
rea inspect-native-data-type ./NativeAotFixture --address 0xMETHODTABLE_ADDRESS --provider ghidra --json
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
```
No new MCP workflow or approval flag is required. Configuration opts into the
adapter; without it ordinary Ghidra profiles/results remain unchanged.
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.
## Evidence boundaries
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 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.
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.
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.
REA preserves pre-recovery calling conventions and uses the loaded compiler
specification default for newly created methods. The upstream universal
+3 -3
View File
@@ -8,9 +8,9 @@ Executable bytes remain unchanged.
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
configuration, startup deadlines, resource controls, and cleanup recovery.
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
[NativeAOT recovery](ghidra-nativeaot.md) is an optional, separately verified
extension. Check the [release boundary](installation.md#released-package-and-main)
when using npm.
[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.
See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds)
to override the header-detected language or seed analysis.
@@ -60,6 +60,12 @@ manifest and graph commitments, JavaScript Application Graph, static
Electron summary, reconstruction statistics, and explicit limitations. It does
not require a live Hopper, Ghidra, browser, or Electron process.
The application graph selects the shallowest `package.json` outside
`node_modules` as its package root, breaking equal-depth ties by Unicode
code-point order. Dependency manifests remain package observations but do not
declare Electron main or renderer entries. If only dependency manifests are
present, the inventory artifact remains the graph root.
## ASAR integrity
ASAR inventory checks Electron integrity metadata for embedded archive entries
@@ -247,6 +253,46 @@ 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
+31 -7
View File
@@ -1,7 +1,7 @@
# JavaScript source recovery
`recover_javascript_sources` / `recover-javascript-sources` derives readable
modules from one explicitly selected UTF-8 JavaScript file or bundle. Use it on
modules from explicitly selected UTF-8 JavaScript files or bundles. Use it on
files returned by `export_web_scripts` or other local scripts. The operation
never executes the selected or recovered application code.
@@ -52,35 +52,58 @@ Both give the same binary (SHA-256
```sh
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --json
rea analyze-javascript-application /tmp/recovered-app/modules --json
# Recover related code-split chunks together; repeat --additional-path.
rea recover-javascript-sources /tmp/web-scripts/main.js /tmp/recovered-chunks \
--additional-path /tmp/web-scripts/runtime.js \
--additional-path /tmp/web-scripts/shared.js \
--additional-path /tmp/web-scripts/checkout.chunk.js --json
```
MCP input:
```json
{
"path": "/tmp/web-scripts/app.js",
"output_directory": "/tmp/recovered-app"
"path": "/tmp/web-scripts/main.js",
"additional_paths": [
"/tmp/web-scripts/runtime.js",
"/tmp/web-scripts/shared.js",
"/tmp/web-scripts/checkout.chunk.js"
],
"output_directory": "/tmp/recovered-chunks"
}
```
The output directory must be absent, absolute, and have an existing parent.
The source is snapshotted in a private workspace, and the engine writes only to
Each source is snapshotted separately in a private workspace. Multiple inputs
retain their relative directory layout and are passed together to one Wakaru
invocation, allowing cross-chunk module IDs to resolve. Inputs must be distinct;
`additional_paths` accepts up to 16 additional files. The engine writes only to
its own staging directory. Reported module paths, input identities, UTF-8 byte
ranges, regular files and emitted map representations are validated before
publication. REA writes verified bytes to an exclusively owned output tree and
rolls it back on failure. It also checks that the original source and engine
bytes remain unchanged before committing. The existing output is never overwritten.
Multi-input relative filenames must be representable unchanged as artifact paths:
NFC Unicode spelling and no literal backslashes. Unsupported layouts fail before
invoking the engine rather than silently renaming inputs referenced by source maps.
Published contents:
- `inputs/bundle.js`: exact original snapshot; its digest binds the Evidence.
- `inputs/bundle.js`: exact original snapshot for a single input. Multiple inputs
retain paths relative to their common parent under `inputs/`.
- `modules/`: derived modules and any upstream-emitted source maps.
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
The inline result includes complete file paths/digests/sizes, extraction ranges,
warnings and `analysis_input`, directly usable as the arguments to
`analyze_javascript_application`. Raw Evidence retains selected command arguments,
`analyze_javascript_application`. `source` identifies the primary input and binds
the Evidence subject; `additional_sources` records the other snapshots and their
published copies when selected. Each module names its owning snapshot, original
path and original digest; all extraction and context ranges refer to that input.
Raw Evidence retains selected command arguments,
reported input paths, exit status, stderr and reports; temporary paths are recorded
as historical execution coordinates after their workspace is removed.
@@ -107,7 +130,8 @@ equivalence remain unknown. No network fetching occurs.
## Resource policy and verification
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
deadline; it accepts up to 64 MiB of input and 128 MiB/10000 entries of staged
deadline; it accepts up to 64 MiB of total input across all selected files and
128 MiB/10000 entries of staged
output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit
failure with rollback. These limits apply per operation and do not claim aggregate
host containment across concurrent callers.
+13 -3
View File
@@ -451,12 +451,12 @@ installation (`application.java.min` through `application.java.max`). Current
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
@@ -672,6 +672,13 @@ 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
@@ -1029,7 +1036,10 @@ and `REA_JAVASCRIPT_FIXTURE_TOOLS` pointing to an isolated npm prefix containing
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
published bytes and provenance, feeds recovered modules into existing analysis,
and compares a finite set of known fixture results. The provider also accepts
and compares a finite set of known fixture results. A webpack fixture with separate
runtime, shared, entry and lazy chunks verifies multi-input recovery through both
surfaces, per-file provenance and resolved cross-chunk imports in the downstream
application graph. The provider also accepts
other ^1.13.0 releases and reports them as unverified; this lane proves the
audited release. It does not establish
arbitrary recovered-application equivalence. CI installs these prerequisites only
+6 -3
View File
@@ -21,9 +21,12 @@ 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 measurements with an explicit unsupported
attestation status; independent format-specific verification currently supports
DOS MZ only.
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.
This boundary does not establish general Windows feature parity. Ghidra GUI
state, annotations, other target architectures and formats, and Hopper-only
+28
View File
@@ -0,0 +1,28 @@
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).
+2 -1
View File
@@ -81,7 +81,8 @@
"bridge/pwndbg/launch_impl.py",
"bridge/pwndbg/core_context.py",
"third_party/pwndbg/README.md",
"third_party/pwndbg/LICENSE.md"
"third_party/pwndbg/LICENSE.md",
"licenses/nativeaot-parser-MIT.txt"
],
"scripts": {
"release:notes": "node scripts/release-notes.mjs",
+24
View File
@@ -14,6 +14,24 @@ 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) => {
@@ -122,6 +140,7 @@ 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;
@@ -131,6 +150,11 @@ 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(
+49 -9
View File
@@ -1,5 +1,5 @@
import { createRequire } from "node:module";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -20,14 +20,9 @@ export async function buildRecoveryFixtures(root, prefix) {
const source = fileURLToPath(new URL("./entry.mjs", import.meta.url));
const webpackOutput = join(root, "webpack");
await mkdir(webpackOutput);
await new Promise((resolve, reject) => {
const compiler = webpack({
mode: "production",
entry: source,
parallelism: 1,
optimization: { concatenateModules: false },
output: { path: webpackOutput, filename: "bundle.js" },
});
const compileWebpack = async (options) =>
new Promise((resolve, reject) => {
const compiler = webpack(options);
compiler.run((error, stats) => {
compiler.close((closeError) => {
if (error || closeError) return reject(error ?? closeError);
@@ -37,6 +32,44 @@ export async function buildRecoveryFixtures(root, prefix) {
});
});
});
await compileWebpack({
mode: "production",
entry: source,
parallelism: 1,
optimization: { concatenateModules: false },
output: { path: webpackOutput, filename: "bundle.js" },
});
const splitOutput = join(root, "webpack-code-split");
await mkdir(splitOutput);
const splitSource = fileURLToPath(
new URL("./code-split/entry.mjs", import.meta.url),
);
await compileWebpack({
mode: "production",
entry: splitSource,
parallelism: 1,
optimization: {
concatenateModules: false,
runtimeChunk: "single",
splitChunks: {
chunks: "all",
minSize: 0,
cacheGroups: {
shared: { test: /shared\.mjs$/u, name: "shared", enforce: true },
},
},
},
output: {
path: splitOutput,
filename: "[name].js",
chunkFilename: "[id].chunk.js",
},
});
const splitFiles = (await readdir(splitOutput))
.filter((name) => name !== "main.js")
.sort();
if (splitFiles.length !== 3)
throw new Error("Expected runtime, shared and lazy chunks");
const esbuildPath = join(root, "esbuild.js");
await esbuild.build({
entryPoints: [source],
@@ -95,6 +128,13 @@ export async function buildRecoveryFixtures(root, prefix) {
options: { extraction_mode: "inspection", rewrite_level: "minimal" },
},
],
codeSplit: {
name: "webpack-code-split",
path: join(splitOutput, "main.js"),
additional_paths: splitFiles.map((name) => join(splitOutput, name)),
expectedFormat: "webpack5",
expectedModules: 4,
},
toolchains: { esbuild: esbuild.version, webpack: webpackVersion },
};
}
@@ -0,0 +1,6 @@
import { Cart, formatPrice, TAX_RATE } from "./shared.mjs";
export function checkout(prices) {
const cart = new Cart();
prices.forEach((price) => cart.add(price));
return formatPrice(cart.total() * (1 + TAX_RATE));
}
@@ -0,0 +1,3 @@
import { formatPrice } from "./shared.mjs";
console.log(formatPrice(1));
import("./checkout.mjs").then(({ checkout }) => console.log(checkout([1, 2])));
@@ -0,0 +1,14 @@
export class Cart {
constructor() {
this.items = [];
}
add(price) {
this.items.push(price);
return this;
}
total() {
return this.items.reduce((sum, price) => sum + price, 0);
}
}
export const formatPrice = (value) => `$${value.toFixed(2)}`;
export const TAX_RATE = 0.08;
+21 -14
View File
@@ -99,6 +99,14 @@ 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>
@@ -168,6 +176,9 @@ const startStorageSite = async () => {
origin: `http://127.0.0.1:${port}`,
close: async () => {
server.close();
// Chrome can leave speculative TCP connections without an HTTP request;
// server.close() alone waits indefinitely for those owned sockets.
server.closeAllConnections();
await once(server, "close");
},
};
@@ -303,11 +314,10 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
await verifyCallerDebuggerPause(observer, origin, scenario);
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
} finally {
await Promise.race([
restoreTarget(observer, target.url),
delay(1_000),
]).catch(() => undefined);
await Promise.race([observer.close(), delay(1_000)]).catch(() => undefined);
await raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
() => undefined,
);
await raceWithTimeout(observer.close(), 1_000).catch(() => undefined);
}
};
@@ -500,14 +510,11 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
unsubscribe();
});
try {
const callFrameId = await Promise.race([
paused,
delay(10_000).then(() => {
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 pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
callFrameId,
expression: "window.reaDebuggerAfter === true",
@@ -536,10 +543,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 Promise.race([
const completion = await raceWithTimeout(
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
delay(5_000).then(() => undefined),
]);
5_000,
);
if (completion === undefined)
throw new Error("Capture remained pending after cancellation", { cause });
const completionError =
@@ -579,7 +586,7 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
});
} finally {
unsubscribe();
await Promise.race([caller.send("Debugger.disable"), delay(1_000)]).catch(
await raceWithTimeout(caller.send("Debugger.disable"), 1_000).catch(
() => undefined,
);
}
+4
View File
@@ -277,11 +277,13 @@ try {
throw new Error("Scenario attachment terminated its external browser");
const profilesBefore = await scenarioProfiles();
process.stderr.write("Browser verifier: scenario environment\n");
const scenarioEnvironment = await verifyScenarioEnvironment(
endpoint,
target,
site.origin,
);
process.stderr.write("Browser verifier: scenario storage\n");
const scenarioStorage = await verifyScenarioStorage({
executable,
endpoint,
@@ -339,7 +341,9 @@ try {
`Browser scenario included unselected structured value: ${unselectedValue}`,
);
process.stderr.write("Browser verifier: large screenshot\n");
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
process.stderr.write("Browser verifier: popup coverage\n");
const popupEvents = await verifyPopupEventCoverage(executable);
const networkEvidence = await verifyBrowserNetworkEvidence(
executable,
+107
View File
@@ -674,6 +674,113 @@ export async function verifyRelativeSwitch(client, procedures, entrySize) {
};
}
/**
* Oracle for relative-switch-signed.S: signed halfword byte offsets from a base
* inside the case bodies, a CBZ route for the zero entry, and a B.CS bound.
*/
const SIGNED_RELATIVE_SWITCH_CASES = [
{ value: 0, immediate: 200 },
{ value: 1, immediate: 201 },
{ value: 2, immediate: 202 },
{ value: 3, immediate: 250 },
{ value: 4, immediate: 204 },
];
export async function verifyRelativeSignedSwitch(client, selectorBias = 0) {
const names = await inventoryCall(client, "list_names", {
document: null,
address: null,
});
const procedure = requireProcedure(names, "rea_relative_switch_signed");
const dossier = await functionCall(client, "analyze_function", {
procedure: procedure.address,
});
const table = dossier.native_api?.jump_tables.find((item) =>
item.data_sources.some(
(source) =>
source.entry_size_bytes === 2 &&
source.entry_count === SIGNED_RELATIVE_SWITCH_CASES.length,
),
);
if (table === undefined)
throw new Error(
`Exact signed halfword relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
);
const expectedValues = SIGNED_RELATIVE_SWITCH_CASES.map(
({ value }) => value + selectorBias,
);
if (
table.mappings.some(
(mapping) =>
mapping.case_value !== null &&
!expectedValues.includes(mapping.case_value),
)
)
throw new Error(
`Signed relative switch emitted an unsupported case label: ${JSON.stringify(table)}`,
);
if (table.mappings.length !== SIGNED_RELATIVE_SWITCH_CASES.length)
throw new Error(
`Signed relative switch has unexpected mappings: ${JSON.stringify(table)}`,
);
if (selectorBias !== 0) {
if (
!table.limitations.some((limitation) =>
limitation.includes("table indices are not proven"),
) ||
!table.mappings.some((mapping) => mapping.case_value === null) ||
table.mappings.some((mapping) => mapping.case_value === selectorBias + 3)
)
throw new Error(
`Normalized selector must retain unknown case evidence: ${JSON.stringify(table)}`,
);
}
for (const { value: tableIndex, immediate } of SIGNED_RELATIVE_SWITCH_CASES) {
const value = tableIndex + selectorBias;
// Without a proven relationship between the machine index and the typed
// switch selector, the CBZ-only case must stay unknown rather than being
// assigned the normalized index or Ghidra's unreachable base destination.
if (selectorBias !== 0 && tableIndex === 3) continue;
const mapping = table.mappings.find((item) => item.case_value === value);
if (
mapping === undefined ||
mapping.confidence !== "high" ||
(selectorBias !== 0 &&
!mapping.evidence.some(
(evidence) => evidence.source === "ghidra-clang-case-token",
))
)
throw new Error(
`Signed relative switch case ${value} was not verified: ${JSON.stringify(table)}`,
);
const instruction = await functionCall(
client,
"inspect_native_instruction",
{ address: mapping.target_address },
);
if (
instruction.status !== "decoded" ||
!instruction.operands.some((operand) =>
operand.components.some(
(component) =>
component.kind === "immediate" &&
component.value === `0x${immediate.toString(16)}`,
),
)
)
throw new Error(
`Signed relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
);
}
return {
entry_size: 2,
signed: true,
selector_bias: selectorBias,
dispatch_address: table.dispatch_address,
mappings: table.mappings.length,
};
}
export async function verifyNativeValueTrace(client, procedures, target) {
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
const names = await inventoryCall(client, "list_names", {
+37 -1
View File
@@ -38,6 +38,7 @@ import {
verifyJumpTableDenseSwitch,
verifyNativeTypeLayout,
verifyRelativeSwitch,
verifyRelativeSignedSwitch,
verifyNativeValueTrace,
} from "./verify-real-ghidra-function.mjs";
const exec = promisify(execFile);
@@ -173,6 +174,36 @@ try {
]);
}
}
const signedRelativeSource = fileURLToPath(
new URL(
"../tests/conformance/ghidra/relative-switch-signed.S",
import.meta.url,
),
);
for (const selectorBias of [0, 2, 10]) {
for (const format of [
"elf",
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
]) {
const targetPath = join(
fixtureRoot,
`relative-signed-${selectorBias}-${format}.o`,
);
await exec(clang, [
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
`-DREA_SELECTOR_BIAS=${selectorBias}`,
"-c",
signedRelativeSource,
"-o",
targetPath,
]);
crossTargets.push([
targetPath,
`relative-signed-${selectorBias}`,
{ format, architecture: "arm64" },
]);
}
}
await exec(clang, [
"--target=aarch64-linux-gnu",
@@ -522,7 +553,12 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
client,
})
: null;
const probes = variant.startsWith("relative-")
const probes = variant.startsWith("relative-signed-")
? await verifyRelativeSignedSwitch(
client,
Number(variant.split("-").at(-1)),
)
: variant.startsWith("relative-")
? await verifyRelativeSwitch(
client,
procedures,
+56 -10
View File
@@ -56,7 +56,7 @@ try {
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
);
await client.connect(transport);
for (const fixture of fixtures.files) {
for (const fixture of [...fixtures.files, fixtures.codeSplit]) {
for (const surface of ["cli", "stdio_mcp"]) {
const output = join(root, `${fixture.name}-${surface}`);
const flags =
@@ -75,11 +75,18 @@ try {
fixture.path,
output,
...flags,
...(fixture.additional_paths ?? []).flatMap((path) => [
"--additional-path",
path,
]),
)
: await call("recover_javascript_sources", {
path: fixture.path,
output_directory: output,
...fixture.options,
...(fixture.additional_paths
? { additional_paths: fixture.additional_paths }
: {}),
});
const result = javascriptRecoveryResultSchema.parse(
evidence.normalized_result,
@@ -97,6 +104,25 @@ try {
analysis.normalized_result.statistics.parsed_javascript_files,
result.modules.length,
);
const imports = analysis.normalized_result.graph.edges.filter(
(edge) =>
edge.relation === "imports" &&
edge.properties.resolution_status === "resolved",
);
if (fixture.additional_paths) {
assert.ok(
imports.length >= 4,
"Expected cross-chunk imports in the recovered application graph",
);
const recoveredPaths = new Set(
result.modules.map((module) => module.reported_filename),
);
for (const edge of imports)
assert.ok(
recoveredPaths.has(edge.properties.resolved_path),
`Expected an emitted import target: ${String(edge.properties.resolved_path)}`,
);
}
const recoveredEntry =
result.modules.find(
(module) => module.reported_filename === "entry.js",
@@ -104,7 +130,8 @@ try {
assert.ok(recoveredEntry);
const oracle =
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
for (const selected of fixture.options?.extraction_mode === "inspection"
for (const selected of fixture.additional_paths ||
fixture.options?.extraction_mode === "inspection"
? []
: [fixture.path, recoveredEntry.artifact.path]) {
const execution = await run(
@@ -125,7 +152,9 @@ try {
maps: result.modules.filter((module) => module.source_map !== null)
.length,
finite_fixture_oracle:
!fixture.additional_paths &&
fixture.options?.extraction_mode !== "inspection",
cross_chunk_imports: fixture.additional_paths ? imports.length : null,
});
}
}
@@ -157,9 +186,15 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
assert.equal(result.status, "complete");
assert.equal(result.reported_format, fixture.expectedFormat);
assert.equal(result.modules.length, fixture.expectedModules);
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
const sources = [result.source, ...(result.additional_sources ?? [])];
assert.deepEqual(
sources.map((source) => source.path),
[fixture.path, ...(fixture.additional_paths ?? [])],
);
for (const source of sources)
assert.equal(source.sha256, fingerprint(await readFile(source.path)));
for (const file of [
result.source.published_copy,
...sources.map((source) => source.published_copy),
result.report,
result.provenance,
result.manifest,
@@ -173,16 +208,27 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
assert.equal(fingerprint(bytes), file.sha256);
}
for (const module of result.modules) {
assert.equal(module.provenance.original_sha256, result.source.sha256);
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
for (const range of module.provenance.byte_ranges)
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
const source = sources.find(
(source) => source.snapshot_path === module.provenance.reported_input,
);
assert.ok(source, "Module must name a selected input snapshot");
assert.equal(module.provenance.original_path, source.path);
assert.equal(module.provenance.original_sha256, source.sha256);
for (const range of [
...module.provenance.byte_ranges,
...module.provenance.context_byte_ranges,
])
assert.ok(range.start <= range.end && range.end <= source.bytes);
if (module.source_map) {
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
for (const source of map.sources)
assert.equal(
assert.ok(
sources.some(
(selected) =>
selected.published_copy.path ===
resolve(dirname(module.source_map.path), source),
result.source.published_copy.path,
),
"Map source must name a published input snapshot",
);
}
}
@@ -26,6 +26,7 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
interface PackageRoleInput {
readonly packageNode: ApplicationNode;
@@ -281,6 +282,7 @@ export const addJavaScriptPackageNodes = (
context: JavaScriptArtifactGraphContext,
): ApplicationNode[] => {
const roots: ApplicationNode[] = [];
const rootPath = applicationManifestPath(context);
for (const packageValue of context.analysis.packages) {
const file = context.filesByPath.get(packageValue.path);
if (file === undefined) continue;
@@ -309,7 +311,7 @@ export const addJavaScriptPackageNodes = (
},
],
});
if (roots.length === 0) {
if (file.path === rootPath) {
roots.push(node);
context.accumulator.addEdge({
source_node_id: node.node_id,
@@ -330,6 +332,7 @@ export const addJavaScriptPackageNodes = (
file,
operation: "inventory-package",
});
if (dependencyManifest(file.path)) continue;
addPackageRole(context, {
packageNode: node,
packageFile: file,
@@ -346,6 +349,24 @@ export const addJavaScriptPackageNodes = (
return roots;
};
/** Dependency-only artifacts retain their inventory root. */
const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter(
(path) => context.filesByPath.has(path) && !dependencyManifest(path),
)
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
@@ -129,4 +129,50 @@ describe("JavaScript recovery workflow", () => {
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
it("rejects additional_paths repeating path or each other", async () => {
const service = new JavaScriptRecoveryService({
recover: async () => {
throw new Error("port must not execute");
},
});
for (const additional_paths of [
[artifact.path],
["/tmp/dir/../bundle.js", "/tmp/bundle.js"],
["/tmp/shared.js", "/tmp/shared.js"],
]) {
const result = await service.recover({
...input,
additional_paths,
});
if (result.ok) throw new Error("expected duplicate rejection");
expect(result.error._tag).toBe("AnalysisInputError");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
}
});
it("rejects relative additional_paths before acquiring the port", async () => {
let invoked = false;
const service = new JavaScriptRecoveryService({
recover: async () => {
invoked = true;
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
"recover_javascript_sources",
"engine missing",
),
);
},
});
const result = await service.recover({
...input,
additional_paths: ["relative-chunk.js"],
});
expect(invoked).toBe(false);
if (result.ok) throw new Error("expected invalid path");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
});
});
@@ -1,4 +1,4 @@
import { isAbsolute } from "node:path";
import { isAbsolute, resolve } from "node:path";
import {
AnalysisCancelledError,
@@ -11,7 +11,7 @@ import {
javascriptRecoveryInputSchema,
javascriptRecoveryResultSchema,
} from "../../domain/javascript/javascriptRecovery.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
@@ -44,18 +44,39 @@ export class JavaScriptRecoveryService {
const invalidPaths = (["path", "output_directory"] as const).filter(
(field) => !isAbsolute(parsed.data[field]),
);
if (invalidPaths.length > 0)
const invalidAdditional = (parsed.data.additional_paths ?? []).filter(
(additional) => !isAbsolute(additional),
);
if (invalidPaths.length > 0 || invalidAdditional.length > 0)
return err(
new AnalysisInputError(
OPERATION,
undefined,
invalidPaths.map((field) => ({
new AnalysisInputError(OPERATION, undefined, [
...invalidPaths.map((field) => ({
path: [field],
reason: "invalid_format",
reason: "invalid_format" as const,
message: `${field} must be an absolute filesystem path on this host.`,
})),
...invalidAdditional.map((additional) => ({
path: ["additional_paths"],
reason: "invalid_format" as const,
message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`,
})),
]),
);
const selectedInputs = new Set(
[parsed.data.path, ...(parsed.data.additional_paths ?? [])].map((path) =>
resolve(path),
),
);
if (selectedInputs.size !== 1 + (parsed.data.additional_paths ?? []).length)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["additional_paths"],
reason: "invalid_format",
message: "additional_paths must not repeat path or each other.",
},
]),
);
const execution = await this.provider.recover(parsed.data, options);
if (!execution.ok) return execution;
const result = javascriptRecoveryResultSchema.safeParse(
@@ -70,6 +91,11 @@ export class JavaScriptRecoveryService {
);
if (
result.data.source.path !== parsed.data.path ||
(result.data.additional_sources ?? []).length !==
(parsed.data.additional_paths ?? []).length ||
(parsed.data.additional_paths ?? []).some(
(path, index) => result.data.additional_sources?.[index]?.path !== path,
) ||
execution.value.subject.path !== result.data.source.path ||
execution.value.subject.sha256 !== result.data.source.sha256 ||
execution.value.provider.id !== result.data.engine.id ||
@@ -85,7 +111,7 @@ export class JavaScriptRecoveryService {
createEvidence(execution.value.subject, execution.value.provider, {
operation: OPERATION,
parameters: jsonObjectSchema.parse(parsed.data),
result: result.data,
result: jsonValueSchema.parse(result.data),
rawResult: execution.value.rawResult,
locations: execution.value.locations,
limitations: execution.value.limitations,
@@ -1,4 +1,4 @@
import { access } from "node:fs/promises";
import { access, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
@@ -32,6 +32,8 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
);
try {
await access(command, constants.R_OK | constants.X_OK);
if (!(await stat(command)).isFile())
throw new TypeError("Configured path is not a regular file");
} catch (cause: unknown) {
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
throw new AnalysisCapabilityUnavailableError(
+9
View File
@@ -27,6 +27,12 @@ export const registerJavaScriptRecoveryCommands = (
outputDirectory: z.string().describe("Absolute absent output directory"),
}),
options: z.object({
additionalPath: z
.array(z.string())
.optional()
.describe(
"Related absolute UTF-8 JavaScript file; repeat for multiple chunks",
),
extractionMode: z
.enum(["structural", "heuristic", "inspection"])
.optional()
@@ -51,6 +57,9 @@ export const registerJavaScriptRecoveryCommands = (
{
path: args.path,
output_directory: args.outputDirectory,
...(options.additionalPath === undefined
? {}
: { additional_paths: options.additionalPath }),
...(options.extractionMode === undefined
? {}
: { extraction_mode: options.extractionMode }),
@@ -13,7 +13,7 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
...toolContractMetadata("recover_javascript_sources"),
kind: "application",
description:
"Recover readable modules from one local UTF-8 JavaScript script/bundle into an absent directory, using caller-supplied Wakaru ^1.13.0 (verified with 1.14.0) on Linux x64. Returns original/derived digests, extraction byte ranges, emitted source maps, warnings and analysis_input inline. Structural splitting may fall back to one module; original names and runtime equivalence remain unknown. No application code execution. Requires util-linux prlimit; one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 entries and 8 MiB per diagnostic/report stream.",
"Recover readable modules from local UTF-8 JavaScript scripts/bundles into an absent directory, using caller-supplied Wakaru ^1.13.0 (verified with 1.14.0) on Linux x64. Use additional_paths for related chunks; each input retains its own snapshot and provenance. Returns original/derived digests, extraction byte ranges, emitted source maps, warnings and analysis_input inline. Structural splitting may fall back to one module; original names and runtime equivalence remain unknown. No application code execution. Requires util-linux prlimit; one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB total input, 128 MiB output, 10000 entries and 8 MiB per diagnostic/report stream.",
inputSchema: javascriptRecoveryInputSchema,
outputSchema: evidenceResultOf(javascriptRecoveryResultSchema),
examples: [
@@ -24,6 +24,18 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
output_directory: "/tmp/recovered-app",
},
},
{
title: "Recover a code-split bundle together",
input: {
path: "/tmp/web-scripts/main.js",
additional_paths: [
"/tmp/web-scripts/runtime.js",
"/tmp/web-scripts/shared.js",
"/tmp/web-scripts/checkout.chunk.js",
],
output_directory: "/tmp/recovered-chunks",
},
},
],
},
] as const satisfies readonly ToolContract[];
@@ -33,7 +33,7 @@ export const inspectElectronBrowserNode = (
context: JavaScriptFindingContext,
): void => {
if (t.isNewExpression(node)) inspectBrowserWindow(node, context);
if (!t.isCallExpression(node)) return;
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
inspectContextBridge(node, context);
inspectUtilityProcess(node, context);
};
@@ -162,7 +162,7 @@ const collectWebPreferences = (
};
const inspectContextBridge = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -208,7 +208,7 @@ const inspectContextBridge = (
};
const inspectUtilityProcess = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -0,0 +1,76 @@
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)) {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
inspectIpcCall(node, context);
inspectValidationCall(node, context);
}
@@ -34,7 +34,7 @@ export const inspectElectronIpcNode = (
};
const inspectIpcCall = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
@@ -117,7 +117,7 @@ const inspectValidationComparison = (
};
const inspectValidationCall = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
if (
@@ -225,7 +225,8 @@ const ipcDescriptor = (
const senderSubject = (
node: t.Node,
): ElectronSenderValidationFinding["subject"] | undefined => {
const name = t.isCallExpression(node)
const name =
t.isCallExpression(node) || t.isOptionalCallExpression(node)
? calleeName(node.callee)
: calleeName(node);
if (/(?:^|\.)sender\.getURL$/u.test(name)) return "sender-url";
@@ -12,6 +12,16 @@ 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.";
+15 -5
View File
@@ -3,11 +3,18 @@ import { digestSchema } from "../digests.js";
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
import { localPathStringSchema } from "../localPath.js";
/** Recover derived readable modules from one explicitly selected local script. */
/** Recover derived readable modules from explicitly selected local scripts. */
export const javascriptRecoveryInputSchema = z.strictObject({
path: localPathStringSchema.describe(
"Absolute path to a UTF-8 JavaScript file; never executed",
),
additional_paths: z
.array(localPathStringSchema)
.max(16)
.optional()
.describe(
"Additional absolute UTF-8 JavaScript files recovered together with path; each is snapshotted separately so imports can resolve across related chunks. All inputs share a 64 MiB total budget",
),
output_directory: localPathStringSchema.describe(
"Absolute absent output directory; parent must exist",
),
@@ -36,12 +43,15 @@ const rangeSchema = z.strictObject({
end: z.number().int().nonnegative(),
});
/** Complete published artifacts and exact producer provenance for source recovery. */
export const javascriptRecoveryResultSchema = z.strictObject({
source: fileSchema.extend({
const sourceSchema = fileSchema.extend({
snapshot_path: z.string().min(1),
published_copy: fileSchema,
}),
});
/** Complete published artifacts and exact producer provenance for source recovery. */
export const javascriptRecoveryResultSchema = z.strictObject({
source: sourceSchema,
additional_sources: z.array(sourceSchema).optional(),
engine: z.strictObject({
id: z.string().min(1),
version: z.string().min(1),
@@ -0,0 +1,329 @@
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",
},
]);
});
@@ -0,0 +1,322 @@
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,6 +47,7 @@ import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDeri
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
import {
collectElectronMemberWritesSteps,
electronExportPath,
electronMemberPath,
ELECTRON_MODULE,
} from "./javascriptElectronMemberWrites.js";
@@ -59,6 +60,8 @@ import {
type ParsedJavaScriptSource,
} from "./javascriptSourceParser.js";
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
interface BindPatternInput {
readonly pattern: t.Node;
readonly initializer: t.Node | null;
@@ -181,6 +184,40 @@ 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"
@@ -349,7 +386,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
}
const origin = semanticRequireOrigin(root, state);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? origin.importedPath
? electronExportPath(origin.importedPath)
: undefined;
};
const writes = yield* collectElectronMemberWritesSteps(
@@ -400,7 +437,7 @@ const electronExport = (
? provenance.origins[0]
: undefined);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? origin.importedPath
? electronExportPath(origin.importedPath)
: null;
};
@@ -23,6 +23,7 @@ import {
inspectCall,
inspectRouteProperty,
inspectRoleProperty,
inspectRpcNode,
} from "./javascriptStaticAnalysisCalls.js";
import {
inspectBundlerRegistration,
@@ -37,6 +38,7 @@ import {
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
import {
classifyParsedJavaScriptElectronBindingsSteps,
classifyParsedJavaScriptRpcClientRootsSteps,
classifyParsedJavaScriptOpenReceivers,
type JavaScriptOpenReceiverFact,
} from "./javascriptSemanticAnalysis.js";
@@ -45,6 +47,8 @@ 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,
@@ -78,11 +82,13 @@ 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);
@@ -223,4 +229,5 @@ const inspectNode = (
inspectRouteProperty(node, findings);
inspectRoleProperty(node, findings);
}
inspectRpcNode(node, findings);
};
@@ -201,6 +201,13 @@ 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,4 +1,5 @@
import type * as t from "@babel/types";
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import type {
JavaScriptBundlerRegistration,
@@ -57,6 +58,7 @@ 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<
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import type { NativeAotParserLimits } from "./nativeAotPe.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
parseNativeAotPe,
} from "./nativeAotPe.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
} from "../../../tests/fixtures/nativeaotPe.js";
const REPORT_BUDGET = NATIVE_AOT_MAX_REPORT_BYTES;
describe("NativeAOT parser resource budgets", () => {
it("reports source-cap rejection without hashing or inventing omitted-byte coverage", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const limits: NativeAotParserLimits = {
sourceBytes: bytes.length - 1,
workUnits: 1024,
workingMemoryBytes: 1024,
};
const report = parseNativeAotPe(
bytes,
sha,
REPORT_BUDGET,
undefined,
limits,
).summary;
expect(report).toMatchObject({
status: "partial",
analysis_artifact_sha256: sha,
header_address: null,
coverage: {
source_bytes_examined: 0,
source_byte_budget_bytes: bytes.length - 1,
truncation_reason: "source-byte-budget",
},
});
expect(report.coverage.source_bytes_omitted).toBeUndefined();
});
});
describe("NativeAOT parser report admission", () => {
it("never returns an over-budget report from early exits", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
expect(() =>
parseNativeAotPe(bytes, sha, 0, undefined, {
sourceBytes: bytes.length - 1,
workUnits: 1024,
workingMemoryBytes: 1024,
}),
).toThrow(/minimum truthful report/iu);
const nonPe = Buffer.from("not a PE image");
expect(() => parseNativeAotPe(nonPe, nativeAotPeDigest(nonPe), 0)).toThrow(
/minimum truthful report/iu,
);
});
it("does not claim a missing RTR header when signature coverage hits the work limit", () => {
const bytes = nativeAotPeFixture();
const limits: NativeAotParserLimits = {
sourceBytes: bytes.length,
workUnits: 8,
workingMemoryBytes: 1024,
};
const report = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
REPORT_BUDGET,
undefined,
limits,
).summary;
expect(report).toMatchObject({
status: "partial",
header_address: null,
truncated: true,
coverage: {
rtr_signature_scan_bytes_examined: 8,
rtr_signature_scan_bytes_omitted: expect.any(Number),
truncation_reason: "work-unit-budget",
},
});
expect(report.reason).not.toMatch(/No supported ReadyToRun directory/iu);
});
it("does not claim a missing root when table discovery is incomplete", () => {
const bytes = nativeAotPeFixture();
const report = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
REPORT_BUDGET,
undefined,
{
sourceBytes: bytes.length,
workUnits: 10_000,
workingMemoryBytes: 1024,
},
).summary;
expect(report.truncated).toBe(true);
expect(report.reason).toMatch(/before determining whether.*root exists/iu);
expect(report.reason).not.toMatch(
/no unique System\.Object.*fully examined/iu,
);
});
});
describe("NativeAOT detail and summary byte accounting", () => {
it("admits serialized MethodTable memory descriptors before emitting them", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const detail = (budget: number) =>
parseNativeAotPe(bytes, sha, REPORT_BUDGET).readTypeDetail(
"0x140002280",
budget,
);
const complete = detail(REPORT_BUDGET);
expect(complete?.derived_memory).not.toBeNull();
let low = 0;
let high = Buffer.byteLength(JSON.stringify(complete));
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (detail(middle)?.derived_memory != null) high = middle;
else low = middle + 1;
}
const atBoundary = detail(low);
const belowBoundary = detail(low - 1);
expect(atBoundary?.derived_memory).not.toBeNull();
expect(Buffer.byteLength(JSON.stringify(atBoundary))).toBeLessThanOrEqual(
low,
);
expect(belowBoundary?.derived_memory ?? null).toBeNull();
expect(belowBoundary?.diagnostics.join(" ")).toContain(
"omitted because the final serialized report exceeded its byte budget",
);
expect(
belowBoundary === undefined
? 0
: Buffer.byteLength(JSON.stringify(belowBoundary)),
).toBeLessThanOrEqual(low - 1);
});
it("truncates report rows before exceeding its JSON byte budget", () => {
const bytes = nativeAotPeFixture();
const sha = nativeAotPeDigest(bytes);
const complete = parseNativeAotPe(bytes, sha, REPORT_BUDGET);
const fullBytes = Buffer.byteLength(JSON.stringify(complete.summary));
let budget = fullBytes - 1;
let summary: ReturnType<typeof parseNativeAotPe>["summary"] | undefined;
while (budget > 0) {
try {
const candidate = parseNativeAotPe(bytes, sha, budget);
if (candidate.summary.truncated) {
summary = candidate.summary;
break;
}
} catch {
// Budgets below the fixed report header cannot carry an honest summary.
}
budget--;
}
if (summary === undefined)
throw new Error("No partial summary budget found");
expect(summary.truncated).toBe(true);
expect(
(summary.types_omitted ?? 0) +
(summary.frozen_strings_omitted ?? 0) +
(summary.coverage.scan_bytes_omitted ?? 0) +
(summary.coverage.frozen_scan_bytes_omitted ?? 0),
).toBeGreaterThan(0);
expect(Buffer.byteLength(JSON.stringify(summary))).toBeLessThanOrEqual(
budget,
);
});
});
+105
View File
@@ -0,0 +1,105 @@
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
+81 -7
View File
@@ -6,6 +6,8 @@ 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(),
@@ -25,19 +27,29 @@ export const nativeMetadataRecoverySchema = z.strictObject({
basis: z.literal("method-table-pointer"),
}),
),
derived_memory: z.strictObject({
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(),
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),
@@ -47,14 +59,76 @@ export const nativeMetadataRecoverySummarySchema = z.strictObject({
format_minor: z.number().int().nonnegative().nullable(),
method_tables: z.number().int().nonnegative(),
types: z.array(relatedType),
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory.nullable(),
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,
coverage: z
.strictObject({
frozen_object_candidates: z.number().int().nonnegative(),
frozen_objects_annotated: z.number().int().nonnegative(),
basis: z.literal(
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([
"rehydrated-pointer-candidates-and-committed-instance-types",
),
"raw-image-pointer-scan",
"raw-image-and-rehydrated-pointer-scan",
"unsupported-layout",
"no-rtr-header",
]),
})
.nullable(),
diagnostics: z.array(z.string()),
+53
View File
@@ -603,3 +603,56 @@ 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");
});
+23 -2
View File
@@ -5,8 +5,14 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascript/javascriptSemanticTraversal.js";
import {
classifyParsedJavaScriptOpenReceivers,
classifyParsedJavaScriptRpcClientRootsSteps,
} from "./javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
import {
@@ -22,6 +28,7 @@ import {
resolveSpecifier,
isUrlLikeModuleSpecifier,
} from "./webBundleAnalyzerAst.js";
import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js";
type BundleObservations = WebBundleAnalysis["observations"];
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
@@ -66,10 +73,24 @@ export const analyzeScript = (
accumulator.parsedScripts += 1;
detectVendorFingerprints(script, accumulator);
const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file);
const rpc = createJavaScriptRpcScan(
script.source.artifact.text,
completeSemanticSteps(classifyParsedJavaScriptRpcClientRootsSteps(file)),
);
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator, openReceiverFacts);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: endpoint.value,
mechanism: endpoint.mechanism,
node,
accumulator,
});
},
});
};
+14
View File
@@ -17,6 +17,13 @@ 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 = (
@@ -69,6 +76,13 @@ 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,
+95 -3
View File
@@ -43,6 +43,21 @@ 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,
@@ -79,6 +94,7 @@ 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.
@@ -227,7 +243,10 @@ export class GhidraClient {
}
/** Read the private immutable source for independent import verification. */
async readTargetSnapshot(): Promise<Result<Buffer, GhidraSessionError>> {
async readTargetSnapshot(
maximumBytes?: number,
signal?: AbortSignal,
): Promise<Result<GhidraTargetSnapshot, GhidraSessionError>> {
if (this.#snapshotPath === undefined)
return err(
this.#failure(
@@ -236,11 +255,80 @@ export class GhidraClient {
),
);
try {
return ok(await readFile(this.#snapshotPath));
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),
});
} catch (cause: unknown) {
return err(
this.#failure(
"protocol",
signal?.aborted === true ? "cancelled" : "protocol",
"Ghidra target snapshot could not be read for load-image verification",
cause,
),
@@ -602,6 +690,10 @@ 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) {
+87 -9
View File
@@ -1,4 +1,5 @@
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";
@@ -12,23 +13,91 @@ 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?: () => Promise<
Result<Buffer, GhidraSessionError>
>;
readonly readTargetSnapshot?: (
maximumBytes?: number,
signal?: AbortSignal,
) => Promise<Result<GhidraTargetSnapshot, 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 (
target: BinaryTarget,
operation: AnalysisOperation,
measured: JsonValue,
client: LoadImageSnapshotClient,
mapSessionError: (failure: GhidraSessionError) => AnalysisError,
input: NativeLoadImageAttestationInput,
): 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(
@@ -55,11 +124,20 @@ 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, target.sha256 ?? "", observations);
)(snapshot.value.bytes, target.sha256 ?? "", observations);
if (!attested.ok)
return err(
new ProviderAdapterError("ghidra", operation, {
+522 -1
View File
@@ -10,16 +10,22 @@ 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";
@@ -219,10 +225,25 @@ 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", () => {
@@ -717,7 +738,8 @@ describe("Ghidra measured load-image projection", () => {
...(state === "missing-snapshot"
? {}
: {
readTargetSnapshot: () => Promise.resolve(ok(fixture.bytes)),
readTargetSnapshot: () =>
Promise.resolve(ok({ kind: "captured", bytes: fixture.bytes })),
}),
});
const ghidra = provider(installationHost(), factory);
@@ -757,6 +779,505 @@ 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",
+337 -6
View File
@@ -8,7 +8,6 @@ import {
type AnalysisOperation,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config/types.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ghidraSeedCommitmentSchema,
ghidraSeedFailure,
@@ -30,6 +29,7 @@ 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,6 +52,18 @@ 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,
@@ -77,6 +89,250 @@ 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;
@@ -258,6 +514,14 @@ 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);
@@ -334,19 +598,83 @@ 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") {
const attested = await attestGhidraNativeLoadImage(
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({
target,
operation,
normalized,
measured: normalized,
client,
(failure) =>
mapSessionError: (failure) =>
projectSessionError(
operation,
failure,
config.ghidraStartupTimeoutMs,
),
);
nativeAotResult: derived,
signal: options?.signal,
});
if (!attested.ok) return attested;
normalized = attested.value;
}
@@ -368,7 +696,10 @@ export const createGhidraProviderClient = (input: {
? []
: [{ provider: committedProfile.provider, observation }];
},
close: () => client.close(),
close: async () => {
nativeAotOwner?.close();
return client.close();
},
};
};
+80 -6
View File
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, opendir, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { lstat, mkdir, open, opendir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { TextDecoder } from "node:util";
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
import {
AnalysisCancelledError,
AnalysisInputError,
@@ -89,12 +90,22 @@ export const readRecoveryFile = async (
return Buffer.concat(chunks, result.bytes);
};
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
export const snapshotRecoveryInput = async (
export type RecoveryInputSnapshot = {
path: string;
snapshot_path: string;
relative_path: string;
sha256: string;
bytes: number;
};
/** Snapshot one script within the remaining operation-wide input budget. */
const snapshotRecoveryInput = async (
path: string,
snapshotPath: string,
relativePath: string,
maximum: number,
signal?: AbortSignal,
) => {
): Promise<RecoveryInputSnapshot> => {
if (!isAbsolute(path))
throw recoveryInputFailure(
"path",
@@ -102,7 +113,7 @@ export const snapshotRecoveryInput = async (
);
let bytes: Buffer;
try {
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
bytes = await readRecoveryFile(path, maximum, signal);
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
@@ -112,15 +123,78 @@ export const snapshotRecoveryInput = async (
cause,
);
}
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
return {
path,
snapshot_path: snapshotPath,
relative_path: relativePath,
sha256: recoveryDigest(bytes),
bytes: bytes.length,
};
};
/** Preserve each selected file and its relative input layout without concatenation. */
export const snapshotRecoveryInputs = async (
paths: readonly [string, ...string[]],
directory: string,
signal?: AbortSignal,
): Promise<readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]]> => {
let commonDirectory = dirname(resolve(paths[0]));
for (const path of paths) {
while (true) {
const fromCommon = relative(commonDirectory, resolve(path));
if (
fromCommon !== ".." &&
!fromCommon.startsWith(`..${sep}`) &&
!isAbsolute(fromCommon)
)
break;
const parent = dirname(commonDirectory);
if (parent === commonDirectory)
throw recoveryInputFailure(
"additional_paths",
"Selected files must share a filesystem root",
);
commonDirectory = parent;
}
}
const snapshots: RecoveryInputSnapshot[] = [];
let remaining = RECOVERY_LIMITS.inputBytes;
for (const path of paths) {
const relativePath =
paths.length === 1
? "bundle.js"
: relative(commonDirectory, resolve(path)).split(sep).join("/");
try {
if (normalizeArtifactPath(relativePath) !== relativePath)
throw new TypeError(
"Input names must already use NFC Unicode spelling",
);
} catch (cause: unknown) {
throw recoveryInputFailure(
"path",
`Cannot preserve selected input path in published artifacts: ${path}: ${recoveryFailureMessage(cause)}`,
cause,
);
}
const snapshot = await snapshotRecoveryInput(
path,
join(directory, relativePath),
relativePath,
remaining,
signal,
);
snapshots.push(snapshot);
remaining -= snapshot.bytes;
}
// The selected-input tuple guarantees a primary snapshot.
const [primary, ...additional] = snapshots;
if (primary === undefined)
throw recoveryInputFailure("path", "Select a JavaScript input");
return [primary, ...additional];
};
/** Inventory staging without following symlinks or publishing special files. */
export const inventoryRecoveryFiles = async (
root: string,
+3 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, realpath } from "node:fs/promises";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import {
AnalysisCancelledError,
@@ -87,6 +87,8 @@ export const resolveWakaruCommand = async (
if (!isAbsolute(limiter))
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
await access(limiter, constants.X_OK);
if (!(await stat(limiter)).isFile())
throw new TypeError(`Resource limiter is not a regular file: ${limiter}`);
return { command, limiter, ...fingerprint };
} catch (cause: unknown) {
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
+26 -20
View File
@@ -14,8 +14,7 @@ import {
fingerprintRecoveryFile,
inventoryRecoveryFiles,
readRecoveryFile,
recoveryDigest,
type snapshotRecoveryInput,
type snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
runWakaruCommand,
@@ -38,7 +37,7 @@ type RecoveryWorkspace = {
root: string;
tree: SafeOutputTree;
input: JavaScriptRecoveryInput;
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
sources: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
environment: Readonly<Record<string, string | undefined>>;
launcher: WakaruLauncher | undefined;
@@ -48,7 +47,7 @@ type RecoveryWorkspace = {
/** Consume the pinned producer representation in a private workspace. */
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
const { root, input, source, engine, environment, launcher, options } =
const { root, input, sources, engine, environment, launcher, options } =
context;
const staging = join(root, "modules");
const commandContext = {
@@ -81,7 +80,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
...commandContext,
staging,
arguments: [
source.snapshot_path,
...sources.map((source) => source.snapshot_path),
`--unpack=${MODES[input.extraction_mode]}`,
`--level=${input.rewrite_level}`,
"--provenance",
@@ -92,16 +91,20 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
],
});
const files = await inventoryRecoveryFiles(staging, options?.signal);
const snapshot = await readRecoveryFile(
const snapshots = new Map<string, Buffer>();
for (const source of sources)
snapshots.set(
source.snapshot_path,
RECOVERY_LIMITS.inputBytes,
await readRecoveryFile(
source.snapshot_path,
source.bytes,
options?.signal,
),
);
const parsed = await parseWakaruReports({
execution: run,
provenancePath: join(staging, "provenance.json"),
snapshotPath: source.snapshot_path,
sourceBytes: snapshot,
snapshots,
files,
});
return { version, admitted, run, parsed };
@@ -109,7 +112,8 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
/** Publish validated recovery and retain exact source and engine bindings. */
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
const { root, tree, input, source, engine, options } = context;
const { root, tree, input, sources, engine, options } = context;
const [source] = sources;
const staging = join(root, "modules");
const { version, admitted, run, parsed } =
await executeWakaruRecovery(context);
@@ -117,26 +121,25 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
tree,
staging,
input,
source,
sources,
engine,
admitted,
parsed,
execution: run,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
if (
recoveryDigest(
await readRecoveryFile(
input.path,
for (const selected of sources) {
const current = await fingerprintRecoveryFile(
selected.path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
),
) !== source.sha256
)
);
if (current.sha256 !== selected.sha256 || current.bytes !== selected.bytes)
throw new AnalysisOutputError(
OPERATION,
`Original input changed during recovery: ${input.path}`,
`Original input changed during recovery: ${selected.path}`,
);
}
if (
(
await fingerprintRecoveryFile(
@@ -166,7 +169,10 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
format: "javascript",
},
locations: [
{ kind: "artifact-path", path: source.path },
...sources.map((selected) => ({
kind: "artifact-path" as const,
path: selected.path,
})),
...result.modules.map((module) => ({
kind: "artifact-path" as const,
path: module.artifact.path,
+6 -6
View File
@@ -22,7 +22,7 @@ import {
checkRecoveryDeadline,
recoveryFailureMessage,
recoveryInputFailure,
snapshotRecoveryInput,
snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
resolveWakaruCommand,
@@ -55,7 +55,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
readonly launcher?: WakaruLauncher,
) {}
/** Recover one script using validated upstream reports and verified output bytes. */
/** Recover selected scripts using validated reports and verified output bytes. */
recover(
input: JavaScriptRecoveryInput,
options?: ExecutionOptions,
@@ -109,9 +109,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
const staging = join(root, "modules");
await mkdir(inputs, { mode: 0o700 });
await mkdir(staging, { mode: 0o700 });
const source = await snapshotRecoveryInput(
input.path,
join(inputs, "bundle.js"),
const sources = await snapshotRecoveryInputs(
[input.path, ...(input.additional_paths ?? [])],
inputs,
options?.signal,
);
try {
@@ -128,7 +128,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
root,
tree,
input,
source,
sources,
engine,
environment: this.environment,
launcher: this.launcher,
+33 -15
View File
@@ -7,7 +7,11 @@ import {
type JavaScriptRecoveryInput,
type JavaScriptRecoveryResult,
} from "../../domain/javascript/javascriptRecovery.js";
import { readRecoveryFile, recoveryDigest } from "./RecoveryFiles.js";
import {
readRecoveryFile,
recoveryDigest,
type RecoveryInputSnapshot,
} from "./RecoveryFiles.js";
import {
recoveryRanges,
validateRecoverySourceMap,
@@ -28,12 +32,7 @@ type PublicationContext = {
tree: SafeOutputTree;
staging: string;
input: JavaScriptRecoveryInput;
source: {
path: string;
snapshot_path: string;
sha256: string;
bytes: number;
};
sources: readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]];
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
@@ -47,7 +46,7 @@ type RecoveryWriter = (path: string, bytes: Buffer) => Promise<PublishedFile>;
export const publishWakaruArtifacts = async (
context: PublicationContext,
): Promise<JavaScriptRecoveryResult> => {
const { tree, source, parsed, staging, signal } = context;
const { tree, sources, parsed, staging, signal } = context;
const write = async (path: string, bytes: Buffer) => {
const sha256 = recoveryDigest(bytes);
const file = await tree.write(
@@ -62,17 +61,25 @@ export const publishWakaruArtifacts = async (
bytes: file.bytesWritten,
};
};
const publishedSources: JavaScriptRecoveryResult["source"][] = [];
for (const selected of sources) {
const { relative_path, ...source } = selected;
const snapshot = await readRecoveryFile(
source.snapshot_path,
RECOVERY_LIMITS.inputBytes,
source.bytes,
signal,
);
if (recoveryDigest(snapshot) !== source.sha256)
throw new AnalysisOutputError(
"recover_javascript_sources",
"The input snapshot changed during recovery",
`The input snapshot changed during recovery: ${source.path}`,
);
const publishedCopy = await write("inputs/bundle.js", snapshot);
publishedSources.push({
...source,
published_copy: await write(`inputs/${relative_path}`, snapshot),
});
}
const [source, ...additionalSources] = publishedSources;
const modules: JavaScriptRecoveryResult["modules"] = [];
for (const module of parsed.report.modules) {
modules.push(await publishModule(context, module, write));
@@ -90,7 +97,10 @@ export const publishWakaruArtifacts = async (
),
);
const details = {
source: { ...source, published_copy: publishedCopy },
source,
...(additionalSources.length === 0
? {}
: { additional_sources: additionalSources }),
engine: {
id: "wakaru",
version: context.admitted.version,
@@ -151,7 +161,7 @@ const publishModule = async (
module: PublicationContext["parsed"]["report"]["modules"][number],
write: RecoveryWriter,
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
const { staging, source, parsed, signal } = context;
const { staging, sources, parsed, signal } = context;
const artifact = await write(
`modules/${module.filename}`,
await readRecoveryFile(
@@ -182,6 +192,14 @@ const publishModule = async (
"recover_javascript_sources",
`Missing module provenance: ${module.filename}`,
);
const origin = sources.find(
(source) => source.snapshot_path === provenance.input,
);
if (origin === undefined)
throw new AnalysisOutputError(
"recover_javascript_sources",
`Unselected module provenance input: ${provenance.input}`,
);
return {
reported_filename: module.filename,
reported_status: module.status,
@@ -189,8 +207,8 @@ const publishModule = async (
source_map: sourceMap,
provenance: {
reported_input: provenance.input,
original_path: source.path,
original_sha256: source.sha256,
original_path: origin.path,
original_sha256: origin.sha256,
extraction: provenance.extraction,
byte_ranges: recoveryRanges(provenance.ranges),
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
+1 -1
View File
@@ -36,5 +36,5 @@ export const RECOVERY_LIMITATIONS = [
"Extraction ranges refer to half-open UTF-8 byte offsets in the original input, not rewritten line positions or whole-bundle coverage.",
"Emitted source maps are preserved as reported by the engine; their mapping accuracy is not independently established.",
"Inspection mode may produce non-executable regions. No input or recovered application code is executed.",
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB total input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
] as const;
+5 -4
View File
@@ -54,8 +54,7 @@ const OPERATION = "recover_javascript_sources";
type ReportContext = {
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
provenancePath: string;
snapshotPath: string;
sourceBytes: Buffer;
snapshots: ReadonlyMap<string, Buffer>;
files: readonly { relativePath: string }[];
};
@@ -121,7 +120,7 @@ const validateModuleSet = (
provenance: z.output<typeof provenanceSchema>,
context: ReportContext,
): void => {
const { files, snapshotPath, sourceBytes } = context;
const { files, snapshots } = context;
const registry = new ArtifactPathRegistry();
const expected = new Set(["provenance.json"]);
const observed = new Set(files.map((file) => file.relativePath));
@@ -135,7 +134,9 @@ const validateModuleSet = (
expected.add(path);
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
const entry = provenance.modules[path];
if (entry === undefined || entry.input !== snapshotPath)
const sourceBytes =
entry === undefined ? undefined : snapshots.get(entry.input);
if (entry === undefined || sourceBytes === undefined)
throw new TypeError(
`Module provenance does not bind the selected input snapshot: ${path}`,
);
+11 -5
View File
@@ -28,6 +28,11 @@ interface Section {
readonly size: number;
}
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.size : section.virtualSize;
/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */
export const readPeResourceLayout = (bytes: Buffer) => {
requirePeRange(bytes, 0, 64);
@@ -64,7 +69,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
offset: bytes.readUInt32LE(at + 20),
size: bytes.readUInt32LE(at + 16),
};
if (section.rva + Math.max(section.virtualSize, section.size) > 0x100000000)
if (section.rva + virtualExtent(section) > 0x100000000)
peFailure("PE section RVA range overflows.");
if (section.size > 0) {
requirePeRange(bytes, section.offset, section.size);
@@ -90,15 +95,14 @@ export const readPeResourceLayout = (bytes: Buffer) => {
if (rva + size > 0x100000000) peFailure("Resource RVA range overflows.");
const candidates = sections.filter(
(section) =>
rva >= section.rva &&
rva - section.rva < Math.max(section.size, section.virtualSize),
rva >= section.rva && rva - section.rva < virtualExtent(section),
);
if (rva < headerSize) {
if (
sections.some(
(section) =>
section.rva < rva + size &&
section.rva + Math.max(section.size, section.virtualSize) > rva,
section.rva + virtualExtent(section) > rva,
) ||
size > headerSize - rva
)
@@ -111,6 +115,8 @@ export const readPeResourceLayout = (bytes: Buffer) => {
const section = candidates[0];
if (section === undefined) return peFailure("Missing resource section.");
const within = rva - section.rva;
if (size > virtualExtent(section) - within)
peFailure("Resource range leaves its section's virtual size.");
if (within > section.size || size > section.size - within)
peFailure("Resource range is not entirely file-backed in one section.");
// A second section starting inside the requested interval is ambiguous too.
@@ -119,7 +125,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
(other) =>
other !== section &&
other.rva < rva + size &&
other.rva + Math.max(other.size, other.virtualSize) > rva,
other.rva + virtualExtent(other) > rva,
)
)
peFailure("Resource interval intersects overlapping virtual sections.");
+59
View File
@@ -259,3 +259,62 @@ describe("PE header admission and bounded traversal", () => {
await expect(parsing).rejects.toThrow();
});
});
describe("PE resource section virtual extent", () => {
const sectionSizes = (fixture: ReturnType<typeof peResourceFixture>) => {
const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8);
const rawSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 16);
expect(rawSize - virtualSize).toBeGreaterThanOrEqual(8);
return { virtualSize, rawSize };
};
it.each([
[0, "Resource RVA has missing or overlapping section mappings."],
[-2, "Resource range leaves its section's virtual size."],
])(
"rejects a payload reaching raw alignment padding past VirtualSize (start %i)",
async (start, message) => {
const fixture = peResourceFixture();
const { virtualSize } = sectionSizes(fixture);
const rva = 0x2000 + virtualSize + start;
fixture.bytes.writeUInt32LE(rva, fixture.dataOffsets[0]);
fixture.bytes.writeUInt32LE(4, (fixture.dataOffsets[0] ?? 0) + 4);
await expect(parse(fixture.bytes)).rejects.toMatchObject({
reason: "format",
message,
});
},
);
it("maps raw bytes when VirtualSize is zero, as the image loader does", async () => {
const fixture = peResourceFixture();
fixture.bytes.writeUInt32LE(0, fixture.sectionAt + 8);
expect(await parse(fixture.bytes)).toMatchObject({
coverage: { status: "complete", resources: 5 },
});
});
it("selects a later section that starts inside earlier raw padding", async () => {
const fixture = peResourceFixture();
const { virtualSize } = sectionSizes(fixture);
const laterRva = 0x2000 + Math.ceil(virtualSize / 4) * 4;
const rawOffset = fixture.bytes.length;
const payload = Buffer.from("LATE");
const bytes = Buffer.concat([fixture.bytes, payload]);
bytes.writeUInt16LE(2, 0x86);
const later = fixture.sectionAt + 40;
bytes.writeUInt32LE(payload.length, later + 8);
bytes.writeUInt32LE(laterRva, later + 12);
bytes.writeUInt32LE(payload.length, later + 16);
bytes.writeUInt32LE(rawOffset, later + 20);
bytes.writeUInt32LE(laterRva, fixture.dataOffsets[0]);
bytes.writeUInt32LE(payload.length, (fixture.dataOffsets[0] ?? 0) + 4);
const result = peResourcesSchema.parse(await parse(bytes));
expect(result.resources[0]?.payload).toMatchObject({
rva: laterRva,
location: { offset: rawOffset, bytes: payload.length },
sha256: createHash("sha256").update(payload).digest("hex"),
});
});
});
+54 -29
View File
@@ -5,7 +5,9 @@ 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";
@@ -37,14 +39,23 @@ export class PeResourcesProvider implements PeResourcesPort {
),
);
} catch (cause) {
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",
return err(peResourcesFailure(cause, input, options?.signal));
}
}
}
const peResourcesFailure = (
cause: unknown,
input: InspectPeResourcesInput,
signal: AbortSignal | undefined,
) => {
const operation = "inspect_pe_resources";
if (signal?.aborted) return new AnalysisCancelledError(operation, { cause });
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
operation,
"memory",
cause.message,
{
@@ -52,32 +63,46 @@ export class PeResourcesProvider implements PeResourcesPort {
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",
if (cause.reason === "integrity")
return new AnalysisArtifactChangedError(
operation,
input.path,
cause.code,
cause.message,
{ cause },
),
);
return err(
new ArtifactOperationError(
"inspect_pe_resources",
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: cause.message,
},
]);
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, { cause });
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(operation, input.path, cause.code, {
cause,
});
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected PE image could not be read (${cause.code}): ${input.path}.`,
},
]);
}
return new ArtifactOperationError(
operation,
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
undefined,
cause instanceof Error
? cause.message
: "PE artifact inspection failed.",
cause instanceof Error ? cause.message : "PE artifact inspection failed.",
{ cause },
),
);
}
}
}
};
+27 -1
View File
@@ -1,13 +1,22 @@
import type { FileHandle } from "node:fs/promises";
/** Read through EOF, returning undefined on overflow; consume at most maxBytes + 1 bytes without closing the handle. */
/** Read through EOF, returning undefined on overflow; leave the handle open. */
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;
@@ -24,4 +33,21 @@ export const readBoundedFileBytes = async (
if (total > maxBytes) return undefined;
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
}
}
const buffer = Buffer.allocUnsafe(expectedSize);
if (expectedSize === 0) return buffer;
let total = 0;
for (;;) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(
buffer,
total,
expectedSize - total,
null,
);
if (bytesRead === 0) return buffer.subarray(0, total);
total += bytesRead;
if (total > maxBytes) return undefined;
if (total === expectedSize) return buffer;
}
};
@@ -0,0 +1,120 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"keeps dependency manifests out of application roots and Electron entries through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-application-manifest-");
const dependency = join(root, "node_modules", "dep");
const nested = join(root, "a");
await Promise.all([mkdir(dependency, { recursive: true }), mkdir(nested)]);
await Promise.all([
writeFile(
join(nested, "package.json"),
JSON.stringify({ name: "nested" }),
),
writeFile(
join(root, "package.json"),
JSON.stringify({ name: "app", main: "main.js" }),
),
writeFile(
join(root, "main.js"),
"throw new Error('source must remain inert');",
),
writeFile(
join(dependency, "package.json"),
JSON.stringify({
name: "dep",
main: "index.js",
browser: "browser.js",
}),
),
writeFile(join(dependency, "index.js"), "module.exports = {};"),
writeFile(join(dependency, "browser.js"), "export const browser = true;"),
]);
let document: unknown;
if (surface === "CLI") {
const response = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(response.exitCode).toBe(0);
document = response.json;
} else {
const { call } = await connectLocalToolsMcp();
const response = await call("analyze_javascript_application", {
input_path: root,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
document = response.structuredContent;
}
const { graph } = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(document).normalized_result;
expect(
graph.nodes
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
.flatMap(({ observations }) => observations.map(({ label }) => label)),
).toEqual(["app"]);
expect(
graph.nodes
.filter(({ kind }) => kind === "electron-main")
.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.declared_path),
),
).toEqual(["main.js"]);
expect(
graph.nodes.filter(({ kind }) => kind === "electron-renderer"),
).toEqual([]);
expect(
graph.nodes
.filter(({ kind }) => kind === "package")
.flatMap(({ observations }) => observations.map(({ label }) => label))
.sort(),
).toEqual(["app", "dep", "nested"]);
},
);
cliTest(
"keeps the artifact root when only dependency manifests are present",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-dependency-only-");
const dependency = join(root, "node_modules", "dep");
await mkdir(dependency, { recursive: true });
await writeFile(
join(dependency, "package.json"),
JSON.stringify({ name: "dep", main: "index.js" }),
);
await writeFile(join(dependency, "index.js"), "module.exports = {};");
const response = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(response.exitCode, response.stderr).toBe(0);
const { graph } = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(response.json).normalized_result;
expect(
graph.nodes
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
.map(({ kind }) => kind),
).toEqual(["artifact"]);
expect(
graph.nodes
.filter(({ kind }) => kind === "package")
.flatMap(({ observations }) => observations.map(({ label }) => label)),
).toEqual(["dep"]);
expect(
graph.nodes.some(
({ kind }) => kind === "electron-main" || kind === "electron-renderer",
),
).toBe(false);
},
);
@@ -8,7 +8,36 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
const expectPeInputFailureAcrossAdapters = async (input: {
readonly cli: TestCli;
readonly client: Client;
readonly toolName: string;
readonly path: string;
readonly reason: "invalid_format" | "invalid_value";
}) => {
const { cli, client, toolName, path, reason } = input;
const result = await cli.run({
arguments: ["inspect-pe-resources", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
operation: "inspect_pe_resources",
issues: [{ path: ["path"], reason }],
},
});
const response = await client.callTool({
name: toolName,
arguments: { path },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response)).toEqual({ error: result.json });
};
cliTest(
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
@@ -92,6 +121,20 @@ cliTest(
).toBe(evidence.evidence_id);
expect(await readFile(path)).toEqual(fixture.bytes);
}
const malformedPath = join(root, "malformed.exe");
await writeFile(malformedPath, Buffer.from("not a PE image"));
for (const [path, reason] of [
[join(root, "missing.exe"), "invalid_value"],
[malformedPath, "invalid_format"],
] as const) {
await expectPeInputFailureAcrossAdapters({
cli,
client,
toolName: contract.name,
path,
reason,
});
}
const absent = peResourceFixture();
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
const path = join(root, "absent.exe");
@@ -2,8 +2,10 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
access,
chmod,
mkdir,
mkdtemp,
rm,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
@@ -11,6 +13,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { HistoricalCaptureDecoder } from "../../../src/browser/history/HistoricalCaptureDecoder.js";
import { MitmproxyCaptureAdapter } from "../../../src/browser/history/MitmproxyCaptureAdapter.js";
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../../src/browser/history/CaptureRelease.js";
import {
inspectWebNetworkCaptureInputSchema,
@@ -20,6 +23,41 @@ import { historicalHar } from "../../fixtures/historicalHar.js";
import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const linux = it.skipIf(process.platform !== "linux");
linux("refuses a directory configured as the mitmdump executable", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "mitmdump");
await mkdir(command);
await expect(
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
join(root, "request.json"),
join(root, "runtime"),
),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining(command),
});
});
linux("allows a symlink to a regular mitmdump executable", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-symlink-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "mitmdump");
const target = join(root, "mitmdump-bin");
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
await symlink(target, command);
await expect(
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
join(root, "request.json"),
join(root, "runtime"),
),
).resolves.toMatchObject({ command });
});
it.each([true, false])(
"binds decoder replies to the complete selected provider identity (matching name: %s)",
async (matching) => {
@@ -368,6 +368,30 @@ 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]);
+21 -3
View File
@@ -6,17 +6,35 @@ 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 without inventing empty inventory", async () => {
it("reports non-file and malformed PE selections as caller input errors", async () => {
const root = await createTestTempDirectory("rea-pe-resource-files-");
const path = join(root, "image.exe");
const alias = join(root, "alias.exe");
const malformed = join(root, "malformed.exe");
await writeFile(path, peResourceFixture().bytes);
await writeFile(malformed, Buffer.from("not a PE image"));
await symlink(path, alias);
const service = createPeResourcesService();
for (const candidate of [root, alias, join(root, "missing.exe")]) {
for (const [candidate, reason] of [
[root, "invalid_format"],
[alias, "invalid_format"],
[malformed, "invalid_format"],
[join(root, "missing.exe"), "invalid_value"],
[join(path, "child.exe"), "invalid_value"],
] as const) {
const result = await service.inspect({ path: candidate });
if (result.ok) throw new Error("Expected file acquisition failure");
expect(result.error._tag).toBe("ArtifactOperationError");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
operation: "inspect_pe_resources",
issues: [
{
path: ["path"],
reason,
message: expect.any(String),
},
],
});
}
});
@@ -0,0 +1,47 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { resolveWakaruCommand } from "../../../src/javascript/recovery/WakaruCommand.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const linuxX64 = it.skipIf(
process.platform !== "linux" || process.arch !== "x64",
);
linuxX64(
"refuses a directory configured as the Wakaru resource limiter",
async () => {
const root = await createTestTempDirectory("rea-wakaru-limiter-");
const command = join(root, "wakaru");
const limiter = join(root, "prlimit");
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
await mkdir(limiter);
await expect(
resolveWakaruCommand({
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
}),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining(limiter),
});
},
);
linuxX64("allows a symlink to a regular Wakaru resource limiter", async () => {
const root = await createTestTempDirectory("rea-wakaru-limiter-symlink-");
const command = join(root, "wakaru");
const limiter = join(root, "prlimit");
const target = join(root, "prlimit-bin");
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
await symlink(target, limiter);
await expect(
resolveWakaruCommand({
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
}),
).resolves.toMatchObject({ limiter });
});
@@ -1,4 +1,4 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { access, mkdir, open, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
@@ -6,6 +6,7 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascript/javascriptRecovery.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { RECOVERY_LIMITS } from "../../../src/javascript/recovery/WakaruRelease.js";
import {
assertRecoveryCleanup,
recoveryFixture,
@@ -36,6 +37,7 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
},
});
expect(value.source.path).toBe(fixture.path);
expect(value.additional_sources).toBeUndefined();
expect(value.source.published_copy.sha256).toBe(value.source.sha256);
expect(value.modules[0]?.provenance.byte_ranges).toEqual([
{ start: 0, end: value.source.bytes },
@@ -396,3 +398,153 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
});
},
);
describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
"Wakaru multi-input ownership",
() => {
it.each(["cafe\u0301.js", "chunk\\name.js"])(
"rejects an unpublishable native filename %s before launching",
async (name) => {
const fixture = await recoveryFixture();
const additional = join(fixture.root, name);
await writeFile(additional, "export const value = 1;");
const result = await fixture.service.recover({
path: fixture.path,
additional_paths: [additional],
output_directory: fixture.output,
});
if (result.ok)
throw new Error("expected unsupported input-layout failure");
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
"Cannot preserve selected input path",
);
expect(fixture.launches).toHaveLength(0);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(additional, "utf8")).toBe(
"export const value = 1;",
);
},
);
it("keeps separate snapshots, per-file ranges and relative paths for same-named inputs", async () => {
const fixture = await recoveryFixture(
"normal",
"import './nested/selected.js';\n",
);
const nested = join(fixture.root, "nested");
await mkdir(nested);
const additional = join(nested, "selected.js");
const content = "export const target = '🎯';\n";
await writeFile(additional, content);
const result = await fixture.service.recover({
path: fixture.path,
additional_paths: [additional],
output_directory: fixture.output,
});
if (!result.ok) throw result.error;
const value = javascriptRecoveryResultSchema.parse(
result.value.normalized_result,
);
const sources = [value.source, ...(value.additional_sources ?? [])];
expect(sources.map((source) => source.path)).toEqual([
fixture.path,
additional,
]);
expect(sources.map((source) => source.published_copy.path)).toEqual([
join(fixture.output, "inputs/selected.js"),
join(fixture.output, "inputs/nested/selected.js"),
]);
expect(await readFile(sources[1]!.published_copy.path, "utf8")).toBe(
content,
);
expect(result.value.subject?.digest.sha256).toBe(value.source.sha256);
for (const [index, module] of value.modules.entries()) {
const source = sources[index]!;
expect(source.published_copy.sha256).toBe(source.sha256);
expect(module.provenance).toMatchObject({
reported_input: source.snapshot_path,
original_path: source.path,
original_sha256: source.sha256,
byte_ranges: [{ start: 0, end: source.bytes }],
context_byte_ranges: [{ start: 0, end: source.bytes }],
});
}
const manifest = JSON.parse(await readFile(value.manifest.path, "utf8"));
expect(manifest.additional_sources).toEqual(value.additional_sources);
await assertRecoveryCleanup(fixture.launches);
});
it.each([
"secondary-foreign",
"secondary-overflow",
"secondary-utf8",
"secondary-change",
"redistribute",
])("rejects %s against its own source and rolls back", async (mode) => {
const fixture = await recoveryFixture(mode, "a");
const additional = join(fixture.root, "chunk.js");
await writeFile(additional, mode === "secondary-utf8" ? "🎯" : "bc");
const result = await fixture.service.recover({
path: fixture.path,
additional_paths: [additional],
output_directory: fixture.output,
});
if (result.ok) throw new Error("expected per-source validation failure");
expect(projectAnalysisError(result.error).code).toMatch(
/execution_failure|unreadable_output/u,
);
const reason =
mode === "secondary-foreign"
? "does not bind the selected input snapshot"
: mode === "redistribute" || mode === "secondary-change"
? "Original input changed"
: "Invalid half-open UTF-8 byte range";
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
reason,
);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
await assertRecoveryCleanup(fixture.launches);
});
it("rejects aggregate input overflow before launching the engine", async () => {
const fixture = await recoveryFixture("normal", "a");
const additional = join(fixture.root, "chunk.js");
const handle = await open(additional, "wx");
try {
await handle.truncate(RECOVERY_LIMITS.inputBytes);
} finally {
await handle.close();
}
const result = await fixture.service.recover({
path: fixture.path,
additional_paths: [additional],
output_directory: fixture.output,
});
if (result.ok) throw new Error("expected aggregate capacity failure");
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
expect(fixture.launches).toHaveLength(0);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("rejects non-UTF-8 input before launching recovery", async () => {
const fixture = await recoveryFixture();
await writeFile(fixture.path, Buffer.from([0xff, 0xfe]));
const result = await fixture.service.recover({
path: fixture.path,
output_directory: fixture.output,
});
if (result.ok) throw new Error("expected malformed source failure");
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
expect(fixture.launches).toHaveLength(0);
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
});
});
},
);
@@ -1,4 +1,6 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
@@ -10,9 +12,11 @@ import {
recoveryFixture,
} from "../../fixtures/javascript-recovery/provider.js";
it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
"advertises and executes recovery through the named SDK contract without a binary target",
async () => {
it
.skipIf(process.platform !== "linux" || process.arch !== "x64")
.each([false, true])(
"advertises and executes recovery through the named SDK contract (multiple inputs: %s)",
async (multiple) => {
const fixture = await recoveryFixture();
const session = createTestBinarySession(() => {
throw new Error("no binary provider should start");
@@ -44,7 +48,13 @@ it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const args = { path: fixture.path, output_directory: fixture.output };
const additional = join(fixture.root, "chunk.js");
if (multiple) await writeFile(additional, "export const chunk = 1;");
const args = {
path: fixture.path,
output_directory: fixture.output,
...(multiple ? { additional_paths: [additional] } : {}),
};
expect(ajv.validate(inputSchema, args)).toBe(true);
const response = await client.callTool({
name: "recover_javascript_sources",
@@ -146,7 +146,7 @@ afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
describe("GhidraClient", () => {
describe("GhidraClient captured source snapshot", () => {
it("exposes the immutable import snapshot only during the live session", async () => {
const client = clientFor(new FixtureLauncher());
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
@@ -154,16 +154,43 @@ describe("GhidraClient", () => {
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);
if (snapshot.ok) expect(snapshot.value).toEqual(readFileSync(fixturePath));
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,
},
});
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);
+8
View File
@@ -39,3 +39,11 @@ bounded, and address-bearing; it never compares their text with Hopper. The
callback fixture also proves that an unresolved targetless flow is not silently
promoted to a direct callee. Every target runs in a separate owned project and
must leave no process, socket, project, or runtime root after close.
`relative-switch-signed.S` covers signed halfword offsets, the CBZ zero-entry
route, and selector biases of 0, 2 and 10 in the AArch64 jump-table lane. The
nonzero biases exercise overlapping and disjoint typed label/index ranges. REA
retains Ghidra's typed labels when the compared selector has an earlier register
definition; additional labels remain unknown until their relationship to the
typed switch selector can be proved. The oracle rejects extra numeric labels
and verifies each retained target against the source instruction immediate.
@@ -0,0 +1,83 @@
// Hand-written signed halfword relative dispatch, modelled on the AOSP
// RenderScript blend kernel shape reported in issue #487:
// adrp/add table; cmp; b.cs default; ldrsh; cbz null; adr base; add; br.
// Entries are signed byte offsets from Lsigned_base (no scaling), so cases
// before the base encode negative offsets. A zero entry means "no case body"
// and is routed through the CBZ before the branch. The source labels and the
// immediates below are the oracle; no REA or Ghidra result defines them.
// case 0 -> 200, 1 -> 201, 2 -> 202, 3 -> null path (250), 4 -> 204,
// selector >= 5 -> default (-1).
#ifndef REA_SELECTOR_BIAS
#define REA_SELECTOR_BIAS 0
#endif
// Nonzero variants switch on the original selector (bias .. bias+4),
// while the guarded table index is selector - bias.
#if defined(__APPLE__)
.section __TEXT,__text,regular,pure_instructions
.globl _rea_relative_switch_signed
.p2align 2
_rea_relative_switch_signed:
#else
.text
.globl rea_relative_switch_signed
.type rea_relative_switch_signed,%function
.p2align 2
rea_relative_switch_signed:
#endif
#if defined(__APPLE__)
adrp x5, Lsigned_table@PAGE
add x5, x5, Lsigned_table@PAGEOFF
#else
adrp x5, Lsigned_table
add x5, x5, :lo12:Lsigned_table
#endif
#if REA_SELECTOR_BIAS != 0
sub w8, w0, #REA_SELECTOR_BIAS
cmp w8, #5
b.cs Lsigned_default
ldrsh x6, [x5, w8, uxtw #1]
#else
cmp w0, #5
b.cs Lsigned_default
ldrsh x6, [x5, w0, uxtw #1]
#endif
cbz x6, Lsigned_null
adr x5, Lsigned_base
add x6, x5, x6
br x6
Lsigned_case0:
mov w0, #200
ret
Lsigned_case1:
mov w0, #201
// The base sits inside case 1 so that no real case has a zero offset.
Lsigned_base:
ret
Lsigned_case2:
mov w0, #202
ret
Lsigned_case4:
mov w0, #204
ret
Lsigned_null:
mov w0, #250
ret
Lsigned_default:
mov w0, #-1
ret
#if !defined(__APPLE__)
.size rea_relative_switch_signed, .-rea_relative_switch_signed
#endif
#if defined(__APPLE__)
.section __TEXT,__const
#else
.section .rodata
#endif
.p2align 1
Lsigned_table:
.short Lsigned_case0-Lsigned_base
.short Lsigned_case1-Lsigned_base
.short Lsigned_case2-Lsigned_base
.short 0
.short Lsigned_case4-Lsigned_base
+65
View File
@@ -14,6 +14,71 @@ if (args.includes("--version")) {
} else {
const input = args[0];
const output = args[args.indexOf("-o") + 1];
const inputs = args.slice(
0,
args.findIndex((arg) => arg.startsWith("--")),
);
if (inputs.length > 1) {
const modules = [];
const provenance = {
format: "unknown",
strategy: "structural",
modules: {},
};
for (const [index, selected] of inputs.entries()) {
const bytes = await readFile(selected);
const filename = `module-${index}.js`;
modules.push({ filename, kind: "javascript", status: "decompiled" });
await writeFile(join(output, filename), bytes);
provenance.modules[filename] = {
input:
index === 1 && mode === "secondary-foreign"
? "/unselected.js"
: selected,
ranges: [
[
0,
index === 1 && mode === "secondary-overflow"
? bytes.length + 1
: bytes.length,
],
],
context_ranges: [
[0, index === 1 && mode === "secondary-utf8" ? 2 : bytes.length],
],
extraction: "structural",
};
}
if (mode === "redistribute") {
const primary = process.env.REA_RECOVERY_ORIGINAL_PATH;
const secondary = process.env.REA_RECOVERY_ADDITIONAL_PATH;
const first = await readFile(primary);
const second = await readFile(secondary);
await writeFile(primary, Buffer.concat([first, second.subarray(0, 1)]));
await writeFile(secondary, second.subarray(1));
}
if (mode === "secondary-change")
await writeFile(
process.env.REA_RECOVERY_ADDITIONAL_PATH,
"changed additional input",
);
await writeFile(
join(output, "provenance.json"),
JSON.stringify(provenance),
);
console.log(
JSON.stringify({
detected_formats: [],
safety: "normal",
modules,
warnings: [],
total: modules.length,
failed: 0,
elapsed_ms: 1,
}),
);
process.exit(0);
}
const bytes = await readFile(input);
await writeFile(join(dirname(output), "recovery-started"), "started");
if (mode === "hang") await new Promise(() => setInterval(() => {}, 1000));
+1
View File
@@ -38,6 +38,7 @@ export const recoveryFixture = async (
...options.env,
REA_RECOVERY_FIXTURE_MODE: mode,
REA_RECOVERY_ORIGINAL_PATH: path,
REA_RECOVERY_ADDITIONAL_PATH: join(root, "chunk.js"),
REA_RECOVERY_ENGINE_PATH: command,
},
});
+91
View File
@@ -0,0 +1,91 @@
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;
};