Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd5d5fb46 | ||
|
|
51539a53ff | ||
|
|
ef535b81fe | ||
|
|
64ebdf4501 | ||
|
|
6dd719eaf6 | ||
|
|
3ed59b2812 | ||
|
|
764ff05231 | ||
|
|
75f50d86f6 | ||
|
|
6caa38a0ca | ||
|
|
0374865836 | ||
|
|
e9fac56232 | ||
|
|
5a0e0ba3b8 | ||
|
|
3af3811ab0 | ||
|
|
2572e1eb2f | ||
|
|
24a0bf9a61 | ||
|
|
630bdafcac | ||
|
|
dd3640548f | ||
|
|
e84ad86a75 | ||
|
|
b3b44d8a2d | ||
|
|
02beb74535 | ||
|
|
21a8bd0ccd | ||
|
|
78014e3d14 | ||
|
|
472b72a530 | ||
|
|
894c30ef02 | ||
|
|
f9eaf056ef | ||
|
|
50787b2faf | ||
|
|
911d580634 | ||
|
|
b62931f833 | ||
|
|
d2aed172ab | ||
|
|
00a6ac3de9 | ||
|
|
4bfd8038be | ||
|
|
e1ca412757 | ||
|
|
66c26f280b | ||
|
|
cffc548cd3 | ||
|
|
ae81f64d2a | ||
|
|
c945931961 | ||
|
|
69be514c67 | ||
|
|
554e9b1612 |
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_VERSION="24.18.0"
|
||||
NODE_SHA256="55aa7153f9d88f28d765fcdad5ae6945b5c0f98a36881703817e4c450fa76742"
|
||||
export PATH="/usr/local/bin:${PATH}"
|
||||
|
||||
if [[ "$(/usr/local/bin/node -v 2>/dev/null || true)" != "v${NODE_VERSION}" ]]; then
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "${tmp}/node.tar.xz" "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz"
|
||||
echo "${NODE_SHA256} ${tmp}/node.tar.xz" | sha256sum -c -
|
||||
sudo tar -xJf "${tmp}/node.tar.xz" -C /usr/local --strip-components=1
|
||||
rm -rf "${tmp}"
|
||||
fi
|
||||
|
||||
hash -r
|
||||
|
||||
for cmd in node npm npx corepack; do
|
||||
if [[ -x "/usr/local/bin/${cmd}" ]]; then
|
||||
sudo ln -sfn "/usr/local/bin/${cmd}" "/usr/local/cargo/bin/${cmd}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "${HOME}/.bashrc" ]] && ! grep -qF '# rea-dev-node' "${HOME}/.bashrc"; then
|
||||
printf '\n# rea-dev-node\nexport PATH="/usr/local/bin:$PATH"\n' >> "${HOME}/.bashrc"
|
||||
fi
|
||||
|
||||
npm ci
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "REA",
|
||||
"install": "bash .cursor/cloud-agent-install.sh"
|
||||
}
|
||||
@@ -77,6 +77,8 @@ jobs:
|
||||
- run: npm ci
|
||||
- if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
run: npm run check:ci
|
||||
- if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
run: npm run verify:test-discovery
|
||||
- if: github.event_name == 'pull_request' && needs.changes.outputs.code != 'true'
|
||||
run: npm run format:check
|
||||
|
||||
@@ -274,8 +276,12 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run verify:keyed-archive
|
||||
- run: npm run verify:asset-catalog
|
||||
- name: Native compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- src/application/NibNativeHierarchy.test.ts
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
run: npm run verify:apple-dispatch && npm run verify:interface-builder
|
||||
- name: Native plist CLI selection regressions
|
||||
run: npm run test:focused -- tests/boundary/cli/nativePlistCli.test.ts
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
@@ -368,6 +374,8 @@ jobs:
|
||||
tests/boundary/filesystem/safeOutputTree.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
|
||||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
||||
src/cliReferenceSourceImportStatus.test.ts
|
||||
tests/boundary/filesystem/asarInventoryFreshness.test.ts
|
||||
tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts
|
||||
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
|
||||
|
||||
@@ -5,10 +5,20 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/real-browser.yml"
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/verify-browser-network.mjs"
|
||||
- "scripts/verify-browser-scripts.mjs"
|
||||
- "scripts/lib/browser-*.mjs"
|
||||
- "scripts/fixtures/browser-network-site.mjs"
|
||||
- "scripts/fixtures/browser-script-site.mjs"
|
||||
- "scripts/fixtures/browser-verifier-site.mjs"
|
||||
- "scripts/fixtures/page-cdp-proxy.mjs"
|
||||
- "src/browser/**"
|
||||
- "src/domain/browser*.ts"
|
||||
- "src/domain/webScript*.ts"
|
||||
- "src/application/WebScriptExportService.ts"
|
||||
- "src/contracts/webScriptToolContracts.ts"
|
||||
- "src/server/registerWebScriptTool.ts"
|
||||
- "src/cli/webScriptCommands.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
|
||||
+87
-6
@@ -6,11 +6,19 @@
|
||||
"max-depth": ["warn", 4],
|
||||
"max-lines": [
|
||||
"warn",
|
||||
{ "max": 500, "skipBlankLines": true, "skipComments": true }
|
||||
{
|
||||
"max": 500,
|
||||
"skipBlankLines": true,
|
||||
"skipComments": true
|
||||
}
|
||||
],
|
||||
"max-lines-per-function": [
|
||||
"warn",
|
||||
{ "max": 100, "skipBlankLines": true, "skipComments": true }
|
||||
{
|
||||
"max": 100,
|
||||
"skipBlankLines": true,
|
||||
"skipComments": true
|
||||
}
|
||||
],
|
||||
"max-params": ["warn", 4],
|
||||
"no-var": "warn",
|
||||
@@ -38,8 +46,74 @@
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/ghidra/**", "**/hopper/**", "**/ida/**"],
|
||||
"message": "Deep-provider imports belong behind adapters or in the application composition root."
|
||||
"group": [
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**"
|
||||
],
|
||||
"message": "Provider imports belong behind typed ports or in production composition."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/{contracts,domain}/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/application/**",
|
||||
"**/server/**",
|
||||
"**/cli/**",
|
||||
"**/main/**",
|
||||
"**/cli.*",
|
||||
"**/main.*",
|
||||
"**/android/**",
|
||||
"**/artifacts/**",
|
||||
"**/browser/**",
|
||||
"**/dotnet/**",
|
||||
"**/firmware/**",
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/native/**",
|
||||
"**/process/**",
|
||||
"**/reference/**",
|
||||
"**/windows/**",
|
||||
"**/composition/**"
|
||||
],
|
||||
"message": "Domain and contracts depend inward; use typed ports for application, caller and provider boundaries."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/application/**/*.ts"],
|
||||
"excludeFiles": ["src/application/runtime.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**",
|
||||
"**/composition/**"
|
||||
],
|
||||
"message": "Application workflows use typed ports; production construction belongs in composition."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -52,7 +126,11 @@
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": [
|
||||
"error",
|
||||
{ "max": 150, "skipBlankLines": true, "skipComments": true }
|
||||
{
|
||||
"max": 150,
|
||||
"skipBlankLines": true,
|
||||
"skipComments": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -74,7 +152,10 @@
|
||||
"**/ida/**",
|
||||
"**/replay/**",
|
||||
"**/cli.*",
|
||||
"**/main.*"
|
||||
"**/main.*",
|
||||
"**/composition/**",
|
||||
"**/android/**",
|
||||
"**/firmware/**"
|
||||
],
|
||||
"message": "Domain tests may import only their owning domain and inward dependencies."
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rath
|
||||
|
||||
Keep tool catalogs complete and self-describing; prefer capability- and session-scoped availability over schema truncation. Serialized bytes alone do not measure agent usability or model context cost.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
Development requires Node.js 24.18.0 and npm 11.16.0 (`.nvmrc` and `packageManager`). The Cloud Agent image places an older `node` on `PATH` ahead of a normal install. `.cursor/cloud-agent-install.sh` installs that toolchain under `/usr/local` and prepends `/usr/local/bin` for login shells. Confirm `node -v` is `v24.18.0` before installing dependencies.
|
||||
|
||||
`npm ci` installs locked dependencies. `npm run build:cached` produces the CLI and MCP server. `npm run check:fast` is the pre-push typecheck and lint. Hopper, Ghidra, and IDA are optional bring-your-own providers. JavaScript analysis and the deterministic Vitest suites do not need them. `rea doctor` reports those engines as missing until they are configured.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Use Conventional Commit subjects because Release Please derives versions and changelogs from them. Examples: `feat: add historical source import`, `fix(process): stop timers after exit`, and `docs: update architecture`. Use `!` or a `BREAKING CHANGE:` footer for breaking changes. Pull request titles must follow the same format because squash merges use the title as the release commit. Pull requests should describe contract or behavior changes, list verification commands, link issues, and include sanitized MCP examples when schemas change. State whether real Hopper/Ghidra verification was performed. Never commit binaries, Hopper or Ghidra project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts (e.g. `.codex/`).
|
||||
|
||||
+5
-1
@@ -2,11 +2,15 @@
|
||||
|
||||
REA welcomes focused bug fixes, documentation improvements, tests, and reverse-engineering workflow enhancements. Open an issue before a large contract or architecture change so its scope can be agreed before implementation.
|
||||
|
||||
For capability organization and provider composition, follow the incremental
|
||||
[migration guide](docs/capability-migration.md). Run `npm run verify:test-discovery`
|
||||
after adding or moving tests.
|
||||
|
||||
When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog.
|
||||
|
||||
## Development setup
|
||||
|
||||
REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch) and an installed Hopper application. Linux demo verification uses its own private Xvfb display and does not require a desktop session. Run `nvm use` before installing dependencies.
|
||||
REA development requires Node.js 24.18.x and npm 11.16.x (pinned toolchain via `nvm use`; the supported runtime range is Node.js ^22.19 || ^24.11 || >=26, as the README badge states). Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch) and an installed Hopper application. Linux demo verification uses its own private Xvfb display and does not require a desktop session. Run `nvm use` before installing dependencies.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
|
||||
@@ -51,49 +51,6 @@ REA connects your agent to tools for inspecting native binaries, JavaScript and
|
||||
|
||||
Setup registers REA with your agent and installs matching workflow instructions. Native analysis can use an existing Hopper or Ghidra installation; setup can optionally install Hopper with approval. Static JavaScript analysis needs neither engine.
|
||||
|
||||
## Just ask your agent
|
||||
|
||||
After [setup](#quick-start), restart your agent and ask:
|
||||
|
||||
```text
|
||||
Understand how search works in the Notes app, show me the evidence, and build a
|
||||
similar feature for my project.
|
||||
```
|
||||
|
||||
Replace Notes with the app you want to understand, or ask for an overview first.
|
||||
|
||||
## The investigation model
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Decompile</strong><br /><br />
|
||||
Open an app and recover readable code, strings, names, and other clues about how it works.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Understand</strong><br /><br />
|
||||
Follow the code from one part of the app to another until the agent can explain how a feature actually works.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Recreate</strong><br /><br />
|
||||
Turn what the agent learned into a feature for your own product, adapted to your stack, interface, and requirements.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
REA shows how it reached its conclusions. It does not claim to recover original source code or automatically clone an application.
|
||||
|
||||
## Why REA
|
||||
|
||||
| | |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Built for agents** | Ask what an app does and let your agent inspect it instead of guessing. |
|
||||
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or agent. |
|
||||
| **Guided setup** | Configure your agent, connect an existing analysis tool, or install Hopper with your approval. |
|
||||
| **From insight to code** | Understand a feature, then build your own version in the same coding session. |
|
||||
| **Local by design** | Analysis runs on your supported local host. REA does not upload the app to a hosted analysis service. |
|
||||
| **Keeps context** | Investigate several apps without starting over for every question. |
|
||||
|
||||
## Quick start
|
||||
|
||||
### Run setup (recommended)
|
||||
@@ -108,25 +65,21 @@ Choose which supported agents should use REA, then review the exact paths and ch
|
||||
|
||||
Setup shows its changes before applying them and backs up existing configuration. See [Installation and setup](docs/installation.md) for requirements and setup options.
|
||||
|
||||
### With an agent (recommended)
|
||||
### AI Coding Assistants (optional)
|
||||
|
||||
After setup, restart your agent and [describe the app or feature](#just-ask-your-agent) you want to understand. Hopper can run in demo mode; if it shows a first-run prompt, choose the demo or enter an existing license.
|
||||
|
||||
REA supports Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, Command Code, and VS Code. Existing REA registrations are selected by default during setup; other detected agents remain unselected until chosen. Other agents can use the [manual MCP configuration](#manual-mcp-configuration).
|
||||
|
||||
### Install the skill separately
|
||||
Add the skill to your AI coding assistant for richer context:
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea --skill reverse-engineer-anything
|
||||
```
|
||||
|
||||
This installs agent instructions, not MCP registration or analysis engines.
|
||||
Follow the skill's [conditional connection guide](skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed):
|
||||
prepare a scoped `npx rea-agents setup` plan if registration is missing, review
|
||||
and approve its actual changes, then restart/reconnect and verify the tools.
|
||||
Guided setup above installs a version-matched skill by default. The skills.sh
|
||||
route uses repository instructions, which can be ahead of the released server;
|
||||
see [released package and main](docs/installation.md#released-package-and-main).
|
||||
The skill provides REA's investigation workflow. Run setup above to connect REA to your agent and configure analysis tools. Setup already installs a version-matched skill by default; this command installs the repository version.
|
||||
|
||||
### With an agent (recommended)
|
||||
|
||||
After setup, restart your agent and [describe the app or feature](#just-ask-your-agent) you want to understand. Hopper can run in demo mode; if it shows a first-run prompt, choose the demo or enter an existing license.
|
||||
|
||||
REA supports Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, Command Code, and VS Code. Existing REA registrations are selected by default during setup; other detected agents remain unselected until chosen. Other agents can use the [manual MCP configuration](#manual-mcp-configuration).
|
||||
|
||||
### First result from the terminal
|
||||
|
||||
@@ -178,7 +131,7 @@ Firmware region inspection and explicit extraction use caller-supplied Binwalk a
|
||||
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures.
|
||||
|
||||
Repository main includes experimental Windows x64 Ghidra support for native x86-64 PE applications on local NTFS, with bundled Job Object, private-DACL, and path-admission controls. Check the [release boundary](docs/installation.md#released-package-and-main) before expecting this from an npm package. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
Repository main and npm 4.1.0 include experimental Windows x64 Ghidra support for native x86-64 PE applications on local NTFS, with bundled Job Object, private-DACL, and path-admission controls. Check the [release boundary](docs/installation.md#released-package-and-main) before expecting this from an older npm package. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
|
||||
If something is not working, run:
|
||||
|
||||
@@ -297,6 +250,11 @@ Import an older source tree as a reference. REA keeps it separate from observati
|
||||
rea import-reference-source /absolute/path/to/source
|
||||
```
|
||||
|
||||
Historical-source import requires safe no-follow file opens and currently runs
|
||||
on Linux and macOS. Native Windows reports `unsupported_host`; run the import
|
||||
with Linux REA in WSL or on another supported host. Changing permissions or
|
||||
reinstalling REA does not enable this Windows workflow.
|
||||
|
||||
Imports read the path supplied to the command. File names do not cause automatic omissions; files are represented by hashes and metadata. To exclude selected paths, set `REA_REFERENCE_SECRET_PATTERNS_JSON` to a JSON string array of ignore patterns. Exports never replace an existing file unless `--overwrite` is explicit.
|
||||
|
||||
Use a snapshot to save successful analysis results and reuse them on later runs. REA reuses a result only when the target bytes, operation, parameters, analysis tool, and settings match. It does not cache changes or cursor-dependent calls. Snapshot files stay local and use owner-only permissions.
|
||||
@@ -314,6 +272,49 @@ result is returned. Pass `snapshot_path` and, when required, `overwrite: true` t
|
||||
`close_binary` to save atomically before Hopper resources are released. If the
|
||||
save fails, REA deliberately leaves the session open.
|
||||
|
||||
## Just ask your agent
|
||||
|
||||
After [setup](#quick-start), restart your agent and ask:
|
||||
|
||||
```text
|
||||
Understand how search works in the Notes app, show me the evidence, and build a
|
||||
similar feature for my project.
|
||||
```
|
||||
|
||||
Replace Notes with the app you want to understand, or ask for an overview first.
|
||||
|
||||
## The investigation model
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Decompile</strong><br /><br />
|
||||
Open an app and recover readable code, strings, names, and other clues about how it works.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Understand</strong><br /><br />
|
||||
Follow the code from one part of the app to another until the agent can explain how a feature actually works.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<strong>Recreate</strong><br /><br />
|
||||
Turn what the agent learned into a feature for your own product, adapted to your stack, interface, and requirements.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
REA shows how it reached its conclusions. It does not claim to recover original source code or automatically clone an application.
|
||||
|
||||
## Why REA
|
||||
|
||||
| | |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Built for agents** | Ask what an app does and let your agent inspect it instead of guessing. |
|
||||
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or agent. |
|
||||
| **Guided setup** | Configure your agent, connect an existing analysis tool, or install Hopper with your approval. |
|
||||
| **From insight to code** | Understand a feature, then build your own version in the same coding session. |
|
||||
| **Local by design** | Analysis runs on your supported local host. REA does not upload the app to a hosted analysis service. |
|
||||
| **Keeps context** | Investigate several apps without starting over for every question. |
|
||||
|
||||
## One prompt, a full investigation
|
||||
|
||||
```text
|
||||
@@ -349,20 +350,20 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
|
||||
## Tool catalog for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 7 | cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 8 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
|
||||
|
||||
@@ -376,6 +377,13 @@ Static Android APK inspection is verified on Linux with headless JADX; see [Andr
|
||||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Artifact requests name the input and requested extraction or traversal directly; macOS DMG traversal also requires the host's native mounting support.
|
||||
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- **Websites:** Inspect a selected page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and screenshots requested by the call. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
|
||||
|
||||
Export retained scripts with `export_web_scripts` / `rea export-web-scripts`
|
||||
into a verified local directory, then use the existing JavaScript analysis
|
||||
and tracing tools. Source URLs, frame or transaction references, competing
|
||||
versions, and missing-source states remain inline. See
|
||||
[captured website scripts](docs/website-script-export.md).
|
||||
|
||||
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
|
||||
- **Controlled behavior capture:** Run process, browser, or Electron scenarios with the target, actions, and lifecycle declared in each request, then compare the resulting evidence. Missing observations cannot establish that two runs behaved the same way.
|
||||
@@ -457,7 +465,7 @@ The [current status](#current-status) section describes shipped capabilities. Th
|
||||
|
||||
1. **Expand browser and Electron interaction:** add scenario actions beyond the current click and wait operations.
|
||||
2. **Observe native apps at runtime:** explore LLDB, Frida, system logs, and native API tracing.
|
||||
3. **Evaluate more tools and targets:** assess IDA/Hex-Rays, Binary Ninja, Rizin, LIEF, Windows-native tools, mobile apps, and firmware.
|
||||
3. **Evaluate more tools and targets:** assess Binary Ninja, Rizin, LIEF, Windows-native tools, mobile apps, and firmware. IDA support already ships via the bring-your-own upstream adapter; see the [IDA provider guide](docs/ida-provider.md).
|
||||
|
||||
Setup already lets you choose agent integration and Hopper installation. Support for installing additional analysis tools is future work, described in the [installation roadmap](docs/roadmap.md).
|
||||
|
||||
@@ -630,9 +638,9 @@ Process Capture records behavior and is not a security sandbox.
|
||||
Capture a scenario or compare two saved Process Capture Evidence records:
|
||||
|
||||
```bash
|
||||
rea capture-process ./scenario.json > authority.json
|
||||
rea capture-process ./reconstruction.json > reconstruction.json
|
||||
rea compare-process-captures authority.json reconstruction.json
|
||||
rea capture-process ./authority-scenario.json --json > authority-capture.json
|
||||
rea capture-process ./reconstruction-scenario.json --json > reconstruction-capture.json
|
||||
rea compare-process-captures authority-capture.json reconstruction-capture.json --json
|
||||
```
|
||||
|
||||
The comparison reports each observed dimension separately and identifies the
|
||||
@@ -640,9 +648,16 @@ first terminal, interaction, exit, filesystem, or process divergence.
|
||||
See [Process Capture](docs/process-capture.md) for scenario fields, limits, and
|
||||
evidence boundaries.
|
||||
|
||||
REA installs a prebuilt PTY backend for supported macOS, Linux, and Windows
|
||||
architectures. If the capability check reports that the backend is unavailable,
|
||||
reinstall REA for the current platform and architecture.
|
||||
Process Capture currently runs on Linux and macOS with a working native PTY
|
||||
backend. Native Windows capture remains unavailable until the PTY adapter can
|
||||
verify descendant cleanup; installing or reinstalling its Windows PTY binary
|
||||
does not enable capture. For Linux commands, run Linux REA inside WSL.
|
||||
Comparing existing capture Evidence remains available on Windows.
|
||||
|
||||
On supported capture hosts, a missing or incompatible PTY binary can be fixed
|
||||
by reinstalling REA for the current platform, architecture, and Node.js version
|
||||
with optional dependencies enabled. Use separate scenario and output files;
|
||||
`--json` produces the JSON consumed by the comparison command.
|
||||
|
||||
ASAR inventory verifies Electron integrity metadata for both archive entries
|
||||
and `.asar.unpacked` companion files. Integrity failures identify the logical
|
||||
|
||||
+41
-54
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **العربية**
|
||||
|
||||
# REA: هندسة أي شيء عكسيًا
|
||||
# REA: اعكس هندسة أي شيء
|
||||
|
||||
### استقصِ سلوك التطبيقات والملفات التنفيذية الأصلية باستخدام وكيلك.
|
||||
### وسيط MCP واحد للهندسة العكسية عبر الملفات التنفيذية والتطبيقات وسلوك وقت التشغيل.
|
||||
|
||||
**اعثر على ميزة تعجبك. افهم آلية عملها. ابنها بالطريقة التي تريدها.**
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/82054/weekly?language=TypeScript" alt="morluto/rea | Trendshift" width="250" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [حالة الدعم](#حالة-الدعم) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [منصة أدوات التحقيق](#منصة-أدوات-التحقيق) · [خطة العمل](#خطة-العمل) · [كيف يعمل؟](#كيف-يعمل)
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
يوفر REA أدوات لتحليل الملفات التنفيذية الأصلية وتطبيقات JavaScript وElectron وتجميعات .NET والمواقع. يمكنك استخدامها من وكيلك أو من الطرفية. يعمل التحليل محليًا، وتتضمن النتائج الأدلة والقيود.
|
||||
|
||||
يضبط Setup إعدادات الوكيل ويربطه بتثبيت موجود من Hopper أو Ghidra. ويمكنه تثبيت Hopper بعد موافقتك.
|
||||
يضبط Setup إعدادات الوكيل ويربطه بتثبيت موجود من Hopper أو Ghidra، ويثبّت سير عمل REA الموجّه. ويمكنه تثبيت Hopper بعد موافقتك. ولا يحتاج التحليل الثابت لتطبيقات JavaScript إلى أي محرك.
|
||||
|
||||
## اطلب من وكيلك مباشرة
|
||||
|
||||
@@ -69,14 +69,14 @@
|
||||
|
||||
## لماذا REA؟
|
||||
|
||||
| | |
|
||||
| -------------------- | ------------------------------------------------------------------------------------- |
|
||||
| **مصمم للوكلاء** | اسأل عما يفعله تطبيق مترجم ودع الوكيل يجمع الأدلة بدلًا من التخمين. |
|
||||
| **CLI وMCP** | استخدم قدرات الهندسة العكسية نفسها من الطرفية أو وكيل البرمجة. |
|
||||
| **يتولى التعقيد** | يدير REA إعداد الأدوات وفتح التطبيق واستمرار الاستقصاء والتنظيف بعد الانتهاء. |
|
||||
| **سير عمل كامل** | انتقل من النظرة الأولى إلى الشيفرة شبه المصدرية وعلاقات الاستدعاء والأنواع وأدلة التنفيذ. |
|
||||
| **محلي حسب التصميم** | يجري التحليل على جهازك المدعوم ولا يرفع REA الملف التنفيذي إلى خدمة تحليل مستضافة. |
|
||||
| **يحافظ على السياق** | استقصِ عدة ملفات تنفيذية من دون بدء التحليل من جديد عند كل سؤال. |
|
||||
| | |
|
||||
| ------------------------ | ---------------------------------------------------------------------------- |
|
||||
| **مصمم للوكلاء** | اسأل عما يفعله التطبيق ودع الوكيل يفحصه بدلًا من التخمين. |
|
||||
| **CLI وMCP** | استخدم قدرات الهندسة العكسية نفسها من الطرفية أو الوكيل. |
|
||||
| **إعداد موجّه** | اضبط وكيلك، واربط أداة تحليل موجودة، أو ثبّت Hopper بموافقتك. |
|
||||
| **من الفهم إلى الشيفرة** | افهم الميزة ثم ابنِ نسختك الخاصة في جلسة البرمجة نفسها. |
|
||||
| **محلي حسب التصميم** | يجري التحليل على مضيفك المحلي المدعوم، ولا يرفع REA التطبيق إلى خدمة مستضافة. |
|
||||
| **يحافظ على السياق** | استقصِ عدة تطبيقات دون بدء التحليل من جديد عند كل سؤال. |
|
||||
|
||||
## البدء السريع
|
||||
|
||||
@@ -98,28 +98,23 @@ npx rea-agents setup
|
||||
|
||||
يمكن تشغيل Hopper في الوضع التجريبي. إذا ظهرت رسالة عند التشغيل الأول، فاختر التجربة أو أدخل ترخيصًا موجودًا.
|
||||
|
||||
### Skill instructions and a first CLI result
|
||||
### مهارة مساعد البرمجة (اختياري)
|
||||
|
||||
أضف المهارة إلى مساعد البرمجة للحصول على سياق أغنى:
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea --skill reverse-engineer-anything
|
||||
```
|
||||
|
||||
This installs instructions, not MCP registration or analysis engines. Follow the
|
||||
[conditional connection guide](skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
||||
`npx rea-agents setup` installs a matching skill and registers selected clients
|
||||
only after showing the plan and receiving approval. Restart/reconnect after
|
||||
registration, then verify REA tools in the active session. Repository skills can
|
||||
be ahead of the [released package](docs/installation.md#released-package-and-main).
|
||||
توفر المهارة سير عمل التحقيق في REA. نفّذ الإعداد أعلاه لربط REA بالوكيل وضبط أدوات التحليل. يثبّت الإعداد افتراضيًا مهارة مطابقة للإصدار؛ وهذا الأمر يثبّت نسخة المستودع.
|
||||
|
||||
For your extracted JavaScript/Electron tree or ASAR, no MCP setup or native
|
||||
engine is required:
|
||||
لشجرة JavaScript/Electron المستخرجة أو ملف ASAR، لا يلزم إعداد MCP ولا محرك أصلي:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
Replace the path with your target; Windows paths such as `"D:/apps/example"`
|
||||
are supported by this static workflow. Native analysis requires its own engine.
|
||||
استبدل المسار بهدفك (مثل `"D:/apps/example"` على Windows). يدعم سير العمل الثابت هذه المسارات، بينما يتطلب التحليل الأصلي محركه الخاص.
|
||||
|
||||
### تثبيت أمر rea
|
||||
|
||||
@@ -150,7 +145,7 @@ rea setup
|
||||
|
||||
يستطيع Setup التحقق من التثبيت وحفظ المسارات؛ ولا يثبت أو يحدّث Ghidra أو Java أو Node.js أو npm أو Homebrew.
|
||||
|
||||
Repository main includes experimental Windows x64 Ghidra P0 for native, non-managed, non-DLL x86-64 PE applications on fixed local NTFS. The native controls are implemented; npm 4.0.1 does not contain them. See the [release boundary](docs/installation.md#released-package-and-main) and [Windows Ghidra P0](docs/windows-ghidra-p0.md).
|
||||
يتضمن فرع main والحزمة npm 4.1.0 دعمًا تجريبيًا لـ Windows x64 Ghidra P0 لتطبيقات PE الأصلية x86-64 (غير المُدارة وغير DLL) على NTFS محلي، مع ضوابط Job Object وقوائم DACL الخاصة وقبول المسارات المجمّعة. تحقق من [حد الإصدار](docs/installation.md#released-package-and-main) قبل توقع ذلك من حزمة npm أقدم. راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للمتطلبات والنطاق الموثق.
|
||||
|
||||
### تشخيص المشكلات
|
||||
|
||||
@@ -166,21 +161,12 @@ Repository main includes experimental Windows x64 Ghidra P0 for native, non-mana
|
||||
|
||||
## حالة الدعم
|
||||
|
||||
The current repository capabilities and platform requirements are described in
|
||||
[the English support guide](README.md#current-status). Main can be ahead of the
|
||||
[npm release](docs/installation.md#released-package-and-main).
|
||||
تُوصف قدرات المستودع الحالية ومتطلبات الأنظمة في [دليل الدعم الإنجليزي](README.md#current-status). قد يسبق فرع main [إصدار npm](docs/installation.md#released-package-and-main).
|
||||
|
||||
- Ghidra provides 25 read-only operations on Linux x64, macOS x64/arm64, and the
|
||||
experimental Windows x64 P0 boundary. Linux/macOS additionally supports atomic
|
||||
session function annotations. Windows P0 is read-only; Ghidra has no GUI controls.
|
||||
- Static Android inspection has separate bring-your-own JADX/Java prerequisites;
|
||||
real-provider verification covers Linux. See [Android analysis](docs/android-analysis.md).
|
||||
- Browser, Electron, and process requests specify their target, actions, and
|
||||
lifecycle directly. They require the host's actual access, without separate
|
||||
REA permission grants. Setup configuration writes and Hopper installation
|
||||
still require approval of their exact plan.
|
||||
- `rea capabilities` describes binary-session operations rather than every
|
||||
application/runtime tool. Consult the full MCP tool list and relevant guide.
|
||||
- يوفر Ghidra 25 عملية للقراءة فقط على Linux x64 وmacOS x64/arm64 وحدود Windows x64 P0 التجريبية. يدعم Linux/macOS أيضًا تعليقات الدوال الذرية على مستوى الجلسة. نظام Windows P0 للقراءة فقط، ولا يوفر Ghidra أي تحكم في الواجهة.
|
||||
- يتطلب الفحص الثابت لنظام Android تجهيز JADX/Java بشكل منفصل؛ ويغطي التحقق بالموفر الحقيقي نظام Linux. راجع [تحليل Android](docs/android-analysis.md).
|
||||
- تحدد طلبات المتصفح وElectron والعمليات هدفها وإجراءاتها ودورة حياتها مباشرة، وتعتمد على وصول المضيف الفعلي دون منح أذونات منفصلة من REA. ما زالت كتابة إعدادات Setup وتثبيت Hopper تتطلب الموافقة على خطتها الدقيقة.
|
||||
- يصف `rea capabilities` عمليات جلسة الملف التنفيذي لا كل أدوات التطبيقات ووقت التشغيل. راجع قائمة أدوات MCP الكاملة والدليل ذا الصلة.
|
||||
|
||||
## استقصاء كامل بطلب واحد
|
||||
|
||||
@@ -225,20 +211,20 @@ The current repository capabilities and platform requirements are described in
|
||||
|
||||
## منصة أدوات التحقيق
|
||||
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| --------------------- | ----: | -------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
|
||||
| التحليل المركب | 14 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
|
||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
|
||||
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
|
||||
| .NET PE/CLI | 7 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة واستخراجها الصريح على Linux |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف والأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | الاتصال بهدف Node/Electron Inspector موجود لمراقبة البرامج وسياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 7 | تتبع الميزات ومقارنة الإصدارات وبنية قيم الإرجاع والتحقق من إعادة الإنشاء |
|
||||
| جلسة الملف التنفيذي | 21 | تبديل الأهداف وحفظ الأدلة ومقارنة العمليات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| -------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
|
||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift دون تشغيل Hopper |
|
||||
| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج |
|
||||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 8 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
|
||||
@@ -299,8 +285,9 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
|
||||
|
||||
## الأمان والخصوصية
|
||||
|
||||
- يبقى تحليل الملف التنفيذي محليًا بين REA ومزوّد التحليل المختار.
|
||||
- يبقى التحليل محليًا بين REA وموفر التحليل المختار عبر مقابس محلية خاصة، ومع IDA عبر تسجيل MCP المحلي المضبوط.
|
||||
- لا يرفع REA الأهداف إلى خدمة مستضافة.
|
||||
- تعمل أدوات التحليل والأهداف المطلقة بصلاحيات المستخدم نفسه؛ وما زال التقاط واجهة النظام على macOS يعتمد على الوصول إلى تسهيلات الاستخدام وتسجيل الشاشة. ولا ينفّذ التحليل الثابت للـ JavaScript الوحدات المستخرجة.
|
||||
- تستخدم جلسات Ghidra مشروعًا مؤقتًا معزولًا ولا تفتح مشاريع Ghidra التي يملكها المستخدم أو تعدّلها.
|
||||
- راجع الملفات التنفيذية غير الموثوقة واعزلها كما تفعل مع أي مدخل أصلي قد يكون ضارًا.
|
||||
- أبلغ عن الثغرات وفق [سياسة الأمان](SECURITY.md)، وليس عبر قضية عامة.
|
||||
@@ -324,7 +311,7 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
|
||||
<details>
|
||||
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
|
||||
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.4 وJDK 21 الكامل. تحليل Ghidra على Windows غير متاح بسبب الضوابط الأصلية غير المنفذة؛ راجع [Windows Ghidra P0](docs/windows-ghidra-p0.md).
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.4 وJDK 21 الكامل، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+35
-49
@@ -15,7 +15,7 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/82054/weekly?language=TypeScript" alt="morluto/rea | Trendshift" width="250" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
[クイックスタート](#クイックスタート) · [現在の対応状況](#現在の対応状況) · [バイナリから動作へ](#バイナリから動作へ) · [調査ツールカタログ](#調査ツールカタログ) · [ロードマップ](#ロードマップ) · [仕組み](#仕組み)
|
||||
|
||||
@@ -69,14 +69,14 @@ REA は調査をバイナリ上の根拠に結び付けます。元のソース
|
||||
|
||||
## REA を選ぶ理由
|
||||
|
||||
| | |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **エージェント向け** | コンパイル済みアプリについて質問し、推測ではなく根拠を集めさせることができます。 |
|
||||
| **CLI と MCP** | ターミナルとコーディングエージェントから同じリバースエンジニアリング機能を使えます。 |
|
||||
| **複雑さを処理** | ツール設定、アプリの読み込み、調査の維持、終了後のクリーンアップを REA が担います。 |
|
||||
| **一連の調査に対応** | 最初の概要から疑似コード、呼び出し関係、型、実装の手掛かりまで掘り下げられます。 |
|
||||
| **ローカルで解析** | 解析は対応するローカルホストで実行され、REA がバイナリをホスト型解析サービスへ送ることはありません。 |
|
||||
| **コンテキストを維持** | 質問ごとに解析を最初からやり直さず、複数のバイナリを続けて調査できます。 |
|
||||
| | |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **エージェント向け** | アプリの動作について質問し、推測ではなくエージェントに調査させることができます。 |
|
||||
| **CLI と MCP** | ターミナルとエージェントから同じリバースエンジニアリング機能を使えます。 |
|
||||
| **ガイド付きセットアップ** | エージェントの設定、既存の解析ツールへの接続、承認後の Hopper インストールに対応します。 |
|
||||
| **洞察からコードへ** | 機能を理解したら、同じコーディングセッションで自分のプロダクト向けに実装できます。 |
|
||||
| **ローカルで解析** | 解析は対応するローカルホストで実行され、REA がバイナリをホスト型解析サービスへ送ることはありません。 |
|
||||
| **コンテキストを維持** | 質問ごとに解析を最初からやり直さず、複数のアプリを続けて調査できます。 |
|
||||
|
||||
## クイックスタート
|
||||
|
||||
@@ -98,28 +98,23 @@ Setup は最初に連携するエージェントを複数選択できるよう
|
||||
|
||||
Hopper はデモモードで使えます。初回起動の画面ではデモを選択するか、既存のライセンスを入力してください。
|
||||
|
||||
### Skill instructions and a first CLI result
|
||||
### AI コーディングアシスタント向けスキル(任意)
|
||||
|
||||
AI コーディングアシスタントにスキルを追加すると、より豊富なコンテキストが得られます:
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea --skill reverse-engineer-anything
|
||||
```
|
||||
|
||||
This installs instructions, not MCP registration or analysis engines. Follow the
|
||||
[conditional connection guide](skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
||||
`npx rea-agents setup` installs a matching skill and registers selected clients
|
||||
only after showing the plan and receiving approval. Restart/reconnect after
|
||||
registration, then verify REA tools in the active session. Repository skills can
|
||||
be ahead of the [released package](docs/installation.md#released-package-and-main).
|
||||
このスキルは REA の調査ワークフローを提供します。上記の setup を実行して REA をエージェントに接続し、解析ツールを設定してください。Setup は既定でバージョンに合ったスキルをインストールします。このコマンドではリポジトリ版のスキルをインストールします。
|
||||
|
||||
For your extracted JavaScript/Electron tree or ASAR, no MCP setup or native
|
||||
engine is required:
|
||||
展開済みの JavaScript/Electron アプリのツリーや ASAR であれば、MCP の設定もネイティブエンジンも不要です:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
Replace the path with your target; Windows paths such as `"D:/apps/example"`
|
||||
are supported by this static workflow. Native analysis requires its own engine.
|
||||
パスを調査対象に置き換えてください(Windows では `"D:/apps/example"` のようなパス)。この静的ワークフローではそのようなパスも扱えます。ネイティブ解析には専用のエンジン設定が必要です。
|
||||
|
||||
### rea コマンドをインストール
|
||||
|
||||
@@ -150,7 +145,7 @@ Ghidra は Linux x64 と macOS x64/arm64 に対応します。Ghidra 12.1.4 と
|
||||
|
||||
Setup はインストールを確認し、パスを保存できます。Ghidra、Java、Node.js、npm、Homebrew のインストールや更新は行いません。
|
||||
|
||||
Repository main includes experimental Windows x64 Ghidra P0 for native, non-managed, non-DLL x86-64 PE applications on fixed local NTFS. The native controls are implemented; npm 4.0.1 does not contain them. See the [release boundary](docs/installation.md#released-package-and-main) and [Windows Ghidra P0](docs/windows-ghidra-p0.md).
|
||||
リポジトリの main と npm 4.1.0 には、ローカル NTFS 上のネイティブ x86-64 PE アプリケーション(非マネージド、非 DLL 向け)を対象とする実験的な Windows x64 Ghidra P0 対応が含まれています。Job Object、プライベート DACL、パス許可の各制御が同梱されています。古い npm パッケージに期待する前に[リリース境界](docs/installation.md#released-package-and-main)を確認してください。前提条件と検証済みの範囲は [Windows Ghidra P0](docs/windows-ghidra-p0.md) を参照してください。
|
||||
|
||||
### トラブルシューティング
|
||||
|
||||
@@ -166,21 +161,12 @@ Linux の既定ランチャーは `/opt/hopper/bin/Hopper` です。別の場所
|
||||
|
||||
## 現在の対応状況
|
||||
|
||||
The current repository capabilities and platform requirements are described in
|
||||
[the English support guide](README.md#current-status). Main can be ahead of the
|
||||
[npm release](docs/installation.md#released-package-and-main).
|
||||
リポジトリの現在の機能とプラットフォーム要件は[英語の対応状況ガイド](README.md#current-status)で説明しています。main は [npm リリース](docs/installation.md#released-package-and-main)より先行する場合があります。
|
||||
|
||||
- Ghidra provides 25 read-only operations on Linux x64, macOS x64/arm64, and the
|
||||
experimental Windows x64 P0 boundary. Linux/macOS additionally supports atomic
|
||||
session function annotations. Windows P0 is read-only; Ghidra has no GUI controls.
|
||||
- Static Android inspection has separate bring-your-own JADX/Java prerequisites;
|
||||
real-provider verification covers Linux. See [Android analysis](docs/android-analysis.md).
|
||||
- Browser, Electron, and process requests specify their target, actions, and
|
||||
lifecycle directly. They require the host's actual access, without separate
|
||||
REA permission grants. Setup configuration writes and Hopper installation
|
||||
still require approval of their exact plan.
|
||||
- `rea capabilities` describes binary-session operations rather than every
|
||||
application/runtime tool. Consult the full MCP tool list and relevant guide.
|
||||
- Ghidra は Linux x64、macOS x64/arm64、実験的な Windows x64 P0 境界で 25 件の読み取り専用操作を提供します。Linux/macOS ではアトミックなセッション内関数注釈にも対応します。Windows P0 は読み取り専用であり、Ghidra に GUI 操作はありません。
|
||||
- 静的な Android 検査には持ち込みの JADX/Java が別途必要です。実プロバイダー検証は Linux を対象とします。詳しくは[Android 解析](docs/android-analysis.md)を参照してください。
|
||||
- ブラウザー、Electron、プロセスの各リクエストは対象・操作・ライフサイクルを直接指定し、ホストの実際のアクセスに依存します。REA 側の個別の許可付与は不要です。Setup による設定書き込みと Hopper のインストールは、引き続き正確な計画の承認が必要です。
|
||||
- `rea capabilities` はバイナリセッション操作の説明であり、すべてのアプリ/ランタイムツールの目録ではありません。接続済み MCP ツール一覧と関連ガイドを参照してください。
|
||||
|
||||
## ひとつのプロンプトで調査を完結
|
||||
|
||||
@@ -214,20 +200,20 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
|
||||
## 調査ツールカタログ
|
||||
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | ------------------------------------------------------------------------------- |
|
||||
| バイナリ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
|
||||
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
|
||||
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
||||
| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
|
||||
| ファームウェア | 2 | Linux の領域検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
|
||||
| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | 既存の Node/Electron Inspector に接続し、スクリプトと実行コンテキストを観察 |
|
||||
| アプリのワークフロー | 7 | 機能追跡、バージョン比較、戻り値構造の比較、再実装の検証 |
|
||||
| バイナリセッション | 21 | 対象切り替え、根拠の保存、プロセス・関数比較、未解決事項の記録 |
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
|
||||
| macOS ネイティブ | 7 | Hopper を起動せずに扱える Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 |
|
||||
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 8 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
|
||||
+35
-49
@@ -15,7 +15,7 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/82054/weekly?language=TypeScript" alt="morluto/rea | Trendshift" width="250" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
[빠른 시작](#빠른-시작) · [현재 지원 범위](#현재-지원-범위) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [조사 도구 카탈로그](#조사-도구-카탈로그) · [로드맵](#로드맵) · [작동 방식](#작동-방식)
|
||||
|
||||
@@ -70,14 +70,14 @@ REA는 분석을 바이너리 증거에 근거하게 합니다. 원본 소스
|
||||
|
||||
## REA를 사용하는 이유
|
||||
|
||||
| | |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| **에이전트용** | 컴파일된 앱에 관해 질문하고 추측 대신 증거를 수집하게 합니다. |
|
||||
| **CLI와 MCP** | 터미널과 코딩 에이전트에서 동일한 리버스 엔지니어링 기능을 사용합니다. |
|
||||
| **복잡성 처리** | 도구 설정, 앱 열기, 조사 유지, 작업 후 정리를 REA가 처리합니다. |
|
||||
| **전체 조사 과정** | 첫 개요에서 의사 코드, 호출 관계, 타입, 구현 단서까지 이어서 조사합니다. |
|
||||
| **로컬 분석** | 분석은 지원되는 로컬 호스트에서 실행되며 REA는 바이너리를 호스팅 분석 서비스에 업로드하지 않습니다. |
|
||||
| **컨텍스트 유지** | 질문마다 분석을 처음부터 시작하지 않고 여러 바이너리를 연속으로 조사합니다. |
|
||||
| | |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **에이전트용** | 앱의 동작에 대해 질문하고 추측 대신 에이전트가 조사하게 합니다. |
|
||||
| **CLI와 MCP** | 터미널과 에이전트에서 동일한 리버스 엔지니어링 기능을 사용합니다. |
|
||||
| **안내식 설정** | 에이전트 설정, 기존 분석 도구 연결, 승인 후 Hopper 설치를 지원합니다. |
|
||||
| **통찰에서 코드로** | 기능을 이해한 뒤 같은 코딩 세션에서 자신의 제품에 맞는 버전을 구현합니다. |
|
||||
| **로컬 분석** | 분석은 지원되는 로컬 호스트에서 실행되며 REA는 바이너리를 호스팅 분석 서비스에 업로드하지 않습니다. |
|
||||
| **컨텍스트 유지** | 질문마다 분석을 처음부터 시작하지 않고 여러 앱을 연속으로 조사합니다. |
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
@@ -99,28 +99,23 @@ Setup은 먼저 연결할 에이전트를 여러 개 선택하도록 안내합
|
||||
|
||||
Hopper는 데모 모드로 사용할 수 있습니다. 첫 실행 안내가 나오면 데모를 선택하거나 기존 라이선스를 입력하세요.
|
||||
|
||||
### Skill instructions and a first CLI result
|
||||
### AI 코딩 어시스턴트용 스킬(선택)
|
||||
|
||||
AI 코딩 어시스턴트에 스킬을 추가하면 더 풍부한 컨텍스트를 얻을 수 있습니다.
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea --skill reverse-engineer-anything
|
||||
```
|
||||
|
||||
This installs instructions, not MCP registration or analysis engines. Follow the
|
||||
[conditional connection guide](skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
||||
`npx rea-agents setup` installs a matching skill and registers selected clients
|
||||
only after showing the plan and receiving approval. Restart/reconnect after
|
||||
registration, then verify REA tools in the active session. Repository skills can
|
||||
be ahead of the [released package](docs/installation.md#released-package-and-main).
|
||||
이 스킬은 REA의 조사 워크플로를 제공합니다. 위 setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다.
|
||||
|
||||
For your extracted JavaScript/Electron tree or ASAR, no MCP setup or native
|
||||
engine is required:
|
||||
압축을 해제한 JavaScript/Electron 앱 트리나 ASAR라면 MCP 설정이나 네이티브 엔진 없이 바로 분석할 수 있습니다.
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
Replace the path with your target; Windows paths such as `"D:/apps/example"`
|
||||
are supported by this static workflow. Native analysis requires its own engine.
|
||||
경로를 조사 대상으로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다.
|
||||
|
||||
### rea 명령 설치하기
|
||||
|
||||
@@ -151,7 +146,7 @@ Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.4와 완
|
||||
|
||||
Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다.
|
||||
|
||||
Repository main includes experimental Windows x64 Ghidra P0 for native, non-managed, non-DLL x86-64 PE applications on fixed local NTFS. The native controls are implemented; npm 4.0.1 does not contain them. See the [release boundary](docs/installation.md#released-package-and-main) and [Windows Ghidra P0](docs/windows-ghidra-p0.md).
|
||||
저장소 main과 npm 4.1.0에는 로컬 NTFS상의 네이티브 x86-64 PE 애플리케이션(비관리, 비 DLL 대상)을 위한 실험적인 Windows x64 Ghidra P0 지원이 포함되어 있습니다. Job Object, 비공개 DACL, 경로 승인 제어가 함께 제공됩니다. 이전 npm 패키지에 이 기능이 있을 것으로 기대하기 전에 [릴리스 경계](docs/installation.md#released-package-and-main)를 확인하세요. 전제 조건과 검증 범위는 [Windows Ghidra P0](docs/windows-ghidra-p0.md)를 참고하세요.
|
||||
|
||||
### 문제 해결
|
||||
|
||||
@@ -167,21 +162,12 @@ Linux의 기본 Hopper 실행 파일은 `/opt/hopper/bin/Hopper`입니다. 다
|
||||
|
||||
## 현재 지원 범위
|
||||
|
||||
The current repository capabilities and platform requirements are described in
|
||||
[the English support guide](README.md#current-status). Main can be ahead of the
|
||||
[npm release](docs/installation.md#released-package-and-main).
|
||||
저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞설 수 있습니다.
|
||||
|
||||
- Ghidra provides 25 read-only operations on Linux x64, macOS x64/arm64, and the
|
||||
experimental Windows x64 P0 boundary. Linux/macOS additionally supports atomic
|
||||
session function annotations. Windows P0 is read-only; Ghidra has no GUI controls.
|
||||
- Static Android inspection has separate bring-your-own JADX/Java prerequisites;
|
||||
real-provider verification covers Linux. See [Android analysis](docs/android-analysis.md).
|
||||
- Browser, Electron, and process requests specify their target, actions, and
|
||||
lifecycle directly. They require the host's actual access, without separate
|
||||
REA permission grants. Setup configuration writes and Hopper installation
|
||||
still require approval of their exact plan.
|
||||
- `rea capabilities` describes binary-session operations rather than every
|
||||
application/runtime tool. Consult the full MCP tool list and relevant guide.
|
||||
- Ghidra는 Linux x64, macOS x64/arm64, 실험적인 Windows x64 P0 경계에서 25개의 읽기 전용 작업을 제공합니다. Linux/macOS에서는 원자적 세션 함수 주석도 추가로 지원합니다. Windows P0는 읽기 전용이며 Ghidra에는 GUI 제어가 없습니다.
|
||||
- 정적 Android 검사는 별도로 준비한 JADX/Java가 필요합니다. 실제 제공자 검증은 Linux를 대상으로 합니다. [Android 분석](docs/android-analysis.md)을 참고하세요.
|
||||
- 브라우저, Electron, 프로세스 요청은 대상·동작·수명 주기를 직접 지정하며 호스트의 실제 접근에 의존합니다. 별도의 REA 권한 승인은 필요 없습니다. Setup의 설정 쓰기와 Hopper 설치는 여전히 정확한 계획에 대한 승인이 필요합니다.
|
||||
- `rea capabilities`는 모든 앱/런타임 도구의 목록이 아니라 바이너리 세션 작업을 설명합니다. 연결된 MCP 도구 목록과 관련 가이드를 참고하세요.
|
||||
|
||||
## 하나의 프롬프트로 전체 조사
|
||||
|
||||
@@ -215,20 +201,20 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
|
||||
## 조사 도구 카탈로그
|
||||
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | --------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
|
||||
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
|
||||
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
||||
| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
|
||||
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||
| Android APK | 5 | 패키지와 manifest, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | 기존 Node/Electron Inspector에 연결해 스크립트와 실행 컨텍스트 관찰 |
|
||||
| 앱 워크플로 | 7 | 기능 추적, 버전 비교, 반환 구조 비교, 재구현 검증 |
|
||||
| 바이너리 세션 | 21 | 대상 전환, 근거 저장, 프로세스·함수 비교, 미해결 항목 기록 |
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
|
||||
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
|
||||
| macOS 네이티브 | 7 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 |
|
||||
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
|
||||
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 8 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
|
||||
+34
-48
@@ -15,7 +15,7 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/82054/weekly?language=TypeScript" alt="morluto/rea | Trendshift" width="250" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
[快速开始](#快速开始) · [当前状态](#当前状态) · [从二进制到行为](#从二进制到行为) · [调查工具目录](#调查工具目录) · [路线图](#路线图) · [工作原理](#工作原理)
|
||||
|
||||
@@ -71,12 +71,12 @@ REA 让调查始终以二进制证据为依据。它不会声称能恢复原始
|
||||
|
||||
| | |
|
||||
| ---------------- | -------------------------------------------------------------------- |
|
||||
| **为智能体设计** | 直接询问编译后应用的行为,让智能体搜集证据,而不是猜测。 |
|
||||
| **CLI 与 MCP** | 在终端或编程智能体中使用同一套逆向工程能力。 |
|
||||
| **处理复杂流程** | REA 负责工具设置、打开应用、维持调查过程,并在完成后清理资源。 |
|
||||
| **完整工作流** | 从初步概览推进到伪代码、调用关系、类型和实现线索。 |
|
||||
| **为智能体设计** | 直接询问应用的行为,让智能体去检查,而不是猜测。 |
|
||||
| **CLI 与 MCP** | 在终端或智能体中使用同一套逆向工程能力。 |
|
||||
| **引导式设置** | 配置智能体、连接已有分析工具,或在你批准后安装 Hopper。 |
|
||||
| **从洞察到代码** | 理解功能后,在同一次编程会话中构建属于你自己的版本。 |
|
||||
| **本地运行** | 分析在受支持的本机系统上运行;REA 不会把二进制上传到托管式分析服务。 |
|
||||
| **保留上下文** | 连续调查多个二进制文件,无需为每个问题重新开始整个分析过程。 |
|
||||
| **保留上下文** | 连续调查多个应用,无需为每个问题重新开始整个分析过程。 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -98,28 +98,23 @@ Setup 会先展示变更并备份已有配置。要求与更多选项见[安装
|
||||
|
||||
Hopper 支持演示模式。如果首次启动时出现提示,选择演示模式或输入已有许可证。
|
||||
|
||||
### Skill instructions and a first CLI result
|
||||
### AI 编程助手技能(可选)
|
||||
|
||||
为你的 AI 编程助手添加 skill,以获得更丰富的上下文:
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea --skill reverse-engineer-anything
|
||||
```
|
||||
|
||||
This installs instructions, not MCP registration or analysis engines. Follow the
|
||||
[conditional connection guide](skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
||||
`npx rea-agents setup` installs a matching skill and registers selected clients
|
||||
only after showing the plan and receiving approval. Restart/reconnect after
|
||||
registration, then verify REA tools in the active session. Repository skills can
|
||||
be ahead of the [released package](docs/installation.md#released-package-and-main).
|
||||
该 skill 提供 REA 的调查工作流。先运行上文的 setup,把 REA 连接到你的智能体并配置分析工具。Setup 默认会安装版本匹配的 skill;这条命令安装的是仓库版本。
|
||||
|
||||
For your extracted JavaScript/Electron tree or ASAR, no MCP setup or native
|
||||
engine is required:
|
||||
对于已解包的 JavaScript/Electron 应用目录或 ASAR,无需 MCP 配置,也无需原生引擎:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
Replace the path with your target; Windows paths such as `"D:/apps/example"`
|
||||
are supported by this static workflow. Native analysis requires its own engine.
|
||||
把路径换成你的目标(例如 Windows 上的 `"D:/apps/example"`)。这条静态流程支持此类路径。原生分析仍需要先配置对应的分析引擎。
|
||||
|
||||
### 安装 rea 命令
|
||||
|
||||
@@ -138,7 +133,7 @@ npm install --global rea-agents
|
||||
rea setup
|
||||
```
|
||||
|
||||
### 运行要求
|
||||
### 系统要求
|
||||
|
||||
- macOS 12 或更高版本
|
||||
- Ubuntu 24.04+、Fedora 41+ 或 64 位 Arch Linux
|
||||
@@ -150,9 +145,9 @@ Ghidra 支持 Linux x64 和 macOS x64/arm64。单独安装 Ghidra 12.1.4 和完
|
||||
|
||||
Setup 可以验证安装并保存路径,不会安装或升级 Ghidra、Java、Node.js、npm 或 Homebrew。
|
||||
|
||||
Repository main includes experimental Windows x64 Ghidra P0 for native, non-managed, non-DLL x86-64 PE applications on fixed local NTFS. The native controls are implemented; npm 4.0.1 does not contain them. See the [release boundary](docs/installation.md#released-package-and-main) and [Windows Ghidra P0](docs/windows-ghidra-p0.md).
|
||||
仓库 main 分支与 npm 4.1.0 已包含实验性的 Windows x64 Ghidra P0 支持,适用于本地 NTFS 上的原生 x86-64 PE 应用(非托管、非 DLL),并附带 Job Object、私有 DACL 与路径准入控制。请先确认[发布边界](docs/installation.md#released-package-and-main),再假定旧版 npm 包具备该能力。详见 [Windows Ghidra P0](docs/windows-ghidra-p0.md) 的前置条件与已验证范围。
|
||||
|
||||
### 故障排查
|
||||
### 故障排除
|
||||
|
||||
运行 `npx -y rea-agents@latest doctor`,检查主机、依赖、分析工具和智能体配置。该命令不会修改文件。添加 `--json` 可获取结构化诊断。
|
||||
|
||||
@@ -166,21 +161,12 @@ Linux 上的默认 Hopper 启动器为 `/opt/hopper/bin/Hopper`。其他路径
|
||||
|
||||
## 当前状态
|
||||
|
||||
The current repository capabilities and platform requirements are described in
|
||||
[the English support guide](README.md#current-status). Main can be ahead of the
|
||||
[npm release](docs/installation.md#released-package-and-main).
|
||||
仓库当前能力与平台要求见[英文支持指南](README.md#current-status)。main 分支可能领先于 [npm 发布版](docs/installation.md#released-package-and-main)。
|
||||
|
||||
- Ghidra provides 25 read-only operations on Linux x64, macOS x64/arm64, and the
|
||||
experimental Windows x64 P0 boundary. Linux/macOS additionally supports atomic
|
||||
session function annotations. Windows P0 is read-only; Ghidra has no GUI controls.
|
||||
- Static Android inspection has separate bring-your-own JADX/Java prerequisites;
|
||||
real-provider verification covers Linux. See [Android analysis](docs/android-analysis.md).
|
||||
- Browser, Electron, and process requests specify their target, actions, and
|
||||
lifecycle directly. They require the host's actual access, without separate
|
||||
REA permission grants. Setup configuration writes and Hopper installation
|
||||
still require approval of their exact plan.
|
||||
- `rea capabilities` describes binary-session operations rather than every
|
||||
application/runtime tool. Consult the full MCP tool list and relevant guide.
|
||||
- Ghidra 在 Linux x64、macOS x64/arm64 以及实验性的 Windows x64 P0 边界上提供 25 个只读操作。Linux/macOS 额外支持原子化的会话级函数注释。Windows P0 为只读;Ghidra 不提供 GUI 控制。
|
||||
- 静态 Android 检查需要自备 JADX/Java,前置条件独立;真机验证覆盖 Linux。详见 [Android 分析](docs/android-analysis.md)。
|
||||
- 浏览器、Electron 与进程请求需直接声明目标、动作与生命周期,并依赖宿主的实际访问权限,无需 REA 另行授权。Setup 的配置写入与 Hopper 安装仍需逐项批准确切计划。
|
||||
- `rea capabilities` 描述的是二进制会话操作,而非全部应用/运行时工具。请以已连接 MCP 工具列表与相关指南为准。
|
||||
|
||||
## 一个提示词,完成一次完整调查
|
||||
|
||||
@@ -214,20 +200,20 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
|
||||
## 调查工具目录
|
||||
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | -------------------------------------------------------------- |
|
||||
| 二进制检查 | 41 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
|
||||
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
||||
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
||||
| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
|
||||
| 韧体 | 2 | Linux 韧体区域识别与显式解包 |
|
||||
| Android APK | 5 | 包与 manifest 声明、class 搜索、成员清单、方法反编译与静态引用 |
|
||||
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | 连接现有 Node/Electron Inspector,观察脚本与执行上下文 |
|
||||
| 应用工作流 | 7 | 功能追踪、版本比较、返回结构比较与重建验证 |
|
||||
| 二进制会话 | 21 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | ------------------------------------------------------------------------------------------------------ |
|
||||
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
|
||||
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
|
||||
| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 |
|
||||
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
|
||||
| 固件 | 2 | Linux 固件区域检查与显式提取 |
|
||||
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 8 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ kept as an unmodified, commit-pinned Git submodule; see
|
||||
[upstream provenance](../third_party/README.md). CLI and MCP use the same
|
||||
application workflow and return artifact-bound Evidence inline.
|
||||
|
||||
This family is present on repository main and absent from npm 4.0.1. Check the
|
||||
This family is present on repository main and in npm 4.1.0. Check the
|
||||
[released package boundary](installation.md#released-package-and-main) and the
|
||||
connected server's tool list before selecting it.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
graph TD
|
||||
subgraph "Entry Points"
|
||||
CLI["src/cli.ts<br/>One-shot CLI adapter<br/>setup, diagnostics, analysis, decompilation"]
|
||||
MAIN["src/main.ts<br/>MCP stdio server adapter<br/>config, session, process lifetime"]
|
||||
CLI["src/cli.ts + src/cli/<br/>One-shot CLI adapter<br/>setup, diagnostics, analysis, decompilation"]
|
||||
MAIN["src/main.ts + src/main/<br/>MCP stdio server adapter<br/>config, session, process lifetime"]
|
||||
DISPATCH["scripts/rea.mjs<br/>Executable dispatcher<br/>routes mcp / one-shot"]
|
||||
end
|
||||
|
||||
@@ -9,14 +9,14 @@ graph TD
|
||||
SERVER["src/server/<br/>MCP request translation<br/>createServer, tool registration, toolResult"]
|
||||
HOPPER_BRIDGE["bridge/hopper_bridge.py<br/>Runs inside Hopper<br/>adapts operations to Hopper Python API"]
|
||||
GHIDRA_BRIDGE["bridge/ghidra/ReaGhidraBridge.java<br/>Packaged HeadlessScript<br/>inventory, decompiler, references, CFG"]
|
||||
INSTALL["Host installation adapters<br/>macOS: verified vendor DMG to ~/Applications<br/>Linux: verified vendor package + apt/dnf/pacman"]
|
||||
INSTALL["src/cli/ + src/application/Setup*<br/>Host installation adapters<br/>macOS: verified vendor DMG to ~/Applications<br/>Linux: verified vendor package + apt/dnf/pacman"]
|
||||
end
|
||||
|
||||
subgraph "Application Layer"
|
||||
APP["src/application/<br/>Shared CLI/MCP workflows<br/>enhanced analysis and investigations"]
|
||||
SESSION["SessionProviderRouter + BinarySession<br/>one immutable deep binding per target<br/>disjoint auxiliary routing"]
|
||||
REGISTRY["AnalysisProviderRegistry<br/>sorted deep candidates<br/>deterministic selection, no fallback"]
|
||||
JS_RECON["JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
|
||||
JS_RECON["src/application/ + src/artifacts/<br/>JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
|
||||
end
|
||||
|
||||
subgraph "Provider Layers"
|
||||
@@ -25,7 +25,7 @@ graph TD
|
||||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
|
||||
IDA_MCP["External ida-pro-mcp<br/>GUI proxy or database supervisor<br/>upstream installation and engine lifecycle"]
|
||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured script adapters and publication"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
FIRMWARE["src/firmware/<br/>Binwalk / Unblob adapters<br/>owned processes, report normalization, private extraction"]
|
||||
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
||||
@@ -48,7 +48,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Configuration"
|
||||
CONFIG["src/config.ts<br/>Zod-validated env parsing<br/>provider settings and origins"]
|
||||
CONFIG["src/config.ts + src/config/<br/>Zod-validated env parsing<br/>provider settings and origins"]
|
||||
end
|
||||
|
||||
subgraph "Tests"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Website observation with CDP
|
||||
|
||||
Captured Debugger sources can be exported for REA's existing static JavaScript
|
||||
analysis with `export_web_scripts`. See [captured website scripts](website-script-export.md)
|
||||
for the capture options, source mappings, and browser/local resolution limits.
|
||||
|
||||
REA can attach to a user-owned Chrome-family browser through the Chrome DevTools Protocol (CDP) and produce bounded Evidence about an existing page. This is a passive reverse-engineering capability, not a general browser automation or remote-control surface.
|
||||
|
||||
## Shipped surfaces
|
||||
@@ -8,7 +12,7 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally selected storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete structural input-schema summary inline. REA never exposes `WebMCP.invokeTool`; malformed protocol payloads fail explicitly instead of returning incomplete tool metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete structural input-schema summary inline. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. REA never exposes `WebMCP.invokeTool`; malformed protocol payloads fail explicitly instead of returning incomplete tool metadata.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
@@ -120,7 +124,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
|
||||
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free property paths/types, and discarded. `Network.getResponseBody` is never sent unless JSON body shape capture is selected.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
|
||||
|
||||
Frames, resources, scripts, events, and workers outside the selected origin scope are excluded. An explicit `allowed_origins` list can include additional origins; excluded target details are counted without being exposed.
|
||||
@@ -142,10 +146,12 @@ Disallowed-origin entries are filtered before retention.
|
||||
|
||||
CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
## Non-goals and threat model
|
||||
|
||||
The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and returns an image only when requested. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
|
||||
|
||||
This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, include only origins you intend to inspect, and treat other same-user processes as outside this boundary.
|
||||
|
||||
Response metadata normalizes a `Referrer-Policy` fallback list to its last recognized nonempty policy, including mixed-case tokens. Unknown policy names are skipped; malformed token syntax and lists without a recognized policy remain `null`. This reports the response's declared policy, not the effective policy of a document or an inferred browser default.
|
||||
|
||||
@@ -194,6 +194,83 @@ external browser without closing it. The request itself defines the operation;
|
||||
there is no additional REA grant step. The browser and host still enforce their
|
||||
own access and process rules.
|
||||
|
||||
## Network evidence
|
||||
|
||||
`capture.events: ["network"]` retains request, response, completion, failure,
|
||||
and unfinished-request metadata. Select content independently:
|
||||
|
||||
```json
|
||||
{
|
||||
"browser": {
|
||||
"mode": "launch",
|
||||
"executable_path": "/absolute/path/to/chromium"
|
||||
},
|
||||
"start_url": { "url": "http://127.0.0.1:3000" },
|
||||
"actions": [
|
||||
{
|
||||
"step_id": "run",
|
||||
"action": "click",
|
||||
"locator": { "kind": "css", "selector": "#start" },
|
||||
"timeout_ms": 10000
|
||||
},
|
||||
{
|
||||
"step_id": "ready",
|
||||
"action": "wait_for",
|
||||
"locator": { "kind": "css", "selector": "#done" },
|
||||
"state": "visible",
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
],
|
||||
"capture": {
|
||||
"network": {
|
||||
"request_body": true,
|
||||
"response_body": true,
|
||||
"header_values": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All three options default to `false`. Any selected network content also enables
|
||||
network metadata observation; listing `network` in `events` is then optional.
|
||||
Each observed request object receives a capture-scoped `transaction_id`.
|
||||
Requests sharing a URL remain distinct. Redirects link to the preceding
|
||||
transaction only when it was observed; a null predecessor does not establish
|
||||
that no earlier hop existed. Older captures without IDs remain readable and
|
||||
their transaction association is unknown.
|
||||
|
||||
`scenario.network_content` records the resolved content selection. Comparing
|
||||
captures with different selection, or legacy captures without that coverage
|
||||
information, reports network evidence as unknown with a recovery instruction.
|
||||
Capturing more data alone does not establish a website change.
|
||||
|
||||
`network-content` events reference the exact request or response event through
|
||||
`source_event_sequence`. They include independently typed headers and body
|
||||
states. Captured bodies retain base64 bytes, byte count, media type, and SHA-256
|
||||
of the retained bytes. Request bytes are those exposed by Playwright; a
|
||||
`not_exposed` request body does not prove absence or complete multipart file
|
||||
coverage. Response bytes are decoded by the browser, not the compressed wire
|
||||
representation. No later fetch is substituted for the original response.
|
||||
Headers preserve producer order and duplicates. Authorization,
|
||||
Proxy-Authorization, Cookie, and Set-Cookie values are null with
|
||||
`redacted: true`; ordinary values remain intact. Declared UTF-8 secret values
|
||||
and their standard URI/form or JSON-escaped representations are replaced without
|
||||
decoding unrelated binary bytes. Other encodings are not interpreted. Digests describe the
|
||||
retained representation after redaction.
|
||||
|
||||
Only completed responses are read. At the end of the final step, observation
|
||||
stops; requests still in flight emit `request-unfinished`, and their observed
|
||||
responses have explicitly unavailable bodies. This cutoff does not wait for
|
||||
long-lived streaming connections. Already-started content reads settle before
|
||||
browser cleanup, with a provider-owned five-second timeout per read and
|
||||
cancellation support. Failed or unexposed reads retain per-content states and make selected
|
||||
event coverage incomplete. Body events can arrive during later steps or final
|
||||
drain; event bounds describe receipt during the step, while the source event
|
||||
reference establishes the transaction phase. Comparison attributes content to
|
||||
its source event's step and excludes the sequence reference from normalization.
|
||||
Playwright scenario capture does not supply initiator stacks; passive CDP
|
||||
observation retains its own initiator evidence.
|
||||
|
||||
## Scenario comparison
|
||||
|
||||
`compare_web_captures` and `compare-web-captures` also accept two complete
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Incremental capability migration
|
||||
|
||||
This working guide tracks the migration in #740. Update it as concrete domains
|
||||
migrate. Existing public contracts and domain-specific ports remain authoritative;
|
||||
there is no new universal provider interface or fixed module manifest.
|
||||
|
||||
## Ownership to preserve
|
||||
|
||||
| Responsibility | Current owner | Migration direction |
|
||||
| --------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- |
|
||||
| Analyst semantics and Evidence representation | domain | Keep pure and provider-neutral |
|
||||
| Named public input/output contracts | contracts | Keep exact CLI/MCP meaning and complete discovery |
|
||||
| Producer parsing and external tool protocols | provider adapters | Keep behind the relevant typed port |
|
||||
| Shared analyst workflows | application | Share between CLI and MCP |
|
||||
| Concrete provider construction | binary runtime, composition, MCP startup | Consolidate one capability at a time in production composition |
|
||||
| Investigation Evidence and Unknowns | BinarySessionRecords | Extract one composed record owner after caller migration |
|
||||
| Target/profile snapshots and invalidation | BinarySessionRecords/BinarySession | Keep binary-owned |
|
||||
| Subprocess ownership and host primitives | process/windows | Reuse; preserve cancellation and cleanup |
|
||||
| Public translation | CLI/MCP adapters | Delegate to shared workflows and named contracts |
|
||||
|
||||
Retain exact Evidence IDs, provenance, detached reads, atomic Evidence/Unknown
|
||||
mutations and optimistic revisions. Preserve current close and failed-cleanup
|
||||
record clearing while extracting ownership; #721 owns any retention-policy change.
|
||||
Each CLI invocation and MCP connection keeps its own state. Provider registration
|
||||
must not launch engines or install prerequisites.
|
||||
|
||||
## Migrate before generalizing
|
||||
|
||||
1. Record the actual callers, state, environment, protocol and verification owner
|
||||
of the capability being migrated.
|
||||
2. Move concrete construction behind its existing typed port. Preserve injected
|
||||
ports, selected environment, queues, failure state and cleanup behavior.
|
||||
3. Test both caller paths and the applicable package/real-provider boundary.
|
||||
4. Use independently migrated capabilities to identify repeated metadata and
|
||||
composition needs. Derive a small source declaration only when those needs
|
||||
are established; keep unlike target/lifecycle semantics separate.
|
||||
5. Move a coherent capability's files, fixtures and verification lanes in a
|
||||
mechanical PR. Update all path-sensitive consumers in that same PR.
|
||||
|
||||
Pure helpers do not need an interface each. A capture-format adapter or typed
|
||||
factory may be sufficient. Avoid generic JSON execution, external MCP catalog
|
||||
passthrough, eager provider acquisition, new permission fields or a multi-package
|
||||
rewrite. Optional loading should preserve successful peers and report the failed
|
||||
capability without hiding tools from the catalog.
|
||||
|
||||
## Checks before and after a move
|
||||
|
||||
`npm run verify:test-discovery` compares actual Vitest file discovery with every
|
||||
repository-owned `*.test.ts` under src and tests, including newly added files. The
|
||||
check requires Git, includes untracked non-ignored tests, and excludes removed
|
||||
paths during a move. It
|
||||
rejects missing files and overlapping project ownership. It only lists files;
|
||||
it does not replace behavior tests or real-provider verification.
|
||||
|
||||
Check imports, project isolation and coverage, development-test selection,
|
||||
lint exceptions, catalog/generator imports, npm and CI verification entrypoints,
|
||||
fixture ownership, and installed-package bridge/native path resolution.
|
||||
Domain and contracts must not import application workflows, caller adapters or
|
||||
provider implementations. Production composition is a deliberate outer boundary.
|
||||
Other existing adapter imports will migrate incrementally; do not widen a lint
|
||||
exception to conceal new protocol code inside application workflows.
|
||||
|
||||
Select verification by the claim changed: schema/composition, package, real
|
||||
engine or host-native workflow. Record host/target prerequisites and source-owned
|
||||
fixtures. Run heavy lanes serially with bounded workers/heaps and reuse existing
|
||||
engines. Recording ports do not prove real provider or platform support.
|
||||
|
||||
## Extension examples to validate during migration
|
||||
|
||||
An implementation of an existing port should require a provider adapter, typed
|
||||
production factory selection, exact producer parsing/provenance and matching
|
||||
verification. Existing workflow meaning and public contracts should stay stable.
|
||||
|
||||
A new analyst capability deliberately adds semantics, named contracts, a typed
|
||||
port/workflow when needed, both caller adapters, availability, documentation and
|
||||
verification. Its integration is broader than swapping a provider. The migration
|
||||
should eliminate incidental duplicate wiring, not erase those design decisions.
|
||||
|
||||
Update these examples with real migrated entrypoints before closing #740.
|
||||
|
||||
## First production factory migration
|
||||
|
||||
`src/composition/android.ts` and `src/composition/firmware.ts` construct providers
|
||||
behind the existing AndroidAnalysisPort and FirmwareAnalysisPort. CLI registrars
|
||||
pass their selected environment; MCP uses the process environment unless a caller
|
||||
injects a port. Each factory call returns a fresh instance and acquires no process,
|
||||
workspace or toolchain. Queues, cleanup-failure state and per-request cleanup stay
|
||||
inside the provider. The existing launcher seams remain available to boundary
|
||||
fixtures, which now exercise the production factories.
|
||||
|
||||
Application workflows and MCP registration must not import these concrete
|
||||
providers. Composition may import their implementations. Binary composition is
|
||||
still at its existing entrypoint while its direct-analysis callers are investigated.
|
||||
This concrete pilot does not introduce a shared provider interface or module
|
||||
manifest.
|
||||
@@ -68,13 +68,12 @@ selects the npm release, and persistent MCP registrations are pinned to the
|
||||
version that performed setup. Installing newer instructions does not update a
|
||||
running server or its registration.
|
||||
|
||||
The release checked on **2026-10-06** was **4.0.1** (116 MCP tools). That artifact
|
||||
has static JavaScript inspection and full inline Evidence workflows, but does
|
||||
not include main's Windows native control bundle, Android/JADX or firmware
|
||||
tools, Ghidra function annotation workflow, or retained application-Evidence
|
||||
The release checked on **2026-10-06** was **4.1.0** (125 MCP tools). That artifact
|
||||
includes main's Windows native control bundle, Android/JADX and firmware
|
||||
tools, Ghidra function annotation workflow, and retained application-Evidence
|
||||
references.
|
||||
Main's catalog describes those additions; a source build or a subsequent
|
||||
release containing them is required. Package startup alone does not verify a
|
||||
Main's catalog describes the current code; a source build or a subsequent
|
||||
release containing later changes is required for anything newer. Package startup alone does not verify a
|
||||
provider's real platform workflow.
|
||||
|
||||
To check the published version, run `npm view rea-agents dist-tags.latest`.
|
||||
|
||||
@@ -55,6 +55,16 @@ registrations/removals and dispatch, Node timers and cancellation handles, async
|
||||
defaults, request construction and response consumers, parse/coercion/
|
||||
validation boundaries, and built-in resource acquisition/release.
|
||||
|
||||
Mutation tracking covers explicit member assignments, updates, deletion, and
|
||||
loop assignment targets, including the supported local aliases and shared
|
||||
nested references. Calls such as `Object.assign`, `Reflect.set`, and
|
||||
`Reflect.deleteProperty`, and writes reaching a caller's object through another
|
||||
function's parameter, are not tracked by this mutation pass. These channels
|
||||
can leave an initializer-derived literal in the graph even after the runtime
|
||||
value changes. Treat such a result as an uncovered mutation channel, not as
|
||||
proof of the current runtime value; a follow-up needs to model that channel
|
||||
and verify its caller/alias behavior.
|
||||
|
||||
Function fingerprints commit normalized syntax, control-flow shape, relation
|
||||
shape, literal sets, arity, and detected effects without using local names or
|
||||
source offsets. Equal duplicate fingerprints remain ambiguous. Dynamic
|
||||
@@ -164,7 +174,7 @@ in `application`, or `left`/`right` for comparisons. `RETURNED_ID` is a template
|
||||
not a literal valid ID. Native Evidence arrays still use complete records.
|
||||
References are scoped to one connection; `close_binary` clears them. Export a
|
||||
bundle before closing and import it on another connection, or supply the full
|
||||
inline Evidence there. npm 4.0.1 accepts full inline Evidence only; installing
|
||||
inline Evidence there. Versions before 4.1.0 accept full inline Evidence only; installing
|
||||
newer skill instructions does not change that schema. See
|
||||
[MCP Evidence inputs](mcp-contracts.md#retained-application-evidence-inputs).
|
||||
|
||||
|
||||
@@ -90,6 +90,51 @@ coverage, and route while retaining the outer bundle commitment.
|
||||
| Obfuscated assembly | Same positive byte observations as its underlying deployment form | Meaning inferred only from names |
|
||||
| Malformed/unsupported | Exact admitted regions and failure locations | Completeness, successfully skipped rows, or semantics beyond the admitted parser boundary |
|
||||
|
||||
### NativeAOT metadata recovery
|
||||
|
||||
NativeAOT removes the ordinary CIL method bodies, so a CIL decompiler cannot
|
||||
reconstruct those bodies as C# source. The executable remains a native target
|
||||
for Hopper or Ghidra, where decompilation produces pseudocode and analyst
|
||||
inference. This is a different route from ordinary managed member inspection.
|
||||
|
||||
NativeAOT is not limited to Windows `.exe` files. The native image can be PE,
|
||||
ELF, or Mach-O, and NativeAOT can also publish shared libraries (PE DLL, ELF
|
||||
`.so`, or Mach-O `.dylib`) with explicitly exported entry points. Identify the
|
||||
image from its bytes and loader metadata, not its filename extension. When the
|
||||
image is inside an application bundle, inventory or extract the bundle first
|
||||
and carry the component identity into native analysis. Matching PDB, ELF debug,
|
||||
or dSYM sidecars can add symbol evidence when the selected provider supports
|
||||
them; verify their identity and keep them separate from observations made from
|
||||
the executable itself. [Microsoft's NativeAOT overview](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/)
|
||||
lists supported OS/architecture targets and deployment limitations, while its
|
||||
[native library guide](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/libraries)
|
||||
describes exported shared-library entry points.
|
||||
|
||||
The [Ghidra NativeAOT analyzer](https://github.com/Washi1337/ghidra-nativeaot)
|
||||
is a useful optional companion for recovering ReadyToRun metadata. It
|
||||
rehydrates the `DEHYDRATED_DATA` section and annotates method tables, type
|
||||
relationships, vtable slots, frozen objects, and strings. It does not recover
|
||||
original C# method bodies. It is a separate Ghidra extension and interactive
|
||||
metadata browser; REA does not currently install or invoke it through its
|
||||
headless bridge. Its README describes ReadyToRun header discovery, including
|
||||
symbol-based and heuristic paths, so a missed header or unsupported binary
|
||||
should remain an explicit limitation rather than a failed claim about the
|
||||
binary's contents.
|
||||
|
||||
The upstream analyzer currently gates its own analysis to x86-64. NativeAOT's
|
||||
broader platform matrix does not establish REA coverage: PE/COFF, ELF, Mach-O,
|
||||
shared libraries, target architectures, and runtime metadata versions each need
|
||||
their own provider verification. REA's experimental Windows Ghidra boundary is
|
||||
limited to admitted native x86-64 PE applications, so it does not currently
|
||||
establish coverage for PE DLLs. Mobile NativeAOT targets should remain
|
||||
experimental until REA has a matching provider and package-level verification.
|
||||
|
||||
For REA's native analysis, preserve the same artifact path and digest used by
|
||||
`inspect_managed_artifact`, and report recovered type data as provider
|
||||
observations. Linking those types to native function addresses requires verified
|
||||
provider evidence; names or vtable similarity alone do not prove that a native
|
||||
function implements a specific managed method.
|
||||
|
||||
## Evidence record shape
|
||||
|
||||
Every planned operation returns a provider result and Evidence with four
|
||||
|
||||
@@ -6,6 +6,12 @@ coverage and unknowns. The CLI and MCP use the same application workflows.
|
||||
|
||||
## Static inspection
|
||||
|
||||
`inspect_macho` retains otool segment `file_offset` values relative to the
|
||||
selected Mach-O slice. Its evidence file-offset ranges address the original
|
||||
input file and include the observed lipo slice offset for universal binaries.
|
||||
If that offset is unavailable, segment evidence locations are omitted with an
|
||||
explicit limitation; architecture inventory locations remain available.
|
||||
|
||||
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
|
||||
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
|
||||
rendition fields, pagination and exact UI resource-name matches are returned.
|
||||
|
||||
@@ -12,6 +12,24 @@ sandbox. Filesystem observation paths select what REA snapshots; they do not
|
||||
restrict what the process can read or write. The inherited environment is not
|
||||
recorded, so its influence may remain unknown.
|
||||
|
||||
## Host support
|
||||
|
||||
Capturing a new scenario currently requires Linux or macOS and a working
|
||||
native PTY backend. Native Windows capture is unavailable because the PTY
|
||||
adapter does not yet verify descendant cleanup. The native Job Object controls
|
||||
used by other REA providers do not establish PTY capture support. Reinstalling
|
||||
the Windows PTY binary does not enable this workflow.
|
||||
|
||||
For Linux commands, use Linux REA inside WSL. Adapt the scenario to that host;
|
||||
this does not establish capture of a native Windows process tree. Comparing
|
||||
existing capture Evidence through the CLI or MCP remains available on Windows
|
||||
and does not launch a PTY or the captured target.
|
||||
|
||||
On supported capture hosts, a missing or incompatible native PTY binary has a
|
||||
different recovery: reinstall REA for the active platform, architecture, and
|
||||
Node.js version with optional dependencies enabled. Capability diagnostics
|
||||
distinguish this from the Windows capture-adapter limitation.
|
||||
|
||||
## Capture a command
|
||||
|
||||
Write a JSON scenario and pass its path to the CLI:
|
||||
@@ -22,6 +40,8 @@ rea capture-process ./scenario.json --json > capture.json
|
||||
|
||||
`--json` is required when saving input for JSON consumers; the default terminal
|
||||
format is TOON. The file contains the complete capture Evidence record.
|
||||
Choose an output file distinct from the scenario input: shell redirection opens
|
||||
and truncates the output before REA reads the scenario.
|
||||
|
||||
For example:
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 125,
|
||||
"total": 126,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -179,13 +179,14 @@
|
||||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 7,
|
||||
"count": 8,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"compare_application_versions",
|
||||
"compare_javascript_export_shapes",
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_coverage",
|
||||
"export_web_scripts",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics"
|
||||
]
|
||||
@@ -450,6 +451,12 @@
|
||||
"observe_javascript_runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-web-script-export",
|
||||
"name": "REA captured script export",
|
||||
"version": "1",
|
||||
"capabilities": ["export_web_scripts"]
|
||||
},
|
||||
{
|
||||
"id": "unblob",
|
||||
"name": "Unblob",
|
||||
@@ -532,7 +539,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 80,
|
||||
"primary_count": 81,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -562,6 +569,7 @@
|
||||
"evaluate-reconstruction-coverage",
|
||||
"evidence-export",
|
||||
"evidence-import",
|
||||
"export-web-scripts",
|
||||
"extract-artifact",
|
||||
"extract-firmware",
|
||||
"function",
|
||||
@@ -624,15 +632,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 80,
|
||||
"mcp_tools": 125,
|
||||
"cli_commands": 81,
|
||||
"mcp_tools": 126,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "eec3abb8fa5b25c6e5d6e361b7ebd157e3599f69b68defda85680dbfe061b3d3",
|
||||
"tools_sha256": "569db1182f19869a3bdb22cf217bc75526f317b59635c8866d0e62741077cd52",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b",
|
||||
"providers_sha256": "044634314b36b966bd918f99f116f4684108556ac1dabc912e76ccd351d97e3e"
|
||||
"combined_sha256": "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55",
|
||||
"providers_sha256": "7949ddcf2eb5ba41b022bb3dfa1951ab96dbf5640285fa7fd5d35b0e4a5bdab3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,27 @@ regressions until a real fixture establishes equivalent coverage.
|
||||
decoding. Its SDK client explicitly permits the larger inline JSON response;
|
||||
this lane does not establish large image-comparison request transport coverage.
|
||||
|
||||
`verify:browser:network` is a focused real-browser lane for transaction identity,
|
||||
selected request/response bytes, binary and compressed responses, duplicate
|
||||
headers, credential and declared-secret redaction, redirects, streaming cutoff,
|
||||
CLI/MCP parity, and owned-profile cleanup. Set `REA_BROWSER_EXECUTABLE` to an
|
||||
installed Chrome-family browser. An optional script argument selects an already
|
||||
installed package's `scripts/rea.mjs` entry point for packaged-artifact checks.
|
||||
The complete `verify:browser` lane includes these same checks.
|
||||
|
||||
After building, `verify:browser:dom` checks empty and HTML-whitespace form
|
||||
destinations against a native Chrome DOM-property oracle through CLI and stdio
|
||||
MCP. It requires `REA_BROWSER_EXECUTABLE` and accepts an optional installed REA
|
||||
entrypoint. The full browser lane includes the same public-adapter assertions
|
||||
before other fixtures navigate the selected page.
|
||||
|
||||
`verify:browser:scripts` checks active script capture → exact-byte export →
|
||||
existing static JavaScript analysis through CLI and stdio MCP, including
|
||||
manifest readback, competing query variants, and resolved relative imports.
|
||||
It uses an installed browser and accepts an optional installed REA entrypoint.
|
||||
The complete `verify:browser` lane also exercises passive script export through
|
||||
both public adapters. See [website script export](website-script-export.md).
|
||||
|
||||
The [test suite audit](test-suite-audit.md) records the pruning decisions,
|
||||
replacement evidence and remaining priorities.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_fbd47cbd3815d7e252ebcb60c4d086dcc1fc6b63f020e83513e28bf4d94c732c",
|
||||
"manifest_id": "ecm_d68a1575bdaa1a044ccce8e27c5dd335612f909bc81c52ce3089b576fd772940",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "b317babebb82157b46b4dff698a170045490bcb21aaf9af582e20908ba2bb998"
|
||||
"sha256": "d2d971cc67bb0f7b80b4e39382a6bd1146445ed1dbf52043f89e2da2985c97ba"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Captured website scripts and static JavaScript analysis
|
||||
|
||||
`export_web_scripts` bridges retained browser sources into the existing local
|
||||
JavaScript analyzer. Both CLI and MCP use the same workflow. It reads a saved
|
||||
`inspect_web_page` or `capture_browser_scenario` capture and writes scripts plus a
|
||||
verified manifest into one caller-selected, absent directory.
|
||||
|
||||
## Capture and export
|
||||
|
||||
For passive inspection, select `include_script_sources: true` (CLI:
|
||||
`--include-script-sources`). For scenarios, select
|
||||
`capture.network.response_body: true`. Save either the complete Evidence record
|
||||
or its normalized result as UTF-8 JSON. The compact MCP wrapper's `evidence`
|
||||
field contains the complete Evidence record.
|
||||
|
||||
```sh
|
||||
rea export-web-scripts /analysis/capture.json /analysis/exported-scripts --json
|
||||
rea analyze-javascript-application /analysis/exported-scripts/files --json
|
||||
```
|
||||
|
||||
MCP uses `export_web_scripts` with `capture_path` and `output_directory` absolute
|
||||
filesystem paths. Its inline result includes `analysis_input`, which can be
|
||||
passed directly to `analyze_javascript_application`. When no script bytes are
|
||||
exportable, `analysis_input` is null and the manifest retains the unavailable
|
||||
sources and their reasons. Capture again with the corresponding content option.
|
||||
|
||||
The destination must not exist. Use a new directory for each capture; an
|
||||
existing directory or symlink is rejected without overwriting its contents.
|
||||
REA creates private output, verifies every written file by digest and durable
|
||||
readback, and removes its own partial output on failure or cancellation.
|
||||
A failed rollback reports residual paths.
|
||||
|
||||
## Sources and mappings
|
||||
|
||||
Every source is returned inline with its URL and its script/frame identity or
|
||||
request transaction and event sequence references. The result also contains
|
||||
the input capture's byte digest, original coverage, source Evidence link when
|
||||
provided, and the persisted manifest's digest and byte count. A saved Evidence
|
||||
envelope must have a valid semantic identifier and matching operation.
|
||||
|
||||
Exported `relative_path` values are relative to `analysis_input.input_path`.
|
||||
The persisted `manifest.json` contains the same inline manifest fields, excluding
|
||||
its own descriptor. It does not duplicate script bytes.
|
||||
|
||||
- Passive sources are the Debugger-exposed text encoded as UTF-8, with the
|
||||
verified source digest and source-map declaration retained. Unavailable or
|
||||
explicitly non-JavaScript sources remain unavailable.
|
||||
- Scenario sources use observed requests classified as `script`, matched to
|
||||
completed response bytes by transaction and event identity. URLs never select
|
||||
the response. Binary and empty retained bytes are written exactly.
|
||||
- Ordinary query-free `.js`, `.mjs`, and `.cjs` URL paths are arranged under a
|
||||
directory for each origin, preserving their relative module layout when it
|
||||
is unambiguous.
|
||||
- Competing versions, query/fragment variants, case and file/directory prefix
|
||||
collisions, inline sources, and unrepresentable paths use unique isolated
|
||||
names with a stated reason. Missing competing sources also prevent selection
|
||||
of an apparently canonical version.
|
||||
|
||||
## Interpretation and limits
|
||||
|
||||
Export does not fetch URLs or execute JavaScript. Retained Debugger sources and
|
||||
response bytes do not establish execution or a complete website. Response bytes
|
||||
are browser-decoded bytes; they do not represent transfer encoding. Declared
|
||||
redaction is preserved and can change JavaScript syntax; passive captures do
|
||||
not report a byte-redaction flag, so that value remains unknown.
|
||||
|
||||
The existing analyzer inventories and parses the actual exported files. It can
|
||||
resolve ordinary captured relative modules, but does not implement browser
|
||||
import maps, root-relative URL loading, remote imports, or runtime behavior.
|
||||
Its local resolver may strip import queries/fragments; isolated variants are
|
||||
kept away from canonical paths to prevent those from selecting a different
|
||||
observed version. Missing dependencies remain unknown. Source maps are not
|
||||
fetched or reconstructed during export.
|
||||
|
||||
The export Evidence describes a derivation from the saved capture. Subsequent
|
||||
application-analysis Evidence describes the local exported artifact. Their
|
||||
identities and authorities remain separate.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run verify:browser:scripts` exercises active capture, exact source export,
|
||||
manifest readback, query variants, and relative-module analysis through a real
|
||||
browser, CLI, and stdio MCP. Set `REA_BROWSER_EXECUTABLE` to an installed
|
||||
Chrome-family browser. The verifier accepts an optional installed REA entrypoint
|
||||
argument for package verification. `npm run verify:browser` additionally covers
|
||||
passive `inspect_web_page` export through both public adapters.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Windows Ghidra P0
|
||||
|
||||
This guide describes repository main. npm 4.0.1 does not include the Windows
|
||||
This guide describes repository main. npm 4.1.0 includes the Windows
|
||||
native bundle; check the [release boundary](installation.md#released-package-and-main)
|
||||
before applying these instructions to a published package.
|
||||
before applying these instructions to an older published package.
|
||||
|
||||
Status: experimental Windows x64 support for the read-only P0 boundary. Windows
|
||||
package builds bundle native process, filesystem, and DACL controls. REA
|
||||
@@ -32,6 +32,10 @@ REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
|
||||
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
|
||||
PyGhidra are not prerequisites. Users do not build the native addon.
|
||||
|
||||
REA selects its packaged bridge scripts by explicit path. Same-named files or
|
||||
directories in the caller's working directory do not select a different
|
||||
script or require users to clean that directory before analysis.
|
||||
|
||||
## Configuration
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -66,6 +66,11 @@ Windows failures retain their constraint, requested coordinate, Win32 code,
|
||||
and system message. Unsupported filesystems and reparse paths are distinct
|
||||
from OS access denial and missing packaged controls.
|
||||
|
||||
Ordinary drive-absolute paths accept backslashes, forward slashes, or mixed
|
||||
separators. File identity retains the original requested spelling. Separator
|
||||
translation does not resolve dot components, repeated separators, or extend
|
||||
the supported device/UNC namespaces; existing admission checks still apply.
|
||||
|
||||
The bearer-token descriptor remains under its immutable private lease until
|
||||
`runtime_close` on Windows; POSIX removes the descriptor after the bridge reads
|
||||
it. Job ownership guarantees process termination on owner death, not deletion
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
|
||||
namespace rea {
|
||||
|
||||
// Translate ordinary DOS drive separators without resolving components or
|
||||
// changing extended, device, UNC, relative, or drive-relative namespaces.
|
||||
inline std::wstring ordinaryDriveSeparators(std::wstring path) {
|
||||
if (path.size() >= 3 && path[1] == L':' &&
|
||||
((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')) &&
|
||||
(path[2] == L'\\' || path[2] == L'/'))
|
||||
std::replace(path.begin(), path.end(), L'/', L'\\');
|
||||
return path;
|
||||
}
|
||||
|
||||
struct Failure : std::runtime_error {
|
||||
DWORD win32;
|
||||
std::wstring path;
|
||||
|
||||
@@ -14,6 +14,7 @@ static std::wstring hex(const unsigned char* bytes, size_t count) {
|
||||
}
|
||||
|
||||
static std::wstring localPath(std::wstring path) {
|
||||
path = ordinaryDriveSeparators(std::move(path));
|
||||
if (path.rfind(L"\\\\?\\", 0) == 0) path.erase(0, 4);
|
||||
require(path.size() >= 3 && path[1] == L':' && path[2] == L'\\' &&
|
||||
((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')),
|
||||
|
||||
@@ -44,7 +44,8 @@ static void pipe(Handle& read, Handle& write) {
|
||||
|
||||
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment) {
|
||||
require(command.size() >= 3 && command[1] == L':' && command[2] == L'\\',
|
||||
const auto executable = ordinaryDriveSeparators(command);
|
||||
require(executable.size() >= 3 && executable[1] == L':' && executable[2] == L'\\',
|
||||
"Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER);
|
||||
auto result = std::make_unique<Process>();
|
||||
result->job = createJob();
|
||||
@@ -102,7 +103,7 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
|
||||
if (uniqueEnvironment.empty()) variables.push_back(L'\0');
|
||||
std::vector<wchar_t> line(commandLine.begin(), commandLine.end()); line.push_back(L'\0');
|
||||
PROCESS_INFORMATION process{};
|
||||
require(CreateProcessW(command.c_str(), line.data(), nullptr, nullptr, TRUE,
|
||||
require(CreateProcessW(executable.c_str(), line.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW,
|
||||
variables.data(), cwd.empty() ? nullptr : cwd.c_str(), &startup.StartupInfo, &process),
|
||||
"Create process atomically inside owned Job Object failed", command);
|
||||
|
||||
Generated
+7
-7
@@ -2659,9 +2659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||
"version": "0.9.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
|
||||
"integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
@@ -2846,15 +2846,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
|
||||
+5
-1
@@ -132,6 +132,8 @@
|
||||
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
|
||||
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
|
||||
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
|
||||
"verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs",
|
||||
"verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs",
|
||||
"verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs",
|
||||
"verify:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs",
|
||||
"verify:javascript-runtime": "npm run build:cached && node scripts/verify-local-javascript-runtime.mjs",
|
||||
@@ -173,7 +175,9 @@
|
||||
"verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs",
|
||||
"build:ghidra:nativeaot": "node scripts/build-ghidra-nativeaot.mjs",
|
||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs"
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
|
||||
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs",
|
||||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
export const networkFixtureSecret = "私密-network-fixture";
|
||||
export const networkFixtureBinary = Buffer.from([0xff, 0, 0xfe, 1]);
|
||||
export const networkFixtureDecoded = Buffer.from("decoded-response-marker");
|
||||
|
||||
const html = `<!doctype html><meta charset="utf-8"><body><button id="start">Run</button><main id="done" hidden>Done</main>
|
||||
<script>document.querySelector('#start').onclick = async () => {
|
||||
const send = marker => fetch('/same', {method:'POST', headers:{
|
||||
'Content-Type':'application/json', 'Authorization':'Bearer transport-only', 'X-Marker': marker
|
||||
}, body:JSON.stringify({marker, password:'ordinary-selected-value', declared:'${networkFixtureSecret}'})}).then(r=>r.text());
|
||||
await Promise.all([send('slow'),send('fast'),fetch('/redirect').then(r=>r.text()),
|
||||
fetch('/binary').then(r=>r.arrayBuffer()),fetch('/compressed').then(r=>r.text()),
|
||||
fetch('/empty').then(r=>r.text()),
|
||||
fetch('/form', {method:'POST', body:new URLSearchParams({declared:'${networkFixtureSecret}'})}).then(r=>r.text())]);
|
||||
await fetch('/stream');
|
||||
document.querySelector('#done').hidden=false;
|
||||
};</script>`;
|
||||
|
||||
/** Start an isolated fixture with concurrent, redirected, binary, and streaming responses. */
|
||||
export async function startBrowserNetworkSite() {
|
||||
const counts = new Map();
|
||||
const timers = new Set();
|
||||
const server = createServer(async (request, response) => {
|
||||
const path = new URL(request.url, "http://fixture.test").pathname;
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
if (path === "/same") {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timers.delete(timer);
|
||||
response.writeHead(200, [
|
||||
"Content-Type",
|
||||
"application/json",
|
||||
"X-Repeat",
|
||||
"first",
|
||||
"X-Repeat",
|
||||
"second",
|
||||
"Set-Cookie",
|
||||
"fixture_session=transport-cookie; HttpOnly",
|
||||
"X-Declared",
|
||||
encodeURIComponent(networkFixtureSecret),
|
||||
]);
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
marker: body.marker,
|
||||
password: body.password,
|
||||
declared: body.declared,
|
||||
}),
|
||||
);
|
||||
},
|
||||
body.marker === "slow" ? 120 : 5,
|
||||
);
|
||||
timers.add(timer);
|
||||
} else if (path === "/redirect") {
|
||||
response.writeHead(302, { Location: "/landing" });
|
||||
response.end();
|
||||
} else if (path === "/landing") {
|
||||
response.end("redirect-target");
|
||||
} else if (path === "/form") {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
response.end(
|
||||
new URLSearchParams(Buffer.concat(chunks).toString()).get("declared"),
|
||||
);
|
||||
} else if (path === "/binary") {
|
||||
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
response.end(networkFixtureBinary);
|
||||
} else if (path === "/compressed") {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Encoding": "gzip",
|
||||
});
|
||||
response.end(gzipSync(networkFixtureDecoded));
|
||||
} else if (path === "/empty") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
} else if (path === "/stream") {
|
||||
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||
response.write("data: still-open\n\n");
|
||||
} else if (path === "/favicon.ico") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
} else {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(html);
|
||||
}
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || address === null)
|
||||
throw new Error("Network fixture has no TCP address");
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
counts,
|
||||
async close() {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/** Independently authored modules and competing query variants for export verification. */
|
||||
export async function startBrowserScriptSite() {
|
||||
const assets = new Map([
|
||||
[
|
||||
"/app/main.js",
|
||||
"import { sourceMarker } from './lib/dep.js'; export const result = sourceMarker; document.body.dataset.ready = 'true';\n",
|
||||
],
|
||||
[
|
||||
"/app/lib/dep.js",
|
||||
"export const sourceMarker = 'independent-source-marker';\n",
|
||||
],
|
||||
["/variant.js?v=1", "globalThis.variantOne = 'query-variant-one';\n"],
|
||||
["/variant.js?v=2", "globalThis.variantTwo = 'query-variant-two';\n"],
|
||||
]);
|
||||
const counts = new Map();
|
||||
const server = createServer((request, response) => {
|
||||
counts.set(request.url, (counts.get(request.url) ?? 0) + 1);
|
||||
const source = assets.get(request.url);
|
||||
if (source !== undefined) {
|
||||
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
||||
response.end(source);
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(
|
||||
'<!doctype html><html><body><script src="/variant.js?v=1"></script><script src="/variant.js?v=2"></script><script type="module" src="/app/main.js"></script></body></html>',
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Script fixture did not bind a port");
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
assets,
|
||||
counts,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((cause) =>
|
||||
cause === undefined ? resolve() : reject(cause),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -52,8 +52,12 @@ export async function startBrowserVerifierSite() {
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.setHeader("set-cookie", [
|
||||
"rea-root-cookie=root-fixture; Path=/; SameSite=Lax",
|
||||
"rea-app-cookie=app-fixture; Path=/app; SameSite=Lax",
|
||||
]);
|
||||
response.end(
|
||||
'<!doctype html><html><head><title>REA browser verifier</title></head><body><main><h1>Browser evidence</h1><button aria-label="ax-private-label-value">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
|
||||
'<!doctype html><html><head><title>REA browser verifier</title><base href="assets/"></head><body><main><h1>Browser evidence</h1><a href="guide">Guide</a><button aria-label="ax-private-label-value">Verify</button><form action=""></form><form action=" "></form><button formaction=""></button><input formaction=""></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
|
||||
);
|
||||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { assertFormDestinations } from "./browser-verifier-assertions.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Verify the real fixture's form destinations through CLI and stdio MCP. */
|
||||
export async function verifyBrowserDomDestinations(
|
||||
attachment,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
documentUrl,
|
||||
) {
|
||||
const env = { ...process.env, REA_LOG_LEVEL: "silent" };
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-page",
|
||||
attachment.cdp_endpoint,
|
||||
attachment.target_id,
|
||||
"--observation-ms",
|
||||
"100",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
assertFormDestinations(
|
||||
parseEvidence(JSON.parse(stdout)).normalized_result,
|
||||
documentUrl,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "browser-dom-destinations", version: "1" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_web_page",
|
||||
arguments: { ...attachment, observation_ms: 100 },
|
||||
});
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
assertFormDestinations(
|
||||
parseEvidence(JSON.parse(mcpTextValue(result)).evidence)
|
||||
.normalized_result,
|
||||
documentUrl,
|
||||
);
|
||||
return { cli: true, stdio_mcp: true, current_document_destinations: 4 };
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
networkFixtureBinary,
|
||||
networkFixtureDecoded,
|
||||
networkFixtureSecret,
|
||||
} from "../fixtures/browser-network-site.mjs";
|
||||
|
||||
const retained = (event) => {
|
||||
assert.equal(event?.kind, "network-content");
|
||||
assert.equal(event.body.state, "captured", JSON.stringify(event.body));
|
||||
return Buffer.from(event.body.content, "base64");
|
||||
};
|
||||
|
||||
/** Verify association against server-authored markers rather than provider-derived expectations. */
|
||||
export function assertBrowserNetworkEvidence(
|
||||
capture,
|
||||
origin,
|
||||
expectedCleanup = "terminated-owned-process",
|
||||
) {
|
||||
assert.ok(capture.steps.every((step) => step.status === "completed"));
|
||||
assert.equal(capture.browser.cleanup, expectedCleanup);
|
||||
assert.deepEqual(capture.scenario.network_content, {
|
||||
request_body: true,
|
||||
response_body: true,
|
||||
header_values: true,
|
||||
});
|
||||
assert.equal(capture.completeness.equality_eligible, false);
|
||||
assert.ok(!JSON.stringify(capture).includes(networkFixtureSecret));
|
||||
const events = capture.events.items;
|
||||
const content = events.filter((event) => event.kind === "network-content");
|
||||
const requestEvents = events.filter((event) => event.kind === "request");
|
||||
const sameUrlRequests = requestEvents.filter(
|
||||
(event) => event.url.url === `${origin}/same`,
|
||||
);
|
||||
assert.equal(sameUrlRequests.length, 2);
|
||||
assert.equal(
|
||||
new Set(sameUrlRequests.map((event) => event.transaction_id)).size,
|
||||
2,
|
||||
);
|
||||
const markers = [];
|
||||
for (const request of sameUrlRequests) {
|
||||
const requestContent = content.find(
|
||||
(event) =>
|
||||
event.phase === "request" &&
|
||||
event.transaction_id === request.transaction_id,
|
||||
);
|
||||
const responseContent = content.find(
|
||||
(event) =>
|
||||
event.phase === "response" &&
|
||||
event.transaction_id === request.transaction_id,
|
||||
);
|
||||
const input = JSON.parse(retained(requestContent).toString());
|
||||
const output = JSON.parse(retained(responseContent).toString());
|
||||
assert.equal(
|
||||
output.marker,
|
||||
input.marker,
|
||||
"Response was associated with a different same-URL request",
|
||||
);
|
||||
assert.equal(output.password, "ordinary-selected-value");
|
||||
assert.equal(input.declared, "[REDACTED:network_secret]");
|
||||
assert.equal(output.declared, input.declared);
|
||||
assert.equal(requestContent.body.redacted, true);
|
||||
assert.equal(responseContent.headers.state, "captured");
|
||||
assert.ok(
|
||||
responseContent.headers.items.some(
|
||||
(item) =>
|
||||
item.name.toLowerCase() === "x-declared" &&
|
||||
item.value === "[REDACTED:network_secret]" &&
|
||||
item.redacted,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
responseContent.headers.items
|
||||
.filter((item) => item.name.toLowerCase() === "x-repeat")
|
||||
.map((item) => item.value),
|
||||
["first", "second"],
|
||||
);
|
||||
assert.ok(
|
||||
responseContent.headers.items.some(
|
||||
(item) =>
|
||||
item.name.toLowerCase() === "set-cookie" &&
|
||||
item.value === null &&
|
||||
item.redacted,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
requestContent.headers.items.some(
|
||||
(item) =>
|
||||
item.name.toLowerCase() === "authorization" &&
|
||||
item.value === null &&
|
||||
item.redacted,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.kind === "request-finished" &&
|
||||
event.transaction_id === request.transaction_id,
|
||||
),
|
||||
);
|
||||
markers.push(input.marker);
|
||||
}
|
||||
assert.deepEqual(markers.sort(), ["fast", "slow"]);
|
||||
const responseFor = (path) => {
|
||||
const response = events.find(
|
||||
(event) =>
|
||||
event.kind === "response" && event.url.url === `${origin}${path}`,
|
||||
);
|
||||
assert.ok(response, `Missing response ${path}`);
|
||||
return content.find(
|
||||
(event) =>
|
||||
event.phase === "response" &&
|
||||
event.source_event_sequence === response.sequence,
|
||||
);
|
||||
};
|
||||
assert.deepEqual(retained(responseFor("/binary")), networkFixtureBinary);
|
||||
assert.deepEqual(retained(responseFor("/compressed")), networkFixtureDecoded);
|
||||
assert.equal(
|
||||
responseFor("/compressed").body.representation,
|
||||
"browser-decoded-response-bytes",
|
||||
);
|
||||
const formRequest = requestEvents.find(
|
||||
(event) => event.url.url === `${origin}/form`,
|
||||
);
|
||||
assert.ok(formRequest);
|
||||
const formContent = content.find(
|
||||
(event) =>
|
||||
event.phase === "request" &&
|
||||
event.transaction_id === formRequest.transaction_id,
|
||||
);
|
||||
assert.equal(
|
||||
retained(formContent).toString(),
|
||||
"declared=[REDACTED:network_secret]",
|
||||
);
|
||||
assert.equal(
|
||||
retained(responseFor("/form")).toString(),
|
||||
"[REDACTED:network_secret]",
|
||||
);
|
||||
assert.equal(responseFor("/stream").body.state, "unavailable");
|
||||
assert.equal(responseFor("/stream").body.reason, "response-unfinished");
|
||||
const first = requestEvents.find(
|
||||
(event) => event.url.url === `${origin}/redirect`,
|
||||
);
|
||||
const next = requestEvents.find(
|
||||
(event) => event.url.url === `${origin}/landing`,
|
||||
);
|
||||
assert.ok(first && next);
|
||||
assert.equal(next.redirected_from_transaction_id, first.transaction_id);
|
||||
return {
|
||||
same_url_markers: markers,
|
||||
binary_bytes: networkFixtureBinary.length,
|
||||
compressed_representation: "browser-decoded-response-bytes",
|
||||
unfinished_stream: "response-unfinished",
|
||||
empty_response_state: responseFor("/empty").body.state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
import { browserScenarioCaptureSchema } from "../../dist/domain/browserScenarioCapture.js";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import {
|
||||
startBrowserNetworkSite,
|
||||
networkFixtureSecret,
|
||||
} from "../fixtures/browser-network-site.mjs";
|
||||
import { assertBrowserNetworkEvidence } from "./browser-network-assertions.mjs";
|
||||
import { scenarioProfiles } from "./browser-scenario-verifier.mjs";
|
||||
|
||||
/** Exercise selected network content through real CLI and stdio MCP, one browser at a time. */
|
||||
export async function verifyBrowserNetworkEvidence(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
attachment,
|
||||
) {
|
||||
const site = await startBrowserNetworkSite();
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
try {
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: executable },
|
||||
start_url: { url: site.origin },
|
||||
actions: [
|
||||
{
|
||||
step_id: "run",
|
||||
action: "click",
|
||||
locator: { kind: "css", selector: "#start" },
|
||||
timeout_ms: 10_000,
|
||||
},
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: "#done" },
|
||||
state: "visible",
|
||||
timeout_ms: 10_000,
|
||||
},
|
||||
],
|
||||
secrets: [
|
||||
{
|
||||
secret_id: "network_secret",
|
||||
environment_variable: "REA_NETWORK_FIXTURE_SECRET",
|
||||
},
|
||||
],
|
||||
capture: {
|
||||
at_end: ["url"],
|
||||
network: {
|
||||
request_body: true,
|
||||
response_body: true,
|
||||
header_values: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_NETWORK_FIXTURE_SECRET: networkFixtureSecret,
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = browserScenarioCaptureSchema.parse(
|
||||
parseEvidence(JSON.parse(stdout)).normalized_result,
|
||||
);
|
||||
const cliProof = assertBrowserNetworkEvidence(cli, site.origin);
|
||||
// The oracle must reject a deliberately incorrect same-URL association.
|
||||
const swapped = structuredClone(cli);
|
||||
const responses = swapped.events.items.filter(
|
||||
(event) =>
|
||||
event.kind === "network-content" &&
|
||||
event.phase === "response" &&
|
||||
event.body.state === "captured" &&
|
||||
Buffer.from(event.body.content, "base64")
|
||||
.toString()
|
||||
.includes('"marker":'),
|
||||
);
|
||||
assert.equal(responses.length, 2);
|
||||
[responses[0].transaction_id, responses[1].transaction_id] = [
|
||||
responses[1].transaction_id,
|
||||
responses[0].transaction_id,
|
||||
];
|
||||
assert.throws(
|
||||
() => assertBrowserNetworkEvidence(swapped, site.origin),
|
||||
/associated with a different same-URL request/,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "network-real-e2e", version: "1" });
|
||||
let mcpProof;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool(
|
||||
{ name: "capture_browser_scenario", arguments: scenario },
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const evidence = parseEvidence({
|
||||
...result.structuredContent?.evidence,
|
||||
normalized_result: result.structuredContent?.result,
|
||||
});
|
||||
mcpProof = assertBrowserNetworkEvidence(
|
||||
browserScenarioCaptureSchema.parse(evidence.normalized_result),
|
||||
site.origin,
|
||||
);
|
||||
if (attachment !== undefined) {
|
||||
const connectedScenario = browserScenarioSchema.parse({
|
||||
...scenario,
|
||||
browser: { mode: "connect", ...attachment },
|
||||
});
|
||||
const connected = await client.callTool(
|
||||
{ name: "capture_browser_scenario", arguments: connectedScenario },
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(connected.isError, true, JSON.stringify(connected));
|
||||
const connectedEvidence = parseEvidence({
|
||||
...connected.structuredContent?.evidence,
|
||||
normalized_result: connected.structuredContent?.result,
|
||||
});
|
||||
assert.deepEqual(
|
||||
assertBrowserNetworkEvidence(
|
||||
browserScenarioCaptureSchema.parse(
|
||||
connectedEvidence.normalized_result,
|
||||
),
|
||||
site.origin,
|
||||
"disconnected-external",
|
||||
),
|
||||
cliProof,
|
||||
);
|
||||
assert.ok(
|
||||
(await fetch(`${attachment.cdp_endpoint}/json/version`)).ok,
|
||||
"Network capture closed the external browser",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(mcpProof, cliProof);
|
||||
for (const path of [
|
||||
"/redirect",
|
||||
"/landing",
|
||||
"/binary",
|
||||
"/compressed",
|
||||
"/stream",
|
||||
"/empty",
|
||||
"/form",
|
||||
])
|
||||
assert.equal(
|
||||
site.counts.get(path),
|
||||
attachment === undefined ? 2 : 3,
|
||||
`Unexpected implicit refetch of ${path}`,
|
||||
);
|
||||
assert.equal(site.counts.get("/same"), attachment === undefined ? 4 : 6);
|
||||
const profilesAfter = await scenarioProfiles();
|
||||
assert.ok(
|
||||
[...profilesAfter].every((profile) => profilesBefore.has(profile)),
|
||||
"A scenario profile survived cleanup",
|
||||
);
|
||||
return {
|
||||
...cliProof,
|
||||
cli: true,
|
||||
mcp: true,
|
||||
association_fault_rejected: true,
|
||||
implicit_refetches: 0,
|
||||
profile_cleanup: true,
|
||||
connected_network_verified: attachment !== undefined,
|
||||
};
|
||||
} finally {
|
||||
await site.close();
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,20 @@ export function assertScenarioCapture(capture) {
|
||||
throw new Error(
|
||||
"Scenario event URL did not report declared-secret redaction",
|
||||
);
|
||||
const unfinished = capture.events.items.filter(
|
||||
({ kind }) => kind === "request-unfinished",
|
||||
);
|
||||
// This fixture polls continuously. A request crossing the capture cutoff
|
||||
// makes later activity unknown even when no response body was selected.
|
||||
const expectedMissing = unfinished.length === 0 ? [] : ["events"];
|
||||
if (
|
||||
capture.events.items.length === 0 ||
|
||||
capture.completeness.equality_eligible !== true
|
||||
capture.completeness.equality_eligible !== (unfinished.length === 0) ||
|
||||
JSON.stringify(capture.completeness.missing_sections) !==
|
||||
JSON.stringify(expectedMissing) ||
|
||||
capture.completeness.truncated_sections.length !== 0
|
||||
)
|
||||
throw new Error(
|
||||
"Scenario launch did not produce complete runtime evidence",
|
||||
`Scenario launch completeness disagrees with its capture cutoff: ${JSON.stringify({ completeness: capture.completeness, unfinished })}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { webScriptExportResultSchema } from "../../dist/domain/webScriptExport.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../dist/domain/javascriptApplicationAnalysis.js";
|
||||
|
||||
/** Verify exact exported bytes and the independently authored source manifest. */
|
||||
export const assertWebScriptExport = async (evidence, capture, assets) => {
|
||||
const result = webScriptExportResultSchema.parse(evidence.normalized_result);
|
||||
assert.equal(result.source_evidence_id, capture.evidence_id);
|
||||
assert.deepEqual(evidence.evidence_links, [capture.evidence_id]);
|
||||
const manifest = await readFile(result.manifest.path);
|
||||
assert.equal(
|
||||
createHash("sha256").update(manifest).digest("hex"),
|
||||
result.manifest.sha256,
|
||||
);
|
||||
const { manifest: descriptor, ...inline } = result;
|
||||
assert.equal(descriptor.bytes, manifest.length);
|
||||
assert.deepEqual(JSON.parse(manifest.toString()), inline);
|
||||
assert.ok(result.analysis_input);
|
||||
assert.ok(result.scripts.length > 0);
|
||||
for (const script of result.scripts) {
|
||||
assert.equal(script.content.state, "exported");
|
||||
const bytes = await readFile(
|
||||
join(result.analysis_input.input_path, script.content.relative_path),
|
||||
);
|
||||
assert.equal(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
script.content.sha256,
|
||||
);
|
||||
if (assets) {
|
||||
const url = new URL(script.url);
|
||||
assert.equal(
|
||||
bytes.toString(),
|
||||
assets.get(`${url.pathname}${url.search}`),
|
||||
`Wrong bytes for ${script.url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (assets) {
|
||||
assert.equal(result.scripts.length, assets.size);
|
||||
const variants = result.scripts.filter(({ url }) =>
|
||||
url.includes("/variant.js?"),
|
||||
);
|
||||
assert.equal(variants.length, 2);
|
||||
assert.ok(variants.every(({ content }) => content.layout === "isolated"));
|
||||
assert.equal(
|
||||
new Set(variants.map(({ content }) => content.relative_path)).size,
|
||||
2,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
/** Verify existing application analysis and an observed relative module layout. */
|
||||
export const assertExportedScriptAnalysis = (evidence, modules, imports) => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.equal(result.statistics.parsed_javascript_files, modules);
|
||||
if (imports)
|
||||
assert.ok(
|
||||
result.graph.edges.some(
|
||||
({ relation, properties }) =>
|
||||
relation === "imports" &&
|
||||
properties.specifier === "./lib/dep.js" &&
|
||||
properties.resolution_status === "resolved",
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { startBrowserScriptSite } from "../fixtures/browser-script-site.mjs";
|
||||
import {
|
||||
assertWebScriptExport,
|
||||
assertExportedScriptAnalysis,
|
||||
} from "./browser-script-export-assertions.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Verify capture → export → existing analysis through real CLI and stdio MCP. */
|
||||
export async function verifyBrowserScriptExport(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
attachment,
|
||||
) {
|
||||
const site = await startBrowserScriptSite();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-browser-script-export-"));
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const cli = exportCli(entrypoint, env);
|
||||
const scenario = scriptCaptureScenario(executable, site.origin);
|
||||
const save = (name, capture) => saveCapture(root, name, capture);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "web-script-real-e2e", version: "1" });
|
||||
try {
|
||||
const cliCapture = await cli(
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
);
|
||||
const cliInput = await save("cli", cliCapture);
|
||||
const counts = new Map(site.counts);
|
||||
const cliExport = await cli(
|
||||
"export-web-scripts",
|
||||
cliInput.capture_path,
|
||||
cliInput.output_directory,
|
||||
);
|
||||
const cliResult = await assertWebScriptExport(
|
||||
cliExport,
|
||||
cliCapture,
|
||||
site.assets,
|
||||
);
|
||||
await cli(
|
||||
"analyze-javascript-application",
|
||||
cliResult.analysis_input.input_path,
|
||||
).then((value) => assertExportedScriptAnalysis(value, 4, true));
|
||||
assert.deepEqual(
|
||||
site.counts,
|
||||
counts,
|
||||
"Export/analysis refetched a captured asset",
|
||||
);
|
||||
await client.connect(transport);
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
};
|
||||
const mcpCapture = await call("capture_browser_scenario", scenario);
|
||||
const mcpInput = await save("mcp", mcpCapture);
|
||||
const mcpCounts = new Map(site.counts);
|
||||
const mcpResult = await assertWebScriptExport(
|
||||
await call("export_web_scripts", mcpInput),
|
||||
mcpCapture,
|
||||
site.assets,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await call("analyze_javascript_application", mcpResult.analysis_input),
|
||||
4,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
site.counts,
|
||||
mcpCounts,
|
||||
"MCP export/analysis refetched a captured asset",
|
||||
);
|
||||
const passive = attachment
|
||||
? await verifyPassiveExport(attachment, { cli, call, save })
|
||||
: false;
|
||||
return {
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
active_scripts: 4,
|
||||
passive,
|
||||
exact_bytes: true,
|
||||
manifest_readback: true,
|
||||
relative_import_resolved: true,
|
||||
query_variants_isolated: true,
|
||||
implicit_refetches: 0,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await site.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const verifyPassiveExport = async (attachment, { cli, call, save }) => {
|
||||
const input = {
|
||||
...attachment,
|
||||
observation_ms: 100,
|
||||
include_script_sources: true,
|
||||
};
|
||||
const page = await call("inspect_web_page", input);
|
||||
const pageInput = await save("passive", page);
|
||||
const exported = await assertWebScriptExport(
|
||||
await call("export_web_scripts", pageInput),
|
||||
page,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await call("analyze_javascript_application", exported.analysis_input),
|
||||
exported.scripts.length,
|
||||
false,
|
||||
);
|
||||
const pageCli = await cli(
|
||||
"inspect-web-page",
|
||||
attachment.cdp_endpoint,
|
||||
attachment.target_id,
|
||||
"--include-script-sources",
|
||||
"--observation-ms",
|
||||
"100",
|
||||
);
|
||||
const pageCliInput = await save("passive-cli", pageCli);
|
||||
const pageCliResult = await assertWebScriptExport(
|
||||
await cli(
|
||||
"export-web-scripts",
|
||||
pageCliInput.capture_path,
|
||||
pageCliInput.output_directory,
|
||||
),
|
||||
pageCli,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await cli(
|
||||
"analyze-javascript-application",
|
||||
pageCliResult.analysis_input.input_path,
|
||||
),
|
||||
pageCliResult.scripts.length,
|
||||
false,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const exportCli =
|
||||
(entrypoint, env) =>
|
||||
async (...args) => {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--json"],
|
||||
{ env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
|
||||
const saveCapture = async (root, name, capture) => {
|
||||
const capturePath = join(root, `${name}.json`);
|
||||
await writeFile(capturePath, JSON.stringify(capture));
|
||||
return {
|
||||
capture_path: capturePath,
|
||||
output_directory: join(root, `${name}-export`),
|
||||
};
|
||||
};
|
||||
|
||||
const scriptCaptureScenario = (executable, origin) => ({
|
||||
browser: { mode: "launch", executable_path: executable },
|
||||
start_url: { url: origin },
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: '[data-ready="true"]' },
|
||||
state: "visible",
|
||||
timeout_ms: 10000,
|
||||
},
|
||||
],
|
||||
capture: { network: { response_body: true } },
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export function assertObservation(result, origin) {
|
||||
assertObservationInventory(result, origin);
|
||||
assertObservationEvents(result);
|
||||
assertObservationPrivacy(result);
|
||||
assertObservationMetadata(result);
|
||||
assertObservationMetadata(result, origin);
|
||||
}
|
||||
|
||||
function assertObservationInventory(result, origin) {
|
||||
@@ -86,9 +86,27 @@ function assertObservationPrivacy(result) {
|
||||
);
|
||||
}
|
||||
|
||||
function assertObservationMetadata(result) {
|
||||
function assertObservationMetadata(result, origin) {
|
||||
assertFormDestinations(result);
|
||||
if (
|
||||
!result.metadata.dom_urls.some(
|
||||
({ attribute, url }) =>
|
||||
attribute === "href" && url === `${origin}/assets/guide`,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome relative DOM URL did not use the document base",
|
||||
);
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (
|
||||
result.storage.content_fingerprints.filter(
|
||||
({ scope }) => scope === "cookie",
|
||||
).length !== 2
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome root and current-page cookie fingerprints were missing",
|
||||
);
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
@@ -104,6 +122,23 @@ function assertObservationMetadata(result) {
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
/** Assert the fixture's empty form destinations retain the current document URL. */
|
||||
export function assertFormDestinations(
|
||||
result,
|
||||
documentUrl = result.target.url,
|
||||
) {
|
||||
const destinations = result.metadata.dom_urls.filter(
|
||||
({ attribute }) => attribute === "action" || attribute === "formaction",
|
||||
);
|
||||
if (
|
||||
destinations.length !== 4 ||
|
||||
destinations.some(({ url }) => url !== documentUrl)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome form destinations did not use the current document URL: ${JSON.stringify({ expected: documentUrl, target: result.target.url, destinations })}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert real-browser static bundle and source-map findings. */
|
||||
export function assertBundleAnalysis(result) {
|
||||
if (
|
||||
|
||||
@@ -64,7 +64,10 @@ export const toolFamilyCatalog = (sources) => {
|
||||
{
|
||||
id: "application",
|
||||
surface: "application-workflow",
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
contracts: [
|
||||
...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
@@ -184,6 +187,10 @@ export const providerCatalog = (sources) => {
|
||||
sources.artifactProviders.JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
},
|
||||
]
|
||||
.map(({ identity, contracts }) => ({
|
||||
id: identity.id,
|
||||
|
||||
@@ -54,6 +54,7 @@ const SOURCE_PATHS = {
|
||||
javascriptRuntimeObservationContracts:
|
||||
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
||||
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
||||
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
|
||||
supportedClients: "dist/application/SupportedClients.js",
|
||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Compare actual Vitest discovery with repository-owned test paths. */
|
||||
export function compareTestDiscovery(
|
||||
expectedPaths: readonly string[],
|
||||
discovery: unknown,
|
||||
root: string,
|
||||
): {
|
||||
readonly missing: readonly string[];
|
||||
readonly unexpected: readonly string[];
|
||||
readonly duplicates: readonly {
|
||||
readonly file: string;
|
||||
readonly projects: readonly string[];
|
||||
}[];
|
||||
readonly discovered: number;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { relative, resolve } from "node:path";
|
||||
|
||||
/** Compare actual Vitest discovery with repository-owned test paths. */
|
||||
export const compareTestDiscovery = (expectedPaths, discovery, root) => {
|
||||
if (!Array.isArray(discovery))
|
||||
throw new TypeError("Vitest file discovery must be an array");
|
||||
const projectsByFile = new Map();
|
||||
for (const entry of discovery) {
|
||||
if (
|
||||
entry === null ||
|
||||
typeof entry !== "object" ||
|
||||
typeof entry.file !== "string" ||
|
||||
entry.file.length === 0 ||
|
||||
typeof entry.projectName !== "string" ||
|
||||
entry.projectName.length === 0
|
||||
)
|
||||
throw new TypeError(
|
||||
"Vitest discovery requires file and projectName strings",
|
||||
);
|
||||
const path = relative(root, resolve(root, entry.file)).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
const projects = projectsByFile.get(path) ?? [];
|
||||
projects.push(entry.projectName);
|
||||
projectsByFile.set(path, projects);
|
||||
}
|
||||
const expected = new Set(expectedPaths);
|
||||
const sorted = (values) => [...values].sort();
|
||||
return {
|
||||
missing: sorted(expected).filter((path) => !projectsByFile.has(path)),
|
||||
unexpected: sorted(projectsByFile.keys()).filter(
|
||||
(path) => !expected.has(path),
|
||||
),
|
||||
duplicates: sorted(projectsByFile.keys()).flatMap((file) => {
|
||||
const projects = projectsByFile.get(file);
|
||||
return projects.length > 1 ? [{ file, projects: sorted(projects) }] : [];
|
||||
}),
|
||||
discovered: projectsByFile.size,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (!executable)
|
||||
throw new Error("verify:browser:dom requires REA_BROWSER_EXECUTABLE");
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-"));
|
||||
const site = await startBrowserVerifierSite();
|
||||
let context;
|
||||
try {
|
||||
context = await chromium.launchPersistentContext(profile, {
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
args: ["--remote-debugging-port=0"],
|
||||
});
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
await page.goto(`${site.origin}/app?selected=fixture#section`);
|
||||
const native = await page.evaluate(() =>
|
||||
[
|
||||
...document.querySelectorAll("form,button[formaction],input[formaction]"),
|
||||
].map((element) =>
|
||||
element.tagName === "FORM" ? element.action : element.formAction,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(native, Array(4).fill(page.url()));
|
||||
const session = await context.newCDPSession(page);
|
||||
const { targetInfo } = await session.send("Target.getTargetInfo");
|
||||
await session.detach();
|
||||
const [port] = (
|
||||
await readFile(join(profile, "DevToolsActivePort"), "utf8")
|
||||
).split("\n");
|
||||
const result = await verifyBrowserDomDestinations(
|
||||
{
|
||||
cdp_endpoint: `http://127.0.0.1:${port}`,
|
||||
target_id: targetInfo.targetId,
|
||||
},
|
||||
process.argv[2],
|
||||
page.url(),
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...result, browser: context.browser().version(), native_oracle: true, verified: true })}\n`,
|
||||
);
|
||||
} finally {
|
||||
await context?.close();
|
||||
await site.close();
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { access } from "node:fs/promises";
|
||||
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined || executable === "")
|
||||
throw new Error(
|
||||
"verify:browser:network requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser",
|
||||
);
|
||||
await access(executable);
|
||||
const run = createVerifierRun();
|
||||
const proof = await verifyBrowserNetworkEvidence(executable, process.argv[2]);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { access } from "node:fs/promises";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined || executable === "")
|
||||
throw new Error(
|
||||
"verify:browser:scripts requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser",
|
||||
);
|
||||
await access(executable);
|
||||
const run = createVerifierRun();
|
||||
const proof = await verifyBrowserScriptExport(executable, process.argv[2]);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
|
||||
);
|
||||
@@ -108,14 +108,26 @@ try {
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const runtimeParent = join(workspace, "runtime with spaces");
|
||||
await mkdir(runtimeParent);
|
||||
const callerDirectory = join(workspace, "caller cwd with spaces");
|
||||
const scriptCollisions = ["ReaGhidraBridge.java", "ReaGhidraPrepareCom.java"];
|
||||
await mkdir(callerDirectory);
|
||||
for (const name of scriptCollisions) await mkdir(join(callerDirectory, name));
|
||||
const environment = {
|
||||
...process.env,
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name]) => name.toLowerCase() !== "comspec",
|
||||
),
|
||||
),
|
||||
ComSpec: (
|
||||
process.env.ComSpec ??
|
||||
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
|
||||
).replaceAll("\\", "/"),
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_LOG_LEVEL: "error",
|
||||
GHIDRA_HEADLESS_MAXMEM: "512m",
|
||||
NODE_OPTIONS: "--max-old-space-size=512",
|
||||
TEMP: runtimeParent,
|
||||
TMP: runtimeParent,
|
||||
TEMP: runtimeParent.replaceAll("\\", "/"),
|
||||
TMP: runtimeParent.replaceAll("\\", "/"),
|
||||
HOME: workspace,
|
||||
USERPROFILE: workspace,
|
||||
APPDATA: join(workspace, "appdata"),
|
||||
@@ -150,6 +162,7 @@ try {
|
||||
(
|
||||
await exec(process.execPath, [entry, "providers", "--json"], {
|
||||
env: environment,
|
||||
cwd: callerDirectory,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout,
|
||||
@@ -159,7 +172,12 @@ try {
|
||||
const inspected = await exec(
|
||||
process.execPath,
|
||||
[entry, "inspect", target, "--provider", "ghidra", "--format", "json"],
|
||||
{ env: environment, timeout: 360_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
{
|
||||
env: environment,
|
||||
cwd: callerDirectory,
|
||||
timeout: 360_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const inspection = JSON.parse(inspected.stdout);
|
||||
assert.ok(inspection.error === undefined);
|
||||
@@ -169,6 +187,7 @@ try {
|
||||
command: process.execPath,
|
||||
args: [entry, "mcp"],
|
||||
env: environment,
|
||||
cwd: callerDirectory,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
@@ -282,6 +301,10 @@ try {
|
||||
token,
|
||||
"Analysis changed caller token authority.",
|
||||
);
|
||||
assert.deepEqual((await readdir(callerDirectory)).sort(), scriptCollisions);
|
||||
for (const name of scriptCollisions)
|
||||
assert.deepEqual(await readdir(join(callerDirectory, name)), []);
|
||||
report.callerScriptCollisionsPreserved = true;
|
||||
report.runtimeCleanup = true;
|
||||
report.ok = true;
|
||||
if (process.argv[4] !== undefined)
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
|
||||
import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs";
|
||||
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
@@ -96,6 +99,10 @@ try {
|
||||
);
|
||||
if (!observed.ok) throw observed.error;
|
||||
assertObservation(observed.value, site.origin);
|
||||
const domDestinations = await verifyBrowserDomDestinations({
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
});
|
||||
const serialized = JSON.stringify(observed.value);
|
||||
if (
|
||||
!observed.value.target.url.includes("startup=browser-secret-value") ||
|
||||
@@ -320,6 +327,15 @@ try {
|
||||
|
||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||
const popupEvents = await verifyPopupEventCoverage(executable);
|
||||
const networkEvidence = await verifyBrowserNetworkEvidence(
|
||||
executable,
|
||||
undefined,
|
||||
{ cdp_endpoint: endpoint, target_id: target },
|
||||
);
|
||||
const scriptExport = await verifyBrowserScriptExport(executable, undefined, {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
@@ -339,6 +355,9 @@ try {
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
largeScreenshot,
|
||||
popupEvents,
|
||||
networkEvidence,
|
||||
scriptExport,
|
||||
domDestinations,
|
||||
browserScenarioCli: true,
|
||||
browserScenarioAttachCleanup: "disconnected-external",
|
||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { compareTestDiscovery } from "./lib/test-discovery.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
|
||||
const temporary = await mkdtemp(join(tmpdir(), "rea-test-discovery-"));
|
||||
try {
|
||||
const output = join(temporary, "discovery.json");
|
||||
await execute(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, "node_modules/vitest/vitest.mjs"),
|
||||
"list",
|
||||
"--filesOnly",
|
||||
`--json=${output}`,
|
||||
],
|
||||
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
const files = await execute(
|
||||
"git",
|
||||
[
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
"src",
|
||||
"tests",
|
||||
],
|
||||
{ cwd: root, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
const expected = [];
|
||||
for (const path of files.stdout
|
||||
.split("\0")
|
||||
.filter((path) => path.endsWith(".test.ts"))) {
|
||||
try {
|
||||
if ((await stat(join(root, path))).isFile()) expected.push(path);
|
||||
} catch (cause) {
|
||||
if (cause.code !== "ENOENT") throw cause;
|
||||
}
|
||||
}
|
||||
const report = compareTestDiscovery(
|
||||
expected,
|
||||
JSON.parse(await readFile(output, "utf8")),
|
||||
root,
|
||||
);
|
||||
if (
|
||||
report.missing.length ||
|
||||
report.unexpected.length ||
|
||||
report.duplicates.length
|
||||
) {
|
||||
console.error(JSON.stringify(report, null, 2));
|
||||
process.exitCode = 1;
|
||||
} else
|
||||
console.log(`Discovered all ${report.discovered} test files exactly once.`);
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -79,13 +79,43 @@ try {
|
||||
await mkdir(sourceDirectory);
|
||||
const source = join(sourceDirectory, "target.bin");
|
||||
await writeFile(source, Buffer.alloc(1024 * 1024, 0x37));
|
||||
const sourceIdentity = native.call("open", [source]);
|
||||
try {
|
||||
for (const requested of [
|
||||
source.replaceAll("\\", "/"),
|
||||
source.replace(/\\/u, "/"),
|
||||
]) {
|
||||
const admitted = native.call("open", [requested]);
|
||||
try {
|
||||
assert.equal(admitted.requestedPath, requested);
|
||||
assert.equal(admitted.finalPath, sourceIdentity.finalPath);
|
||||
assert.equal(admitted.fileId, sourceIdentity.fileId);
|
||||
assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37);
|
||||
} finally {
|
||||
native.call("close", [admitted.handle]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
native.call("close", [sourceIdentity.handle]);
|
||||
}
|
||||
for (const rejected of [
|
||||
source.replace(/^([A-Za-z]):\\/u, "$1:"),
|
||||
`${sourceDirectory}/../source/target.bin`,
|
||||
`${sourceDirectory}//target.bin`,
|
||||
`${source}:stream`,
|
||||
`\\\\?\\${source.replaceAll("\\", "/")}`,
|
||||
"//localhost/share/target.bin",
|
||||
])
|
||||
assert.throws(() => native.call("open", [rejected]));
|
||||
report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true;
|
||||
const snapshotOwner = WindowsPrivateRuntime.create(
|
||||
workspace,
|
||||
workspace.replace(/\\/u, "/"),
|
||||
"single-flight-",
|
||||
);
|
||||
try {
|
||||
const rejectedController = new AbortController();
|
||||
const accepted = snapshotOwner.snapshot(source, "accepted.bin");
|
||||
const requestedSource = source.replaceAll("\\", "/");
|
||||
const accepted = snapshotOwner.snapshot(requestedSource, "accepted.bin");
|
||||
const rejected = snapshotOwner.snapshot(
|
||||
source,
|
||||
"rejected.bin",
|
||||
@@ -94,6 +124,7 @@ try {
|
||||
rejectedController.abort();
|
||||
await assert.rejects(rejected, /Runtime snapshot is still pending/u);
|
||||
const completed = await accepted;
|
||||
assert.equal(completed.source.requestedPath, requestedSource);
|
||||
assert.equal(
|
||||
completed.sha256,
|
||||
createHash("sha256")
|
||||
@@ -147,7 +178,7 @@ try {
|
||||
);
|
||||
assert.throws(() => native.call("open", [source + ":stream"]));
|
||||
const runtime = native.call("runtime_create", [
|
||||
workspace,
|
||||
workspace.replaceAll("\\", "/"),
|
||||
"private root with spaces-",
|
||||
]);
|
||||
const sentinel = join(sourceDirectory, "sentinel");
|
||||
@@ -316,6 +347,23 @@ setInterval(()=>{},1000);\n`,
|
||||
assert.match(settled.output, /observed/u);
|
||||
native.call("process_close", [exited.handle]);
|
||||
report.processes.normalExitAndOutput = true;
|
||||
const forwardCommand = process.execPath.replaceAll("\\", "/");
|
||||
const forwardProcess = native.call("process_spawn", [
|
||||
forwardCommand,
|
||||
[forwardCommand, "-e", "console.log('forward executable')"]
|
||||
.map(quote)
|
||||
.join(" "),
|
||||
workspace,
|
||||
environment,
|
||||
]);
|
||||
try {
|
||||
const completed = await wait(forwardProcess);
|
||||
assert.equal(completed.exitCode, 0);
|
||||
assert.match(completed.output, /forward executable/u);
|
||||
report.processes.ordinaryDriveSeparators = true;
|
||||
} finally {
|
||||
native.call("process_close", [forwardProcess.handle]);
|
||||
}
|
||||
const boundaryFixture = process.argv[3];
|
||||
if (boundaryFixture !== undefined) {
|
||||
const before = JSON.parse(
|
||||
|
||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "26"
|
||||
tool_count: 125
|
||||
catalog_digest: "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b"
|
||||
tool_count: 126
|
||||
catalog_digest: "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Android application artifacts
|
||||
|
||||
Use the connected server's advertised Android tools. Repository main includes
|
||||
this family; npm 4.0.1 does not. A skill installation alone cannot add it to an
|
||||
Use the connected server's advertised Android tools. Repository main and npm
|
||||
4.1.0 include this family. A skill installation alone cannot add it to an
|
||||
older server.
|
||||
|
||||
Start with `inspect_android_package` on the caller's APK path for package
|
||||
|
||||
@@ -44,7 +44,7 @@ When advertised by the connected server, application trace and compare tools
|
||||
accept complete inline Evidence or
|
||||
`{"kind":"retained-evidence","evidence_id":"ev_<64 lowercase hex characters>"}`
|
||||
for their application input (`application`, or `left`/`right`). This notation is
|
||||
a template: replace it with the actual returned ID. npm 4.0.1 accepts only full
|
||||
a template: replace it with the actual returned ID. Versions before 4.1.0 accept only full
|
||||
inline Evidence. Use the exact ID
|
||||
returned by the producer on the same MCP connection. Resolution does not run
|
||||
analysis or select a provider; findings remain inline. Native observation
|
||||
|
||||
@@ -37,29 +37,30 @@ assembly. Keep managed/native boundaries and unavailable reconstruction facts
|
||||
explicit. A bring-your-own reconstruction oracle is separate from the canonical
|
||||
parser and must not become an implicit setup dependency.
|
||||
|
||||
## .NET NativeAOT
|
||||
### .NET NativeAOT
|
||||
|
||||
A NativeAOT PE/ELF contains native code. Absence of PE/CLI metadata does not mean
|
||||
analysis must stop at assembly. Use native function analysis for recovered
|
||||
pseudocode; it is not reconstructed original C#.
|
||||
NativeAOT binaries contain native machine code, so ordinary CIL decompilers
|
||||
cannot recover the original method bodies. When managed metadata/CIL is absent,
|
||||
classify that as a native-analysis route rather than claiming source recovery.
|
||||
Open the native image with the selected deep provider and inspect functions,
|
||||
strings, calls, and references; describe decompiler output as pseudocode and
|
||||
inference.
|
||||
|
||||
With an explicitly configured NativeAOT Ghidra adapter, call
|
||||
`inspect_native_load_image` after opening the target. Its optional
|
||||
`observations.metadata_recovery` returns the format, positive header evidence,
|
||||
coverage, derived-memory identity and type addresses/category paths inline.
|
||||
Then inspect a reported type with `inspect_native_data_type`, follow its
|
||||
`metadata_recovery.related_type`, interfaces and virtual slot addresses, and use
|
||||
`analyze_function` plus string/xref searches for implementation evidence. Keep
|
||||
`not_applicable`, partial recovery and unsupported layouts explicit. Generated
|
||||
`Class_address` names and inferred System.Object/String identities are not
|
||||
original names; custom field layouts remain unknown.
|
||||
NativeAOT images can be PE/COFF, ELF, or Mach-O executables and shared
|
||||
libraries. Use file bytes and loader metadata rather than suffixes to identify
|
||||
them. For packaged apps, inventory/extract with REA's artifact tools and retain
|
||||
the exact contained image identity. PDB, ELF debug, or dSYM sidecars are
|
||||
additional evidence only when the provider accepts and matches them.
|
||||
|
||||
Recovery modifies an ephemeral analysis database and may replace loaded metadata
|
||||
bytes with derived content. Original executable files remain unchanged. Distinguish
|
||||
original file offsets from derived analysis memory. Windows x64 PE **targets**
|
||||
can be analyzed on a supported Linux host; Windows **host** P0 does not admit
|
||||
this database recovery. See the [NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
|
||||
for the tested layout and optional bring-your-own adapter build.
|
||||
For deeper type recovery, the optional third-party
|
||||
[Ghidra NativeAOT analyzer](https://github.com/Washi1337/ghidra-nativeaot)
|
||||
rehydrates ReadyToRun metadata and can recover method tables, type relationships,
|
||||
frozen objects, and strings. This is a Ghidra extension with its own install and
|
||||
interactive workflow; it is not currently part of REA's managed parser or
|
||||
headless Ghidra bridge. Treat its annotations as analysis evidence, retain the
|
||||
binary identity, and do not present recovered types as original source. Header
|
||||
discovery may require a symbol or analyst identification of the ReadyToRun
|
||||
header, depending on the binary and analyzer version.
|
||||
|
||||
## Packages and extraction
|
||||
|
||||
|
||||
@@ -271,20 +271,22 @@ const parseXmlPlistExecutable = (plist: string): string => {
|
||||
);
|
||||
if (match?.[1] === undefined)
|
||||
throw new Error("CFBundleExecutable is missing");
|
||||
return decodeXml(match[1].trim());
|
||||
return decodeXml(match[1]);
|
||||
};
|
||||
|
||||
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
|
||||
// `-n` strips only the newline plutil appends; it requires macOS 12+.
|
||||
(
|
||||
await execFileAsync("/usr/bin/plutil", [
|
||||
"-extract",
|
||||
"CFBundleExecutable",
|
||||
"raw",
|
||||
"-n",
|
||||
"-o",
|
||||
"-",
|
||||
plistPath,
|
||||
])
|
||||
).stdout.trim();
|
||||
).stdout;
|
||||
|
||||
const isSafeExecutableName = (name: string): boolean =>
|
||||
name.length > 0 &&
|
||||
|
||||
@@ -133,13 +133,6 @@ const registrationAligned = (
|
||||
return false;
|
||||
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
|
||||
return false;
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
client.name === "codex" &&
|
||||
registration.startup_timeout_sec !==
|
||||
@@ -155,6 +148,13 @@ const registrationAligned = (
|
||||
return false;
|
||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
return false;
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
command.length === 4 &&
|
||||
command[0] === "npx" &&
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
interfaceBuilderLimitsSchema,
|
||||
type InterfaceBuilderDocumentInput,
|
||||
} from "../domain/interfaceBuilderGraph.js";
|
||||
import {
|
||||
mergeNibHierarchies,
|
||||
projectNibViewHierarchy,
|
||||
type NibHierarchyNode,
|
||||
} from "./NibViewHierarchy.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
||||
@@ -27,6 +32,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
const documents: InterfaceBuilderDocumentInput[] = [];
|
||||
const invalid: string[] = [];
|
||||
const incompleteHierarchies = new Map<string, number>();
|
||||
let omitted = 0;
|
||||
let attempted = 0;
|
||||
try {
|
||||
@@ -40,10 +46,13 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
attempted += 1;
|
||||
try {
|
||||
const bytes = await readEntry(reader, entry, input.signal);
|
||||
const raw =
|
||||
const nib =
|
||||
bytes.subarray(0, 10).toString("ascii") === "NIBArchive"
|
||||
? projectNibArchive(decodeNibArchive(bytes))
|
||||
: decodePlist(bytes);
|
||||
: null;
|
||||
const raw = nib === null ? decodePlist(bytes) : nib.raw;
|
||||
if (nib !== null && nib.omitted > 0)
|
||||
incompleteHierarchies.set(entry.path, nib.omitted);
|
||||
const documentHash = createHash("sha256").update(bytes).digest("hex");
|
||||
documents.push({
|
||||
relativePath: entry.path,
|
||||
@@ -69,26 +78,71 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
documents,
|
||||
limits,
|
||||
});
|
||||
return finalizeHierarchyCoverage(result, {
|
||||
incompleteHierarchies,
|
||||
invalid,
|
||||
attempted,
|
||||
omitted,
|
||||
});
|
||||
};
|
||||
|
||||
const finalizeHierarchyCoverage = (
|
||||
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
|
||||
counts: {
|
||||
incompleteHierarchies: ReadonlyMap<string, number>;
|
||||
invalid: readonly string[];
|
||||
attempted: number;
|
||||
omitted: number;
|
||||
},
|
||||
) => {
|
||||
const { incompleteHierarchies, invalid } = counts;
|
||||
return {
|
||||
...result,
|
||||
documents: result.documents.map((document) => ({
|
||||
...document,
|
||||
hierarchy_complete:
|
||||
document.hierarchy_complete &&
|
||||
!incompleteHierarchies.has(document.relative_path),
|
||||
})),
|
||||
graph: {
|
||||
...result.graph,
|
||||
coverage: [
|
||||
...result.graph.coverage,
|
||||
...result.graph.coverage.map((facet) => {
|
||||
const omitted = incompleteHierarchies.get(
|
||||
facet.facet.replace(/^hierarchy:/u, ""),
|
||||
);
|
||||
return facet.facet.startsWith("hierarchy:") && omitted !== undefined
|
||||
? {
|
||||
...facet,
|
||||
status: "partial" as const,
|
||||
reason: "serialized_view_hierarchy_incomplete",
|
||||
omitted: facet.omitted + omitted,
|
||||
}
|
||||
: facet;
|
||||
}),
|
||||
{
|
||||
facet: "archive_decode",
|
||||
status:
|
||||
invalid.length > 0 ? ("partial" as const) : ("complete" as const),
|
||||
reason: invalid.length > 0 ? "one_or_more_archives_invalid" : null,
|
||||
examined: attempted,
|
||||
omitted,
|
||||
examined: counts.attempted,
|
||||
omitted: counts.omitted,
|
||||
},
|
||||
],
|
||||
truncated: result.graph.truncated || omitted > 0 || invalid.length > 0,
|
||||
truncated:
|
||||
result.graph.truncated ||
|
||||
counts.omitted > 0 ||
|
||||
invalid.length > 0 ||
|
||||
incompleteHierarchies.size > 0,
|
||||
},
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
"Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.",
|
||||
...(incompleteHierarchies.size === 0
|
||||
? []
|
||||
: [
|
||||
"Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.",
|
||||
]),
|
||||
...(invalid.length === 0
|
||||
? []
|
||||
: [
|
||||
@@ -99,7 +153,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
};
|
||||
|
||||
/** Project decoded NIB records into the same bounded graph input as ibtool. */
|
||||
const projectNibArchive = (archive: NibArchiveDocument): JsonValue => {
|
||||
const projectNibArchive = (archive: NibArchiveDocument) => {
|
||||
const byId = new Map(archive.objects.map((object) => [object.id, object]));
|
||||
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
@@ -181,7 +235,7 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => {
|
||||
}),
|
||||
);
|
||||
const connections: Record<string, JsonValue[]> = {};
|
||||
const hierarchy: JsonValue[] = [];
|
||||
const hierarchy: NibHierarchyNode[] = [];
|
||||
const reference = (value: JsonValue | undefined): number | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
@@ -214,23 +268,33 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => {
|
||||
});
|
||||
}
|
||||
}
|
||||
let hierarchyOmitted = 0;
|
||||
const hierarchyFor = (
|
||||
objectId: number,
|
||||
seen: Set<number>,
|
||||
depth: number,
|
||||
): JsonValue | null => {
|
||||
if (depth > 32 || seen.has(objectId)) return null;
|
||||
): NibHierarchyNode | null => {
|
||||
if (depth > 32 || seen.has(objectId)) {
|
||||
hierarchyOmitted += 1;
|
||||
return null;
|
||||
}
|
||||
const object = byId.get(objectId);
|
||||
if (object === undefined) return null;
|
||||
if (object === undefined) {
|
||||
hierarchyOmitted += 1;
|
||||
return null;
|
||||
}
|
||||
const nextSeen = new Set(seen).add(objectId);
|
||||
const children: JsonValue[] = [];
|
||||
const children: NibHierarchyNode[] = [];
|
||||
for (const [key, value] of Object.entries(object.values)) {
|
||||
if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key))
|
||||
continue;
|
||||
const direct = reference(value);
|
||||
if (direct === null) continue;
|
||||
const target = byId.get(direct);
|
||||
if (target === undefined) continue;
|
||||
if (target === undefined) {
|
||||
hierarchyOmitted += 1;
|
||||
continue;
|
||||
}
|
||||
if (/array|set/iu.test(target.class_name)) {
|
||||
for (const candidate of Object.values(target.values)) {
|
||||
const child = reference(candidate);
|
||||
@@ -254,14 +318,22 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => {
|
||||
const root = hierarchyFor(hierarchyRoot, new Set(), 0);
|
||||
if (root !== null) hierarchy.push(root);
|
||||
}
|
||||
return jsonValueSchema.parse({
|
||||
"com.apple.ibtool.document.objects": objects,
|
||||
"com.apple.ibtool.document.connections": connections,
|
||||
"com.apple.ibtool.document.hierarchy": hierarchy,
|
||||
"com.apple.ibtool.document.classes": Object.fromEntries(
|
||||
archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]),
|
||||
),
|
||||
});
|
||||
const viewHierarchy = projectNibViewHierarchy(
|
||||
archive.objects,
|
||||
new Set(Object.keys(objects).map(Number)),
|
||||
);
|
||||
const merged = mergeNibHierarchies(hierarchy, viewHierarchy.hierarchy ?? []);
|
||||
return {
|
||||
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
|
||||
raw: jsonValueSchema.parse({
|
||||
"com.apple.ibtool.document.objects": objects,
|
||||
"com.apple.ibtool.document.connections": connections,
|
||||
"com.apple.ibtool.document.hierarchy": merged.hierarchy,
|
||||
"com.apple.ibtool.document.classes": Object.fromEntries(
|
||||
archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]),
|
||||
),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const isInterfaceBuilderArchive = (path: string): boolean => {
|
||||
|
||||
@@ -53,6 +53,13 @@ export const ANDROID_APPLICATION_PROVIDER = {
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for verified publication of retained website scripts. */
|
||||
export const WEB_SCRIPT_EXPORT_PROVIDER = {
|
||||
id: "rea-web-script-export",
|
||||
name: "REA captured script export",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for deterministic static JavaScript application analysis. */
|
||||
export const JAVASCRIPT_APPLICATION_PROVIDER = {
|
||||
id: "rea-javascript-application",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { analyzeParsedJavaScriptStaticSource } from "../domain/javascriptStaticA
|
||||
import { analyzeParsedJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "../domain/javascriptSourceParser.js";
|
||||
import { hasValidSourceMapContents } from "../domain/sourceMapContents.js";
|
||||
import { flattenSourceMapLeaves } from "../domain/sourceMapEnvelope.js";
|
||||
import type {
|
||||
JavaScriptSourceRange,
|
||||
JavaScriptSourcePoint,
|
||||
@@ -300,20 +301,8 @@ const unavailableSourceMap = (
|
||||
const flattenSourceMaps = (
|
||||
root: unknown,
|
||||
): Readonly<Record<string, unknown>>[] | undefined => {
|
||||
if (!isRecord(root) || root.version !== 3) return undefined;
|
||||
const maps: Readonly<Record<string, unknown>>[] = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const map = pending.pop();
|
||||
if (map === undefined || map.version !== 3) return undefined;
|
||||
if (Array.isArray(map.sections)) {
|
||||
for (const section of map.sections) {
|
||||
if (!isRecord(section) || !isRecord(section.map)) return undefined;
|
||||
pending.push(section.map);
|
||||
}
|
||||
} else maps.push(map);
|
||||
}
|
||||
return maps;
|
||||
const leaves = flattenSourceMapLeaves(root);
|
||||
return leaves === undefined ? undefined : [...leaves];
|
||||
};
|
||||
|
||||
const invalidSourceMap = (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ArtifactReader,
|
||||
} from "../artifacts/ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
|
||||
import { hashReadable } from "./ArtifactInventory/hash.js";
|
||||
import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js";
|
||||
|
||||
/** Relevant file categories projected from the complete artifact inventory. */
|
||||
@@ -89,6 +90,21 @@ export const readJavaScriptArtifactFiles = async (
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JavaScriptArtifactFileSet> => {
|
||||
if (reader instanceof AsarArtifactReader) {
|
||||
const root = snapshot.nodes.find(
|
||||
({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id,
|
||||
);
|
||||
if (root === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Root artifact node is missing",
|
||||
);
|
||||
await verifyEntryBytes(
|
||||
reader.openContainer(signal),
|
||||
{ path: ".", sha256: root.sha256, bytes: root.size },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
const inventory = expectedInventory(snapshot);
|
||||
const expected = inventory.files;
|
||||
const context: ReadContext = {
|
||||
@@ -156,6 +172,11 @@ const visitReader = async (
|
||||
context.registry.add(path, nestedAsar ? "directory" : entry.kind);
|
||||
if (nestedAsar) {
|
||||
const inventory = expectedContainer(path, context);
|
||||
await verifyEntryBytes(
|
||||
await frame.reader.open(entry, context.signal),
|
||||
inventory,
|
||||
context.signal,
|
||||
);
|
||||
context.containers.push(inventory);
|
||||
const nested = new AsarArtifactReader(entry.adapterKey);
|
||||
stack.push({
|
||||
@@ -214,8 +235,14 @@ const readText = async (
|
||||
context: ReadContext,
|
||||
input: ReadTextInput,
|
||||
): Promise<JavaScriptArtifactFile["text"]> => {
|
||||
if (input.expected.kind === "native-addon")
|
||||
if (input.expected.kind === "native-addon") {
|
||||
await verifyEntryBytes(
|
||||
await input.reader.open(input.entry, context.signal),
|
||||
input.expected,
|
||||
context.signal,
|
||||
);
|
||||
return { included: false, reason: "not-applicable" };
|
||||
}
|
||||
const bytes = await readAll(
|
||||
await input.reader.open(input.entry, context.signal),
|
||||
context.signal,
|
||||
@@ -240,6 +267,24 @@ const readText = async (
|
||||
}
|
||||
};
|
||||
|
||||
const verifyEntryBytes = async (
|
||||
stream: Readable,
|
||||
expected: Pick<ExpectedFile, "path" | "sha256" | "bytes">,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const digest = await hashReadable(stream, signal);
|
||||
if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact entry changed after inventory: ${expected.path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
abortIfNeeded(signal);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const expectedInventory = (
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
): {
|
||||
|
||||
@@ -2,6 +2,12 @@ import { builtinModules } from "node:module";
|
||||
import { posix } from "node:path";
|
||||
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import {
|
||||
admitsCanonicalPathSyntax,
|
||||
hasScheme,
|
||||
looksExternal,
|
||||
stripQueryAndFragment,
|
||||
} from "../domain/artifactPathSyntax.js";
|
||||
|
||||
type ArtifactPathResolutionContext =
|
||||
| "package-entrypoint"
|
||||
@@ -115,12 +121,6 @@ const rejectDeclaration = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Path syntax admitted for a canonical artifact path. */
|
||||
const admitsCanonicalPathSyntax = (value: string): boolean =>
|
||||
!value.includes("\0") &&
|
||||
!value.includes("\\") &&
|
||||
!/%(?:2e|2f|5c)/iu.test(value);
|
||||
|
||||
const contextualCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
): string | ArtifactPathResolution => {
|
||||
@@ -212,7 +212,7 @@ const htmlCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
): string | ArtifactPathResolution => {
|
||||
const declared = stripQueryAndFragment(input.declaredPath);
|
||||
if (hasScheme(declared) || declared.startsWith("//"))
|
||||
if (looksExternal(declared))
|
||||
return unresolvedOutcome(input, "external", [
|
||||
"External HTML references are not mapped to local artifact assets.",
|
||||
]);
|
||||
@@ -221,11 +221,7 @@ const htmlCandidate = (
|
||||
rawBase === undefined || rawBase === null
|
||||
? rawBase
|
||||
: stripQueryAndFragment(rawBase);
|
||||
if (
|
||||
base !== undefined &&
|
||||
base !== null &&
|
||||
(hasScheme(base) || base.startsWith("//"))
|
||||
)
|
||||
if (base !== undefined && base !== null && looksExternal(base))
|
||||
return unresolvedOutcome(input, "external", [
|
||||
"The document base href is external, so its script reference is not a local artifact path.",
|
||||
]);
|
||||
@@ -538,9 +534,6 @@ const notFoundCandidate = (): CandidateResolution => ({
|
||||
],
|
||||
});
|
||||
|
||||
const hasScheme = (value: string): boolean =>
|
||||
/^[A-Za-z][A-Za-z+.-]*:/u.test(value);
|
||||
|
||||
const fileUrlPath = (value: string): string | undefined => {
|
||||
if (!value.startsWith("file://")) return undefined;
|
||||
try {
|
||||
@@ -554,9 +547,6 @@ const fileUrlPath = (value: string): string | undefined => {
|
||||
}
|
||||
};
|
||||
|
||||
const stripQueryAndFragment = (value: string): string =>
|
||||
value.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
|
||||
const outcome = (
|
||||
input: ResolveArtifactPathInput,
|
||||
resolution: CandidateResolution,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { parseExpression } from "@babel/parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js";
|
||||
import { staticPath } from "../domain/javascriptStaticAnalysisHelpers.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
|
||||
const urlCases = [
|
||||
["./preload%20name.cjs", "preload name.cjs", "main.mjs"],
|
||||
["./pr%C3%A9load.cjs", "préload.cjs", "main.mjs"],
|
||||
["./preload%3F%23.cjs", "preload?#.cjs", "main.mjs"],
|
||||
["./preload.cjs?cache=1#v2", "preload.cjs", "main.mjs"],
|
||||
["../preload.cjs?cache=1", "preload.cjs", "src/main.mjs"],
|
||||
["./sub/../preload.cjs", "preload.cjs", "main.mjs"],
|
||||
["./preload.cjs", "preload.cjs", "main.mjs"],
|
||||
] as const;
|
||||
|
||||
const unknownExpressions = [
|
||||
'fileURLToPath(new URL("./invalid%.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("./preload%2fname.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("./preload%5cname.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("./%2e/preload.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("https://example.test/preload.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("//example.test/preload.cjs", import.meta.url))',
|
||||
'fileURLToPath(new URL("#fragment", import.meta.url))',
|
||||
'fileURLToPath(new URL("?cache=1", import.meta.url))',
|
||||
'fileURLToPath(new URL("./preload.cjs", runtimeBase))',
|
||||
"fileURLToPath(new URL(runtimePath, import.meta.url))",
|
||||
'convertFileURLToPath(new URL("./preload.cjs", import.meta.url))',
|
||||
];
|
||||
|
||||
describe("source-relative file URL paths", () => {
|
||||
it.each(urlCases)(
|
||||
"resolves %s to the file read by Node",
|
||||
async (reference, target, sourcePath) => {
|
||||
const root = await createTestTempDirectory("rea-file-url-path-");
|
||||
const sourceFile = join(root, sourcePath);
|
||||
await mkdir(dirname(sourceFile), { recursive: true });
|
||||
await writeFile(join(root, target), 'module.exports = "real preload";\n');
|
||||
if (reference.includes("%") && sourcePath === "main.mjs")
|
||||
await writeFile(
|
||||
join(root, reference),
|
||||
'module.exports = "encoded-name decoy";\n',
|
||||
);
|
||||
const nativePath = fileURLToPath(
|
||||
new URL(reference, pathToFileURL(sourceFile)),
|
||||
);
|
||||
expect(relative(root, nativePath)).toBe(target);
|
||||
expect(await readFile(nativePath, "utf8")).toContain("real preload");
|
||||
await writeFile(
|
||||
sourceFile,
|
||||
`import { fileURLToPath } from "node:url";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath(new URL(${JSON.stringify(reference)}, import.meta.url)) } });\n`,
|
||||
);
|
||||
|
||||
const result = await analyzeJavaScriptApplication({
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
|
||||
result.value.normalized_result,
|
||||
);
|
||||
const preloads = graph.nodes.filter(
|
||||
({ kind }) => kind === "electron-preload",
|
||||
);
|
||||
expect(preloads).toHaveLength(1);
|
||||
expect(preloads[0]?.observations).not.toHaveLength(0);
|
||||
for (const observation of preloads[0]?.observations ?? [])
|
||||
expect(observation.properties).toMatchObject({
|
||||
resolved_path: target,
|
||||
resolution_status: "resolved",
|
||||
resolution_context: "filesystem-expression",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(unknownExpressions)("leaves unsupported %s unknown", (expression) => {
|
||||
expect(
|
||||
staticPath(parseExpression(expression, { sourceType: "module" })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not URL-decode literal filesystem punctuation", () => {
|
||||
const expression = 'path.join(__dirname, "preload%20?#.cjs")';
|
||||
expect(
|
||||
staticPath(parseExpression(expression, { sourceType: "module" })),
|
||||
).toBe("preload%20?#.cjs");
|
||||
});
|
||||
});
|
||||
@@ -286,3 +286,103 @@ const graphFor = (source: string) => {
|
||||
analysis,
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
"const value = consume(Promise.resolve(1));",
|
||||
"async function run() { await consume(Promise.resolve(1)); }",
|
||||
"function run() { return consume(Promise.resolve(1)); }",
|
||||
"const value = Promise.all([{ promise: Promise.resolve(1) }]);",
|
||||
])(
|
||||
"does not claim direct Promise ownership through an enclosing expression: %s",
|
||||
(source) => {
|
||||
const graph = graphFor(source);
|
||||
const promise = graph.nodes.find(
|
||||
({ kind, properties }) =>
|
||||
kind === "promise" && properties.method === "resolve",
|
||||
);
|
||||
expect(promise?.properties.ownership).toBe("unknown");
|
||||
expect(
|
||||
graph.relations.filter(
|
||||
({ target_node_id, relation }) =>
|
||||
target_node_id === promise?.node_id &&
|
||||
["owns", "awaits", "returns-task", "aggregates"].includes(relation),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"const routes = {'': 'HOME'}; const root = routes[''];",
|
||||
"const routes = {'': 'HOME'}; const {'': root} = routes;",
|
||||
])(
|
||||
"preserves empty property identities with valid display labels: %s",
|
||||
(source) => {
|
||||
const graph = graphFor(source);
|
||||
expect(graph.nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "property-slot",
|
||||
label: '""',
|
||||
properties: expect.objectContaining({ name: "" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const root = graph.nodes.find(
|
||||
({ kind, label }) => kind === "binding" && label === "root",
|
||||
);
|
||||
const literal = graph.nodes.find(
|
||||
({ kind, identity, properties }) =>
|
||||
kind === "literal" &&
|
||||
properties.value === "HOME" &&
|
||||
identity.role_key.includes(":binding:root:"),
|
||||
);
|
||||
expect(literal).toBeDefined();
|
||||
expect(graph.relations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source_node_id: literal?.node_id,
|
||||
target_node_id: root?.node_id,
|
||||
relation: "defines",
|
||||
resolution: "resolved",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"Promise.resolve(1)",
|
||||
"(Promise.resolve(1) as Promise<number>)",
|
||||
"(Promise.resolve(1) satisfies Promise<number>)",
|
||||
"Promise.resolve(1)!",
|
||||
"((Promise.resolve(1) as Promise<number>)!)",
|
||||
"(new Promise(resolve => resolve(1)) as Promise<number>)",
|
||||
"(Promise.all([Promise.resolve(1)]) as Promise<number[]>)",
|
||||
"(Promise.resolve(1).then(value => value) as Promise<number>)",
|
||||
])("projects a traversable returned-task link for %s", (expression) => {
|
||||
const graph = graphFor(`const run = () => ${expression};`);
|
||||
const promise = graph.nodes.find(
|
||||
({ kind, properties }) =>
|
||||
kind === "promise" && properties.ownership === "returned",
|
||||
);
|
||||
const relation = graph.relations.find(
|
||||
({ relation: kind }) => kind === "returns-task",
|
||||
);
|
||||
expect(promise).toBeDefined();
|
||||
expect(relation).toMatchObject({
|
||||
target_node_id: promise?.node_id,
|
||||
resolution: "resolved",
|
||||
});
|
||||
const returnNode = graph.nodes.find(
|
||||
({ node_id }) => node_id === relation?.source_node_id,
|
||||
);
|
||||
expect(returnNode?.kind).toBe("return-site");
|
||||
if (returnNode === undefined)
|
||||
throw new Error("Expected the linked return site");
|
||||
const query = queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: returnNode.node_id },
|
||||
direction: "forward-influence",
|
||||
allowed_relations: ["returns-task"],
|
||||
});
|
||||
expect(query.relations).toContainEqual(relation);
|
||||
expect(query.nodes.map(({ node_id }) => node_id)).toContain(promise?.node_id);
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ export const semanticPropertySlot = (
|
||||
kind: "property-slot",
|
||||
roleKey: `property:${objectBindingId}:${name}`,
|
||||
location: null,
|
||||
label: name,
|
||||
label: name === "" ? '""' : name,
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
|
||||
properties: {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { decodeNibArchive } from "../artifacts/NibArchive.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
|
||||
const compileNestedViews = async (depth: number, withWindow = false) => {
|
||||
const root = await createTestTempDirectory("rea-nib-hierarchy-");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
const source = await readFile(
|
||||
"tests/conformance/interface-builder/Main.xib",
|
||||
"utf8",
|
||||
);
|
||||
const nested =
|
||||
Array.from(
|
||||
{ length: depth },
|
||||
(_, i) =>
|
||||
`<view id="nested-${i}" customClass="NSView"><rect key="frame" x="0" y="0" width="100" height="100"/><subviews>`,
|
||||
).join("") + "</subviews></view>".repeat(depth);
|
||||
const xib = join(root, "Nested.xib");
|
||||
let xibSource = source.replace("</subviews>", `${nested}</subviews>`);
|
||||
if (withWindow)
|
||||
xibSource = xibSource
|
||||
.replace(
|
||||
'<view id="view" customClass="NSView">',
|
||||
'<window title="Example" id="window"><windowStyleMask key="styleMask" titled="YES" closable="YES"/><rect key="contentRect" x="0" y="0" width="480" height="320"/><view key="contentView" id="view" customClass="NSView">',
|
||||
)
|
||||
.replace("</view>\n </objects>", "</view></window>\n </objects>");
|
||||
await writeFile(xib, xibSource);
|
||||
const nib = join(resources, "Main.nib");
|
||||
execFileSync("/usr/bin/xcrun", [
|
||||
"ibtool",
|
||||
"--errors",
|
||||
"--warnings",
|
||||
"--compile",
|
||||
nib,
|
||||
xib,
|
||||
]);
|
||||
return { bundle, archive: decodeNibArchive(await readFile(nib)) };
|
||||
};
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native compiled NIB view hierarchy",
|
||||
() => {
|
||||
it.each([4, 40])(
|
||||
"recovers every serialized superview link for %i nested views",
|
||||
async (depth) => {
|
||||
const { bundle, archive } = await compileNestedViews(depth);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
});
|
||||
const parents = archive.objects.flatMap(({ id, values }) => {
|
||||
const parent = values.NSSuperview;
|
||||
return typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
!Array.isArray(parent) &&
|
||||
typeof parent.$nib_object_ref === "number"
|
||||
? [{ child: String(id), parent: String(parent.$nib_object_ref) }]
|
||||
: [];
|
||||
});
|
||||
expect(parents).toHaveLength(depth + 1);
|
||||
for (const pair of parents) {
|
||||
const child = result.graph.nodes.find(
|
||||
({ attributes }) =>
|
||||
attributes.interface_builder_object_id === pair.child,
|
||||
);
|
||||
const parent = result.graph.nodes.find(
|
||||
({ attributes }) =>
|
||||
attributes.interface_builder_object_id === pair.parent,
|
||||
);
|
||||
expect(child).toBeDefined();
|
||||
expect(parent).toBeDefined();
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
({ from, to, relation }) =>
|
||||
from === parent?.id &&
|
||||
to === child?.id &&
|
||||
relation === "contains",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(true);
|
||||
expect(result.graph.truncated).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the native window-to-content-view relation alongside superview links", async () => {
|
||||
const { bundle, archive } = await compileNestedViews(4, true);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "c".repeat(64),
|
||||
});
|
||||
const window = archive.objects.find(
|
||||
({ class_name }) => class_name === "NSWindowTemplate",
|
||||
);
|
||||
const content = window?.values.NSWindowView;
|
||||
if (
|
||||
window === undefined ||
|
||||
typeof content !== "object" ||
|
||||
content === null ||
|
||||
Array.isArray(content)
|
||||
)
|
||||
throw new Error("native window content link missing");
|
||||
const windowNode = result.graph.nodes.find(
|
||||
({ attributes }) =>
|
||||
attributes.interface_builder_object_id === String(window.id),
|
||||
);
|
||||
const viewNode = result.graph.nodes.find(
|
||||
({ attributes }) =>
|
||||
attributes.interface_builder_object_id ===
|
||||
String(content.$nib_object_ref),
|
||||
);
|
||||
expect(windowNode).toBeDefined();
|
||||
expect(viewNode).toBeDefined();
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
({ from, to, relation }) =>
|
||||
from === windowNode?.id &&
|
||||
to === viewNode?.id &&
|
||||
relation === "contains",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(true);
|
||||
expect(result.graph.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("reports partial coverage for a native hierarchy beyond the projection depth bound", async () => {
|
||||
const { bundle } = await compileNestedViews(132);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "d".repeat(64),
|
||||
});
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(false);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "hierarchy:Contents/Resources/Main.nib",
|
||||
status: "partial",
|
||||
reason: "serialized_view_hierarchy_incomplete",
|
||||
omitted: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.graph.coverage.find(
|
||||
({ facet }) => facet === "hierarchy:Contents/Resources/Main.nib",
|
||||
)?.omitted,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"reports partial hierarchy at the object budget (window=%s)",
|
||||
async (withWindow) => {
|
||||
const { bundle } = await compileNestedViews(4, withWindow);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "b".repeat(64),
|
||||
limits: { max_objects: 2 },
|
||||
});
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(false);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "hierarchy:Contents/Resources/Main.nib",
|
||||
status: "partial",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mergeNibHierarchies,
|
||||
projectNibViewHierarchy,
|
||||
} from "./NibViewHierarchy.js";
|
||||
|
||||
const object = (id: number, parent?: number) => ({
|
||||
id,
|
||||
class_name: "NSView",
|
||||
values:
|
||||
parent === undefined ? {} : { NSSuperview: { $nib_object_ref: parent } },
|
||||
});
|
||||
|
||||
describe("serialized NIB view hierarchy bounds", () => {
|
||||
it("leaves archives without superview links to the legacy projection", () => {
|
||||
expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({
|
||||
hierarchy: null,
|
||||
omitted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the serialized parent direction and retains all sibling links", () => {
|
||||
expect(
|
||||
projectNibViewHierarchy(
|
||||
[object(0), object(1, 0), object(2, 0)],
|
||||
new Set([0, 1, 2]),
|
||||
),
|
||||
).toEqual({
|
||||
hierarchy: [
|
||||
{
|
||||
objectID: "0",
|
||||
children: [
|
||||
{ objectID: "1", children: [] },
|
||||
{ objectID: "2", children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
omitted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing parents without inventing parentage", () => {
|
||||
expect(projectNibViewHierarchy([object(0, 99)], new Set([0]))).toEqual({
|
||||
hierarchy: [],
|
||||
omitted: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing encoded window content view as incomplete", () => {
|
||||
expect(
|
||||
projectNibViewHierarchy(
|
||||
[
|
||||
{
|
||||
id: 0,
|
||||
class_name: "NSWindowTemplate",
|
||||
values: { NSWindowView: { $nib_object_ref: 99 } },
|
||||
},
|
||||
],
|
||||
new Set([0]),
|
||||
),
|
||||
).toEqual({ hierarchy: [{ objectID: "0", children: [] }], omitted: 1 });
|
||||
});
|
||||
|
||||
it("marks unsupported serialized parent values as incomplete", () => {
|
||||
expect(
|
||||
projectNibViewHierarchy(
|
||||
[{ id: 0, class_name: "NSView", values: { NSSuperview: "unknown" } }],
|
||||
new Set([0]),
|
||||
),
|
||||
).toEqual({ hierarchy: null, omitted: 1 });
|
||||
});
|
||||
|
||||
it("reports cyclic and self-referential parentage as incomplete", () => {
|
||||
expect(
|
||||
projectNibViewHierarchy(
|
||||
[object(0, 1), object(1, 0), object(2, 2)],
|
||||
new Set([0, 1, 2]),
|
||||
),
|
||||
).toEqual({
|
||||
hierarchy: [],
|
||||
omitted: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not project children whose parent was not retained", () => {
|
||||
expect(
|
||||
projectNibViewHierarchy([object(0), object(1, 0)], new Set([1])),
|
||||
).toEqual({
|
||||
hierarchy: [],
|
||||
omitted: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nodes beyond the depth bound instead of claiming complete coverage", () => {
|
||||
const objects = Array.from({ length: 132 }, (_, id) =>
|
||||
object(id, id === 0 ? undefined : id - 1),
|
||||
);
|
||||
expect(
|
||||
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
|
||||
.omitted,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("reports nodes beyond the record bound", () => {
|
||||
const objects = Array.from({ length: 20_002 }, (_, id) =>
|
||||
object(id, id === 0 ? undefined : 0),
|
||||
);
|
||||
expect(
|
||||
projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id)))
|
||||
.omitted,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy NIB hierarchy preservation", () => {
|
||||
it("keeps encoded window parents while merging shared views only once", () => {
|
||||
const legacy = [
|
||||
{
|
||||
objectID: "window",
|
||||
children: [
|
||||
{ objectID: "view", children: [{ objectID: "first", children: [] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
const recovered = [
|
||||
{
|
||||
objectID: "view",
|
||||
children: [
|
||||
{ objectID: "first", children: [] },
|
||||
{ objectID: "second", children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(mergeNibHierarchies(legacy, recovered).hierarchy).toEqual([
|
||||
{
|
||||
objectID: "window",
|
||||
children: [
|
||||
{
|
||||
objectID: "view",
|
||||
children: [
|
||||
{ objectID: "first", children: [] },
|
||||
{ objectID: "second", children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("moves an existing root under its encoded parent without duplicate visits", () => {
|
||||
expect(
|
||||
mergeNibHierarchies(
|
||||
[{ objectID: "child", children: [] }],
|
||||
[
|
||||
{
|
||||
objectID: "parent",
|
||||
children: [{ objectID: "child", children: [] }],
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
hierarchy: [
|
||||
{ objectID: "parent", children: [{ objectID: "child", children: [] }] },
|
||||
],
|
||||
omitted: 0,
|
||||
});
|
||||
});
|
||||
it("reports conflicting encoded parents rather than dropping a link silently", () => {
|
||||
const legacy = [
|
||||
{ objectID: "first", children: [{ objectID: "child", children: [] }] },
|
||||
];
|
||||
const recovered = [
|
||||
{ objectID: "second", children: [{ objectID: "child", children: [] }] },
|
||||
];
|
||||
expect(mergeNibHierarchies(legacy, recovered).omitted).toBe(1);
|
||||
});
|
||||
it("does not introduce a cycle when moving an existing root", () => {
|
||||
const legacy = [
|
||||
{ objectID: "child", children: [{ objectID: "parent", children: [] }] },
|
||||
];
|
||||
const recovered = [
|
||||
{ objectID: "parent", children: [{ objectID: "child", children: [] }] },
|
||||
];
|
||||
expect(mergeNibHierarchies(legacy, recovered)).toEqual({
|
||||
hierarchy: legacy,
|
||||
omitted: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { NibArchiveDocument } from "../artifacts/NibArchive.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
export type NibHierarchyNode = {
|
||||
objectID: string;
|
||||
children: NibHierarchyNode[];
|
||||
};
|
||||
|
||||
/** Project encoded view parents; archive owners are not inferred view parents. */
|
||||
export const projectNibViewHierarchy = (
|
||||
objects: NibArchiveDocument["objects"],
|
||||
retainedIds: ReadonlySet<number>,
|
||||
) => {
|
||||
const { parents, involved, invalid } = collectNibParents(
|
||||
objects,
|
||||
retainedIds,
|
||||
);
|
||||
if (parents.size === 0) return { hierarchy: null, omitted: invalid };
|
||||
const children = new Map<number, number[]>();
|
||||
for (const [child, parent] of parents) {
|
||||
if (!retainedIds.has(parent) || !retainedIds.has(child)) continue;
|
||||
const siblings = children.get(parent) ?? [];
|
||||
siblings.push(child);
|
||||
children.set(parent, siblings);
|
||||
}
|
||||
const roots: NibHierarchyNode[] = [];
|
||||
const pending: { id: number; depth: number; output: NibHierarchyNode[] }[] =
|
||||
[];
|
||||
for (const id of involved)
|
||||
if (!parents.has(id)) pending.push({ id, depth: 0, output: roots });
|
||||
const visited = new Set<number>();
|
||||
while (pending.length > 0) {
|
||||
const item = pending.pop();
|
||||
if (item === undefined) break;
|
||||
if (visited.has(item.id) || item.depth > 128 || visited.size >= 20_000)
|
||||
continue;
|
||||
visited.add(item.id);
|
||||
const node: NibHierarchyNode = { objectID: String(item.id), children: [] };
|
||||
item.output.push(node);
|
||||
for (const child of [...(children.get(item.id) ?? [])].reverse())
|
||||
pending.push({ id: child, depth: item.depth + 1, output: node.children });
|
||||
}
|
||||
return {
|
||||
hierarchy: roots,
|
||||
omitted: involved.size - visited.size + invalid,
|
||||
};
|
||||
};
|
||||
|
||||
/** Add encoded parent links once; conflicting legacy parents remain unknown. */
|
||||
export const mergeNibHierarchies = (
|
||||
legacy: NibHierarchyNode[],
|
||||
recovered: NibHierarchyNode[],
|
||||
) => {
|
||||
const existing = new Map<string, NibHierarchyNode>();
|
||||
const owners = new Map<string, NibHierarchyNode[]>();
|
||||
const parents = new Map<string, string>();
|
||||
const pending = legacy.map((node) => ({
|
||||
node,
|
||||
output: legacy,
|
||||
parent: null as string | null,
|
||||
}));
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop();
|
||||
if (entry === undefined || existing.has(entry.node.objectID)) continue;
|
||||
existing.set(entry.node.objectID, entry.node);
|
||||
owners.set(entry.node.objectID, entry.output);
|
||||
if (entry.parent !== null) parents.set(entry.node.objectID, entry.parent);
|
||||
pending.push(
|
||||
...entry.node.children.map((node) => ({
|
||||
node,
|
||||
output: entry.node.children,
|
||||
parent: entry.node.objectID,
|
||||
})),
|
||||
);
|
||||
}
|
||||
let omitted = 0;
|
||||
const additions = recovered.map((node) => ({
|
||||
node,
|
||||
output: legacy,
|
||||
parent: null as string | null,
|
||||
}));
|
||||
while (additions.length > 0) {
|
||||
const entry = additions.pop();
|
||||
if (entry === undefined) break;
|
||||
let target = existing.get(entry.node.objectID);
|
||||
if (target === undefined) {
|
||||
target = { objectID: entry.node.objectID, children: [] };
|
||||
existing.set(target.objectID, target);
|
||||
entry.output.push(target);
|
||||
owners.set(target.objectID, entry.output);
|
||||
if (entry.parent !== null) parents.set(target.objectID, entry.parent);
|
||||
} else if (
|
||||
entry.parent !== null &&
|
||||
owners.get(target.objectID) !== entry.output
|
||||
) {
|
||||
const owner = owners.get(target.objectID);
|
||||
if (
|
||||
owner !== legacy ||
|
||||
wouldCycle(target.objectID, entry.parent, parents)
|
||||
) {
|
||||
omitted += 1;
|
||||
} else {
|
||||
owner.splice(owner.indexOf(target), 1);
|
||||
entry.output.push(target);
|
||||
owners.set(target.objectID, entry.output);
|
||||
parents.set(target.objectID, entry.parent);
|
||||
}
|
||||
}
|
||||
for (const node of [...entry.node.children].reverse())
|
||||
additions.push({
|
||||
node,
|
||||
output: target.children,
|
||||
parent: target.objectID,
|
||||
});
|
||||
}
|
||||
return { hierarchy: legacy, omitted };
|
||||
};
|
||||
|
||||
const wouldCycle = (
|
||||
child: string,
|
||||
parent: string,
|
||||
parents: ReadonlyMap<string, string>,
|
||||
): boolean => {
|
||||
const seen = new Set<string>();
|
||||
let current: string | undefined = parent;
|
||||
while (current !== undefined && !seen.has(current)) {
|
||||
if (current === child) return true;
|
||||
seen.add(current);
|
||||
current = parents.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const reference = (value: JsonValue | undefined): number | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return typeof value.$nib_object_ref === "number"
|
||||
? value.$nib_object_ref
|
||||
: null;
|
||||
};
|
||||
|
||||
const collectNibParents = (
|
||||
objects: NibArchiveDocument["objects"],
|
||||
retainedIds: ReadonlySet<number>,
|
||||
) => {
|
||||
const parents = new Map<number, number>();
|
||||
const involved = new Set<number>();
|
||||
let invalid = 0;
|
||||
for (const object of objects) {
|
||||
if (!retainedIds.has(object.id)) continue;
|
||||
const child = reference(object.values.NSWindowView);
|
||||
if (
|
||||
object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" &&
|
||||
invalidReference(object.values.NSWindowView)
|
||||
)
|
||||
invalid += 1;
|
||||
if (
|
||||
object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" &&
|
||||
child !== null
|
||||
) {
|
||||
parents.set(child, object.id);
|
||||
involved.add(object.id);
|
||||
involved.add(child);
|
||||
}
|
||||
const parent = reference(object.values.NSSuperview);
|
||||
if (invalidReference(object.values.NSSuperview)) invalid += 1;
|
||||
if (parent === null) continue;
|
||||
parents.set(object.id, parent);
|
||||
involved.add(object.id);
|
||||
if (retainedIds.has(parent)) involved.add(parent);
|
||||
}
|
||||
return { parents, involved, invalid };
|
||||
};
|
||||
|
||||
const invalidReference = (value: JsonValue | undefined): boolean =>
|
||||
value !== undefined && value !== null && reference(value) === null;
|
||||
@@ -13,7 +13,7 @@ export const processCaptureOwnershipUnavailableReason = (
|
||||
platform: NodeJS.Platform,
|
||||
): string | undefined =>
|
||||
platform === "win32"
|
||||
? "Windows process-tree ownership and cleanup are unavailable; REA cannot verify that descendants have stopped."
|
||||
? "Windows PTY process capture is unavailable because this adapter does not yet verify descendant cleanup. Use Linux (including WSL for Linux commands) or macOS. Reinstalling the PTY backend does not enable native Windows capture."
|
||||
: undefined;
|
||||
|
||||
/** Dependencies that make the native PTY probe's host boundary testable. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { captureProcessScenario } from "./ProcessHarness.js";
|
||||
import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js";
|
||||
import { processCaptureSchema } from "../domain/processCapture.js";
|
||||
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../domain/processCapture.fixture.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
|
||||
it("rejects legacy replay output instead of silently discarding it", () => {
|
||||
expect(
|
||||
@@ -48,9 +49,19 @@ it("fails closed on Windows before resolving or launching scenario paths", async
|
||||
const result = await captureProcessScenario(scenario, undefined, "win32");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected Windows ownership refusal");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
||||
if (!(result.error instanceof AnalysisCapabilityUnavailableError))
|
||||
throw new Error("expected a capability-unavailable outcome");
|
||||
expect(result.error).toMatchObject({
|
||||
operation: "capture_process_scenario",
|
||||
reason: expect.stringContaining("Windows process-tree ownership"),
|
||||
reason: expect.stringContaining("Windows PTY process capture"),
|
||||
});
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
category: "unsupported_provider",
|
||||
message: expect.stringContaining("does not yet verify descendant cleanup"),
|
||||
details: {
|
||||
operation: "capture_process_scenario",
|
||||
reason: result.error.reason,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,6 +409,7 @@ export const captureProcessScenario = async (
|
||||
"rea-process",
|
||||
"capture_process_scenario",
|
||||
ownershipReason,
|
||||
{ userMessage: ownershipReason },
|
||||
),
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
evaluateReconstructionClosure,
|
||||
reconstructionCoverageDataSchema,
|
||||
} from "../domain/reconstructionCoverage.js";
|
||||
import { evaluateReconstructionClosure } from "../domain/reconstructionCoverage.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
export const reconstructionCoverageEvaluationInputSchema = z.strictObject({
|
||||
coverage: reconstructionCoverageDataSchema,
|
||||
boundary_id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Evaluate inline reconstruction coverage against a named boundary. */
|
||||
export const evaluateReconstructionCoverage = (
|
||||
rawInput: unknown,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
APPLICATION_GRAPH_DIGESTS,
|
||||
artifactEvidence,
|
||||
buildSyntheticJavaScriptApplicationGraph,
|
||||
completeCoverage,
|
||||
} from "../domain/javascriptApplicationGraph.fixture.js";
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
} from "../domain/javascriptApplicationGraph.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
|
||||
const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture-source", name: "Fixture source", version: "1" },
|
||||
{
|
||||
operation: "inspect_fixture_source",
|
||||
parameters: {},
|
||||
result: {},
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
const base = buildSyntheticJavaScriptApplicationGraph();
|
||||
const unknownNodes = keys.map((key) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "unknown",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar,
|
||||
namespace: "fixture",
|
||||
key,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "Unclassified application boundary",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
"unclassified.js",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const graph = createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: base.root_node_ids,
|
||||
nodes: [...base.nodes, ...unknownNodes],
|
||||
edges: base.edges,
|
||||
coverage: completeCoverage,
|
||||
limitations: base.limitations,
|
||||
});
|
||||
const application = createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, {
|
||||
predicateType: "rea.managed-application-graph",
|
||||
operation: "project_managed_application_graph",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
projection_id: `magp_${"a".repeat(64)}`,
|
||||
root_artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar,
|
||||
source_evidence: {
|
||||
managed_artifact_evidence_id: source.evidence_id,
|
||||
managed_members_evidence_id: null,
|
||||
managed_native_boundaries_evidence_id: null,
|
||||
},
|
||||
summary: {
|
||||
graph_nodes: graph.nodes.length,
|
||||
graph_edges: graph.edges.length,
|
||||
assemblies: 0,
|
||||
modules: 0,
|
||||
types: 0,
|
||||
methods: 0,
|
||||
fields: 0,
|
||||
pinvoke_imports: 0,
|
||||
native_implementations: 0,
|
||||
},
|
||||
graph,
|
||||
coverage: { status: "complete-within-inputs" },
|
||||
evidence_links: [source.evidence_id],
|
||||
limitations: [],
|
||||
}),
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: [source.evidence_id],
|
||||
});
|
||||
return {
|
||||
graph,
|
||||
unknownNodes,
|
||||
generated: deriveReconstructionObligationCandidates(
|
||||
createEvidenceBundle([source, application]),
|
||||
[],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const unknownLimitation = (graphId: string, nodeId: string): string =>
|
||||
`Application graph ${graphId} contains unresolved node ${nodeId}; reconstruction obligations remain unknown.`;
|
||||
|
||||
describe("reconstruction obligation candidates", () => {
|
||||
it("preserves admitted unknown application nodes as limitations", () => {
|
||||
const { graph, unknownNodes, generated } = applicationWithUnknowns([
|
||||
"unclassified-boundary",
|
||||
]);
|
||||
const unknownNode = unknownNodes[0];
|
||||
if (unknownNode === undefined) throw new Error("Expected one unknown node");
|
||||
|
||||
expect(generated.candidates.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
generated.candidates.some(
|
||||
({ target }) => target.application_node_id === unknownNode.node_id,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(generated.limitations).toEqual([
|
||||
unknownLimitation(graph.graph_id, unknownNode.node_id),
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits one deterministic limitation per unknown node", () => {
|
||||
const { graph, unknownNodes, generated } = applicationWithUnknowns([
|
||||
"unclassified-first",
|
||||
"unclassified-second",
|
||||
]);
|
||||
|
||||
expect([...generated.limitations].sort()).toEqual(
|
||||
unknownNodes
|
||||
.map((node) => unknownLimitation(graph.graph_id, node.node_id))
|
||||
.sort(),
|
||||
);
|
||||
for (const node of unknownNodes)
|
||||
expect(
|
||||
generated.candidates.some(
|
||||
({ target }) => target.application_node_id === node.node_id,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,13 @@ const deriveApplicationCandidates = (
|
||||
const source = parseApplicationGraphEvidence(evidence);
|
||||
for (const node of source.graph.nodes) {
|
||||
const candidatePolicy = applicationObligationPolicy(node.kind);
|
||||
if (candidatePolicy === undefined) continue;
|
||||
if (candidatePolicy === undefined) {
|
||||
if (node.kind === "unknown")
|
||||
limitations.add(
|
||||
`Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const reference = node.observations.map((observation) =>
|
||||
graphReference(
|
||||
evidence,
|
||||
|
||||
@@ -163,6 +163,8 @@ export const importReferenceSource = async (
|
||||
if (!readResult.ok) {
|
||||
const error = readResult.error;
|
||||
if (error.code === "cancelled") return err(cancelled());
|
||||
if (error.code === "unsupported")
|
||||
return err(failure("unsupported", error.message));
|
||||
return err(failure("io", error.message));
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,18 @@ export interface ParsedReferenceSourceEntries {
|
||||
readonly limitations: string[];
|
||||
}
|
||||
|
||||
/** Project a low-level entry failure into safe import guidance. */
|
||||
/** Preserve the observed reader failure and add entry-specific recovery guidance. */
|
||||
export const projectReferenceSourceEntryFailure = (
|
||||
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
|
||||
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
|
||||
|
||||
const entryRecoveryGuidance = (
|
||||
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
|
||||
): string => {
|
||||
if (entry.code === "cancelled")
|
||||
return "This entry was not read because the import was cancelled. Start the import again when ready.";
|
||||
if (entry.code === "unsupported")
|
||||
return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system.";
|
||||
return "Exclude this entry or replace it with a regular file.";
|
||||
if (entry.kind === "directory")
|
||||
return "This directory could not be read. Check its permissions, then try again.";
|
||||
if (entry.kind === "symlink")
|
||||
@@ -89,6 +93,22 @@ const resolveInternalSpecifier = (
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
]);
|
||||
// TypeScript source commonly names the extension emitted for NodeNext.
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) {
|
||||
const sourceSuffixes = normalized.endsWith(".js")
|
||||
? [".ts", ".tsx", ".d.ts"]
|
||||
: normalized.endsWith(".mjs")
|
||||
? [".mts", ".d.mts"]
|
||||
: normalized.endsWith(".cjs")
|
||||
? [".cts", ".d.cts"]
|
||||
: normalized.endsWith(".jsx")
|
||||
? [".tsx", ".d.ts"]
|
||||
: [];
|
||||
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
||||
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
||||
}
|
||||
const match = candidates.find((candidate) => filePaths.has(candidate));
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
|
||||
/** Typed expected failure returned by historical-source imports. */
|
||||
export interface ReferenceSourceImportError {
|
||||
readonly tag: "reference-source-import";
|
||||
readonly code: "cancelled" | "invalid-root" | "io" | "parse";
|
||||
readonly code: "cancelled" | "invalid-root" | "unsupported" | "io" | "parse";
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ export const projectReferenceSourceImportError = (
|
||||
message:
|
||||
"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",
|
||||
};
|
||||
if (error.code === "unsupported")
|
||||
return {
|
||||
category: "unsupported_host",
|
||||
message:
|
||||
"Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.",
|
||||
};
|
||||
if (error.code === "io")
|
||||
return {
|
||||
category: "execution_failure",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { publishWebScripts } from "../browser/assets/PublishWebScripts.js";
|
||||
import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import {
|
||||
exportWebScriptsInputSchema,
|
||||
type ExportWebScriptsInput,
|
||||
} from "../domain/webScriptExport.js";
|
||||
import { WebScriptExportError } from "../domain/webScriptExportError.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "export_web_scripts";
|
||||
|
||||
/** Export one local capture through the shared CLI/MCP application workflow. */
|
||||
export const exportWebScripts = async (
|
||||
rawInput: unknown,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const input = exportWebScriptsInputSchema.safeParse(rawInput);
|
||||
return input.success
|
||||
? exportWebScriptsValidated(input.data, options)
|
||||
: err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
projectInputIssues(input.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Publish input already parsed by a named adapter contract. */
|
||||
export const exportWebScriptsValidated = async (
|
||||
input: ExportWebScriptsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (!isAbsolute(input.capture_path) || !isAbsolute(input.output_directory))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
"capture_path and output_directory must be absolute filesystem paths on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
try {
|
||||
options.signal?.throwIfAborted();
|
||||
const bytes = await readFile(input.capture_path, {
|
||||
signal: options.signal,
|
||||
});
|
||||
const loaded = parseCapture(bytes);
|
||||
if (!loaded.ok) return loaded;
|
||||
options.signal?.throwIfAborted();
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const result = await publishWebScripts(
|
||||
input,
|
||||
loaded.value,
|
||||
sha256,
|
||||
options.signal,
|
||||
);
|
||||
return ok(
|
||||
createEvidence(
|
||||
{ path: input.capture_path, sha256, format: "file" },
|
||||
PROVIDER,
|
||||
{
|
||||
predicateType: "rea.web-script-export",
|
||||
operation: OPERATION,
|
||||
parameters: {
|
||||
capture_path: input.capture_path,
|
||||
output_directory: input.output_directory,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: result.manifest.path }],
|
||||
evidenceLinks:
|
||||
loaded.value.sourceEvidenceId === null
|
||||
? []
|
||||
: [loaded.value.sourceEvidenceId],
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof WebScriptExportError) return err(cause);
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new WebScriptExportError(
|
||||
cause.reason,
|
||||
input.output_directory,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(new AnalysisOutputError(OPERATION, cause.message, { cause }));
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string"
|
||||
)
|
||||
return err(
|
||||
new WebScriptExportError(
|
||||
"io",
|
||||
`${input.capture_path} → ${input.output_directory}`,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
capture_path: input.capture_path,
|
||||
output_directory: input.output_directory,
|
||||
error_name: cause instanceof Error ? cause.name : "UnknownError",
|
||||
error_message:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: typeof cause === "string"
|
||||
? cause
|
||||
: "Unknown script export failure",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCapture = (
|
||||
bytes: Buffer,
|
||||
): Result<ReturnType<typeof selectScriptCapture>, AnalysisError> => {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
const json = safeParseJson(text);
|
||||
if (!json.ok) throw new TypeError(json.error);
|
||||
return ok(selectScriptCapture(json.value));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["capture_path"],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Capture is not valid UTF-8 JSON evidence.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
@@ -112,6 +113,12 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open raw container bytes to verify the inventory identity before analysis. */
|
||||
openContainer(signal?: AbortSignal): Readable {
|
||||
abortIfNeeded(signal);
|
||||
return createReadStream(this.path, signal === undefined ? {} : { signal });
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
uncache(this.path);
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -93,6 +93,11 @@ describe("browserScenarioSchema", () => {
|
||||
service_workers: "block",
|
||||
});
|
||||
expect(parsed.capture).toEqual({
|
||||
network: {
|
||||
request_body: false,
|
||||
response_body: false,
|
||||
header_values: false,
|
||||
},
|
||||
after_each_step: ["screenshot", "url", "accessibility"],
|
||||
at_end: ["dom", "storage"],
|
||||
events: ["console", "page-errors", "network", "websockets"],
|
||||
|
||||
@@ -13,6 +13,13 @@ import type { BrowserStorageValueFingerprint } from "../domain/browserScenarioCa
|
||||
|
||||
const REDACTION_PREFIX = "[REDACTED:";
|
||||
|
||||
const encodedSecretValues = (secret: string): ReadonlySet<string> =>
|
||||
new Set([
|
||||
secret,
|
||||
encodeURIComponent(secret),
|
||||
new URLSearchParams([["value", secret]]).toString().slice("value=".length),
|
||||
]);
|
||||
|
||||
/** Resolved secret values kept only for one in-memory scenario session. */
|
||||
export class BrowserScenarioSecrets {
|
||||
private constructor(private readonly values: ReadonlyMap<string, string>) {}
|
||||
@@ -62,6 +69,53 @@ export class BrowserScenarioSecrets {
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Replace declared UTF-8, URI/form, and JSON-escaped secrets without decoding binary data. */
|
||||
redactBytes(value: Buffer): Buffer {
|
||||
const candidates = [...this.values]
|
||||
.flatMap(([id, secret]) => {
|
||||
if (secret === "") return [];
|
||||
return [
|
||||
...new Set([
|
||||
...encodedSecretValues(secret),
|
||||
JSON.stringify(secret).slice(1, -1),
|
||||
]),
|
||||
].map((text) => {
|
||||
const needle = Buffer.from(text);
|
||||
return {
|
||||
id,
|
||||
needle,
|
||||
marker: Buffer.from(`${REDACTION_PREFIX}${id}]`),
|
||||
index: value.indexOf(needle),
|
||||
};
|
||||
});
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.needle.length - left.needle.length ||
|
||||
(left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
|
||||
);
|
||||
const parts: Buffer[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
let next: (typeof candidates)[number] | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.index >= 0 && candidate.index < offset)
|
||||
candidate.index = value.indexOf(candidate.needle, offset);
|
||||
if (
|
||||
candidate.index >= 0 &&
|
||||
(next === undefined || candidate.index < next.index)
|
||||
)
|
||||
next = candidate;
|
||||
}
|
||||
if (next === undefined) break;
|
||||
parts.push(value.subarray(offset, next.index), next.marker);
|
||||
offset = next.index + next.needle.length;
|
||||
}
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.subarray(offset));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Remove only declared secret values from one URL and report that action. */
|
||||
sanitizeUrl(value: string): SanitizedBrowserUrl {
|
||||
let output = value;
|
||||
@@ -73,14 +127,7 @@ export class BrowserScenarioSecrets {
|
||||
for (const [id, secret] of replacements) {
|
||||
if (secret === "") continue;
|
||||
const marker = `${REDACTION_PREFIX}${id}]`;
|
||||
const searchParamsValue = new URLSearchParams([["value", secret]])
|
||||
.toString()
|
||||
.slice("value=".length);
|
||||
for (const candidate of new Set([
|
||||
secret,
|
||||
encodeURIComponent(secret),
|
||||
searchParamsValue,
|
||||
]))
|
||||
for (const candidate of encodedSecretValues(secret))
|
||||
output = output.replaceAll(candidate, marker);
|
||||
}
|
||||
const sanitized = sanitizeBrowserUrl(output);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureDom } from "./CdpCaptureDocuments.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const documentUrl = `${origin}/screens/app`;
|
||||
const input = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: [origin],
|
||||
target_id: "page-1",
|
||||
});
|
||||
|
||||
const snapshot = (
|
||||
baseUrl?: string,
|
||||
baseHref = "assets/",
|
||||
pageUrl = documentUrl,
|
||||
linkHref = "guide",
|
||||
) => ({
|
||||
strings: [
|
||||
pageUrl,
|
||||
baseUrl ?? "",
|
||||
"#document",
|
||||
"BASE",
|
||||
"A",
|
||||
"LINK",
|
||||
"FORM",
|
||||
"",
|
||||
"href",
|
||||
baseHref,
|
||||
linkHref,
|
||||
"rel",
|
||||
"mcp",
|
||||
"agent",
|
||||
"action",
|
||||
"submit",
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
...(baseUrl === undefined ? {} : { baseURL: 1 }),
|
||||
nodes: {
|
||||
nodeType: [9, 1, 1, 1, 1],
|
||||
nodeName: [2, 3, 4, 5, 6],
|
||||
nodeValue: [7, 7, 7, 7, 7],
|
||||
parentIndex: [-1, 0, 0, 0, 0],
|
||||
attributes: [[], [8, 9], [8, 10], [8, 13, 11, 12], [14, 15]],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const capture = (value: ReturnType<typeof snapshot>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
|
||||
describe("DOM metadata document base URLs", () => {
|
||||
it("resolves links, actions and agent hints against the captured document base", () => {
|
||||
const result = capture(snapshot(`${origin}/screens/assets/`));
|
||||
expect(result.urls.map(({ url }) => url)).toEqual([
|
||||
`${origin}/screens/assets/`,
|
||||
`${origin}/screens/assets/guide`,
|
||||
`${origin}/screens/assets/agent`,
|
||||
`${origin}/screens/assets/submit`,
|
||||
]);
|
||||
expect(result.agentHints[0]?.url).toBe(`${origin}/screens/assets/agent`);
|
||||
});
|
||||
|
||||
it("uses the document URL as fallback when no baseURL was supplied", () => {
|
||||
const result = capture(snapshot());
|
||||
expect(result.urls.map(({ url }) => url)).toEqual([
|
||||
`${origin}/screens/assets/`,
|
||||
`${origin}/screens/guide`,
|
||||
`${origin}/screens/agent`,
|
||||
`${origin}/screens/submit`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies destination policy after resolving an ordinary foreign base", () => {
|
||||
const foreign = "https://cdn.example.test/assets/";
|
||||
const result = capture(snapshot(foreign, foreign));
|
||||
expect(result.nodes).toHaveLength(5);
|
||||
expect(result.urls).toHaveLength(4);
|
||||
expect(
|
||||
result.urls.every(
|
||||
({ url, destination_scope }) =>
|
||||
url === null && destination_scope === "outside_policy",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.agentHints[0]?.url).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an absolute approved link independent of a foreign base", () => {
|
||||
const foreign = "https://cdn.example.test/assets/";
|
||||
const result = capture(
|
||||
snapshot(foreign, foreign, documentUrl, `${origin}/guide`),
|
||||
);
|
||||
expect(result.urls[1]).toMatchObject({
|
||||
url: `${origin}/guide`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not authorize a foreign document using its approved base URL", () => {
|
||||
const result = capture(
|
||||
snapshot(
|
||||
`${origin}/assets/`,
|
||||
"assets/",
|
||||
"https://outside.example.test/app",
|
||||
),
|
||||
);
|
||||
expect(result.nodes).toEqual([]);
|
||||
expect(result.urls).toEqual([]);
|
||||
expect(result.agentHints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty form destinations with a document base URL", () => {
|
||||
it.each([
|
||||
{ nodeName: "FORM", attribute: "action" },
|
||||
{ nodeName: "BUTTON", attribute: "formaction" },
|
||||
{ nodeName: "INPUT", attribute: "formaction" },
|
||||
])(
|
||||
"resolves an empty $nodeName $attribute to the document",
|
||||
({ nodeName, attribute }) => {
|
||||
const pageUrl = `${documentUrl}?selected=fixture#section`;
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl, "/assets/", pageUrl, "");
|
||||
value.strings[6] = nodeName;
|
||||
value.strings[14] = attribute;
|
||||
value.strings[15] = "";
|
||||
const result = capture(value);
|
||||
expect(result.urls[3]).toMatchObject({
|
||||
attribute,
|
||||
url: pageUrl,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
expect(result.urls[1]?.url).toBe(baseUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an empty form action approved when the document base is foreign", () => {
|
||||
const foreign = "https://cdn.example.test/assets/";
|
||||
const value = snapshot(foreign, foreign);
|
||||
value.strings[15] = "";
|
||||
expect(capture(value).urls[3]).toMatchObject({
|
||||
url: documentUrl,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([" ", "\t\r\n\f"])(
|
||||
"resolves an HTML-whitespace-only form action to the document: %j",
|
||||
(action) => {
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl);
|
||||
value.strings[15] = action;
|
||||
expect(capture(value).urls[3]?.url).toBe(documentUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["BUTTON", "INPUT"])(
|
||||
"retains document-base resolution for a whitespace-only %s formaction",
|
||||
(nodeName) => {
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl);
|
||||
value.strings[6] = nodeName;
|
||||
value.strings[14] = "formaction";
|
||||
value.strings[15] = " ";
|
||||
expect(capture(value).urls[3]?.url).toBe(baseUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ value: "\u000b", suffix: "" },
|
||||
{ value: "\u00a0", suffix: "%C2%A0" },
|
||||
])(
|
||||
"does not treat non-HTML whitespace as an empty form action: $value",
|
||||
({ value: action, suffix }) => {
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl);
|
||||
value.strings[15] = action;
|
||||
expect(capture(value).urls[3]?.url).toBe(`${baseUrl}${suffix}`);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not apply form semantics to an action attribute on another element", () => {
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl);
|
||||
value.strings[6] = "DIV";
|
||||
value.strings[15] = "";
|
||||
expect(capture(value).urls[3]?.url).toBe(baseUrl);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
recordValue,
|
||||
recordsValue,
|
||||
requiredRecord,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
@@ -38,7 +38,7 @@ export const captureFrames = (
|
||||
completeness?.exclude("frames", "invalid_protocol_value"),
|
||||
)) {
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = stringValue(frame?.id);
|
||||
const frameId = cdpStringValue(frame?.id);
|
||||
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
||||
if (frame === undefined || frameId === undefined) {
|
||||
completeness?.exclude("frames", "invalid_protocol_value");
|
||||
@@ -47,14 +47,14 @@ export const captureFrames = (
|
||||
if (sanitized === undefined || sanitized.origin === null) {
|
||||
completeness?.exclude(
|
||||
"frames",
|
||||
exclusionReasonForUrl(stringValue(frame.url)),
|
||||
exclusionReasonForUrl(cdpStringValue(frame.url)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (maximum !== undefined && items.length >= maximum) continue;
|
||||
items.push({
|
||||
frame_id: frameId,
|
||||
parent_frame_id: stringValue(frame.parentId) ?? null,
|
||||
parent_frame_id: cdpStringValue(frame.parentId) ?? null,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export const captureFrames = (
|
||||
|
||||
/** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
|
||||
export const mainFrameUrl = (result: unknown): string | undefined =>
|
||||
stringValue(
|
||||
cdpStringValue(
|
||||
recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url,
|
||||
);
|
||||
|
||||
@@ -90,17 +90,17 @@ export const captureResources = (
|
||||
if (url === undefined || url.origin === null) {
|
||||
completeness?.exclude(
|
||||
"resources",
|
||||
exclusionReasonForUrl(stringValue(resource.url)),
|
||||
exclusionReasonForUrl(cdpStringValue(resource.url)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const contentSize = numberValue(resource.contentSize);
|
||||
items.push({
|
||||
rawUrl: stringValue(resource.url) ?? "",
|
||||
rawUrl: cdpStringValue(resource.url) ?? "",
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
type: stringValue(resource.type) ?? "Other",
|
||||
mime_type: stringValue(resource.mimeType) ?? "",
|
||||
type: cdpStringValue(resource.type) ?? "Other",
|
||||
mime_type: cdpStringValue(resource.mimeType) ?? "",
|
||||
content_size:
|
||||
contentSize === undefined ? null : Math.max(0, contentSize),
|
||||
});
|
||||
@@ -149,7 +149,7 @@ export const captureDom = (
|
||||
} => {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => stringValue(value) ?? "")
|
||||
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
||||
: [];
|
||||
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
||||
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
||||
@@ -162,6 +162,7 @@ export const captureDom = (
|
||||
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
||||
continue;
|
||||
}
|
||||
const baseUrl = indexedString(strings, document.baseURL) || documentUrl;
|
||||
const documentNodes = recordValue(document.nodes);
|
||||
if (documentNodes === undefined) continue;
|
||||
const nodeTypes = numberArray(documentNodes.nodeType);
|
||||
@@ -191,6 +192,8 @@ export const captureDom = (
|
||||
const metadata = domMetadata({
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
@@ -237,14 +240,14 @@ export const captureAccessibility = (
|
||||
);
|
||||
const nodeIds = new Set(
|
||||
all.flatMap((node) => {
|
||||
const nodeId = stringValue(node.nodeId);
|
||||
const nodeId = cdpStringValue(node.nodeId);
|
||||
return nodeId === undefined ? [] : [nodeId];
|
||||
}),
|
||||
);
|
||||
let excludedFields = 0;
|
||||
const nodes = all.map((node) => {
|
||||
const captureText = (value: unknown): string | null => {
|
||||
const raw = stringValue(recordValue(value)?.value);
|
||||
const raw = cdpStringValue(recordValue(value)?.value);
|
||||
if (raw === undefined) return null;
|
||||
if (!options.includeText) {
|
||||
excludedFields += 1;
|
||||
@@ -253,8 +256,8 @@ export const captureAccessibility = (
|
||||
return raw;
|
||||
};
|
||||
return {
|
||||
node_id: stringValue(node.nodeId) ?? "",
|
||||
parent_id: stringValue(node.parentId) ?? null,
|
||||
node_id: cdpStringValue(node.nodeId) ?? "",
|
||||
parent_id: cdpStringValue(node.parentId) ?? null,
|
||||
role: axText(node.role),
|
||||
name: captureText(node.name),
|
||||
description: captureText(node.description),
|
||||
@@ -269,7 +272,7 @@ export const captureAccessibility = (
|
||||
(node) =>
|
||||
Array.isArray(node.childIds) &&
|
||||
node.childIds.some((childId) => {
|
||||
const id = stringValue(childId);
|
||||
const id = cdpStringValue(childId);
|
||||
return id !== undefined && !nodeIds.has(id);
|
||||
}),
|
||||
),
|
||||
@@ -323,7 +326,7 @@ const accessibilityStates = (
|
||||
): WebPageInspection["accessibility"]["nodes"][number]["states"] => {
|
||||
const states = new Map<string, boolean | number | string>();
|
||||
for (const property of recordsValue(value)) {
|
||||
const name = stringValue(property.name);
|
||||
const name = cdpStringValue(property.name);
|
||||
if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue;
|
||||
const raw = recordValue(property.value)?.value;
|
||||
const state =
|
||||
@@ -342,7 +345,7 @@ const accessibilityStates = (
|
||||
};
|
||||
|
||||
const axText = (value: unknown): string | null =>
|
||||
stringValue(recordValue(value)?.value) ?? null;
|
||||
cdpStringValue(recordValue(value)?.value) ?? null;
|
||||
|
||||
const indexedString = (strings: readonly string[], index: unknown): string => {
|
||||
const integer = numberValue(index);
|
||||
@@ -360,6 +363,7 @@ const numberArray = (value: unknown): readonly number[] =>
|
||||
interface DomMetadataOptions {
|
||||
readonly strings: readonly string[];
|
||||
readonly attributes: readonly number[];
|
||||
readonly baseUrl: string;
|
||||
readonly documentUrl: string;
|
||||
readonly nodeIndex: number;
|
||||
readonly nodeName: string;
|
||||
@@ -375,6 +379,7 @@ const domMetadata = (
|
||||
const {
|
||||
strings,
|
||||
attributes,
|
||||
baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
@@ -390,7 +395,21 @@ const domMetadata = (
|
||||
for (const attribute of domUrlAttributes) {
|
||||
const value = pairs.get(attribute);
|
||||
if (value === undefined) continue;
|
||||
const destination = domDestination(value, documentUrl, allowedOrigins);
|
||||
const tagName = nodeName.toLowerCase();
|
||||
// Chrome strips HTML whitespace for FORM.action, while submit controls
|
||||
// use the document URL only for an exactly empty formaction attribute.
|
||||
const usesDocumentUrl =
|
||||
(tagName === "form" &&
|
||||
attribute === "action" &&
|
||||
/^[\t\n\f\r ]*$/u.test(value)) ||
|
||||
((tagName === "button" || tagName === "input") &&
|
||||
attribute === "formaction" &&
|
||||
value === "");
|
||||
const destination = domDestination(
|
||||
usesDocumentUrl ? documentUrl : value,
|
||||
baseUrl,
|
||||
allowedOrigins,
|
||||
);
|
||||
urls.push({
|
||||
node_index: nodeIndex,
|
||||
attribute,
|
||||
@@ -417,14 +436,14 @@ const domMetadata = (
|
||||
|
||||
const domDestination = (
|
||||
value: string,
|
||||
documentUrl: string,
|
||||
baseUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly url: string | null;
|
||||
readonly scope: "approved" | "outside_policy" | "unsupported";
|
||||
} => {
|
||||
try {
|
||||
const parsed = new URL(value, documentUrl);
|
||||
const parsed = new URL(value, baseUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
||||
return { url: null, scope: "unsupported" };
|
||||
if (!allowedOrigins.has(parsed.origin))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { inferJsonShape, type JsonShape } from "../domain/jsonShape.js";
|
||||
import {
|
||||
recordValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { decodeBase64, isJsonContentType } from "./CdpCaptureEventHelpers.js";
|
||||
@@ -14,7 +14,7 @@ export const ingestResponseBodyShape = (
|
||||
value: unknown,
|
||||
): void => {
|
||||
const result = recordValue(value);
|
||||
const body = stringValue(result?.body);
|
||||
const body = cdpStringValue(result?.body);
|
||||
if (body === undefined) {
|
||||
invalidResponseBodyShape(state, requestId);
|
||||
return;
|
||||
@@ -37,7 +37,7 @@ export const requestBodyShape = (
|
||||
): NetworkState["body_shapes"] => {
|
||||
if (!state.input.include_json_body_shapes)
|
||||
return { status: "not_approved", request: null, response: null };
|
||||
const body = stringValue(request.postData);
|
||||
const body = cdpStringValue(request.postData);
|
||||
if (!isJsonContentType(recordValue(request.headers)) || body === undefined)
|
||||
return { status: "unavailable", request: null, response: null };
|
||||
const inferred = inferBodyShape(body);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { requestBodyShape } from "./CdpCaptureEventBodyShapes.js";
|
||||
@@ -30,7 +30,7 @@ export const handleExecutionContextCreated = (
|
||||
): void => {
|
||||
const context = recordValue(params.context);
|
||||
const identifier = numberValue(context?.id);
|
||||
const frameId = stringValue(recordValue(context?.auxData)?.frameId);
|
||||
const frameId = cdpStringValue(recordValue(context?.auxData)?.frameId);
|
||||
if (
|
||||
identifier === undefined ||
|
||||
!Number.isSafeInteger(identifier) ||
|
||||
@@ -53,8 +53,8 @@ export const handleScriptParsed = (
|
||||
state: CdpCaptureEventsState,
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const scriptId = stringValue(params.scriptId);
|
||||
const rawUrl = stringValue(params.url) ?? "";
|
||||
const scriptId = cdpStringValue(params.scriptId);
|
||||
const rawUrl = cdpStringValue(params.url) ?? "";
|
||||
const sanitized = allowedSanitizedUrl(rawUrl, state.allowedOrigins);
|
||||
if (scriptId === undefined) {
|
||||
state.completeness.exclude("scripts", "invalid_protocol_value");
|
||||
@@ -70,7 +70,7 @@ export const handleScriptParsed = (
|
||||
rawUrl,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
hash: stringValue(params.hash) ?? "",
|
||||
hash: cdpStringValue(params.hash) ?? "",
|
||||
length: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -79,7 +79,7 @@ export const handleScriptParsed = (
|
||||
),
|
||||
),
|
||||
isModule: params.isModule === true,
|
||||
language: stringValue(params.scriptLanguage) ?? null,
|
||||
language: cdpStringValue(params.scriptLanguage) ?? null,
|
||||
sourceMapUrl: sourceMap.sanitized,
|
||||
sourceMapRawUrl: sourceMap.raw,
|
||||
executionContextKey: executionContextKey(params.executionContextId),
|
||||
@@ -92,7 +92,7 @@ const sourceMapForScript = (
|
||||
scriptUrl: string,
|
||||
state: CdpCaptureEventsState,
|
||||
): { readonly sanitized: string | null; readonly raw: string | null } => {
|
||||
const declaredUrl = stringValue(value);
|
||||
const declaredUrl = cdpStringValue(value);
|
||||
if (declaredUrl === undefined || declaredUrl === "")
|
||||
return { sanitized: null, raw: null };
|
||||
let rawUrl: string;
|
||||
@@ -114,7 +114,7 @@ export const handleRequestWillBeSent = (
|
||||
state: CdpCaptureEventsState,
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const requestId = cdpStringValue(params.requestId);
|
||||
const request = recordValue(params.request);
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
@@ -129,14 +129,14 @@ export const handleRequestWillBeSent = (
|
||||
if (sanitized === undefined) {
|
||||
state.completeness.exclude(
|
||||
"network_requests",
|
||||
exclusionReasonForUrl(stringValue(request.url)),
|
||||
exclusionReasonForUrl(cdpStringValue(request.url)),
|
||||
);
|
||||
state.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
const initiator = recordValue(params.initiator);
|
||||
const initiatorFrame = initiatorLocation(initiator);
|
||||
const rawInitiatorUrl = stringValue(initiatorFrame?.url);
|
||||
const rawInitiatorUrl = cdpStringValue(initiatorFrame?.url);
|
||||
const initiatorUrl = allowedSanitizedUrl(
|
||||
rawInitiatorUrl,
|
||||
state.allowedOrigins,
|
||||
@@ -150,13 +150,13 @@ export const handleRequestWillBeSent = (
|
||||
request_id: requestId,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin ?? "",
|
||||
method: stringValue(request.method) ?? "GET",
|
||||
resource_type: stringValue(params.type) ?? null,
|
||||
method: cdpStringValue(request.method) ?? "GET",
|
||||
resource_type: cdpStringValue(params.type) ?? null,
|
||||
status: null,
|
||||
mime_type: null,
|
||||
encoded_data_length: null,
|
||||
initiator: {
|
||||
type: stringValue(initiator?.type) ?? "other",
|
||||
type: cdpStringValue(initiator?.type) ?? "other",
|
||||
url: initiatorUrl?.url ?? null,
|
||||
line: integerOrNull(initiatorFrame?.lineNumber),
|
||||
column: integerOrNull(initiatorFrame?.columnNumber),
|
||||
@@ -169,7 +169,7 @@ export const handleResponseReceived = (
|
||||
state: CdpCaptureEventsState,
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const requestId = cdpStringValue(params.requestId);
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
return;
|
||||
@@ -183,7 +183,7 @@ export const handleResponseReceived = (
|
||||
"network_requests",
|
||||
response === undefined
|
||||
? "invalid_protocol_value"
|
||||
: exclusionReasonForUrl(stringValue(response.url)),
|
||||
: exclusionReasonForUrl(cdpStringValue(response.url)),
|
||||
);
|
||||
state.network.delete(requestId);
|
||||
return;
|
||||
@@ -191,7 +191,7 @@ export const handleResponseReceived = (
|
||||
state.network.set(requestId, {
|
||||
...current,
|
||||
status: numberValue(response.status) ?? null,
|
||||
mime_type: stringValue(response.mimeType) ?? null,
|
||||
mime_type: cdpStringValue(response.mimeType) ?? null,
|
||||
});
|
||||
const metadata = safeResponseMetadata(
|
||||
requestId,
|
||||
@@ -207,7 +207,7 @@ export const handleLoadingFinished = (
|
||||
state: CdpCaptureEventsState,
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const requestId = cdpStringValue(params.requestId);
|
||||
if (requestId === undefined) return;
|
||||
const current = state.network.get(requestId);
|
||||
if (current === undefined) return;
|
||||
@@ -233,16 +233,16 @@ export const handleConsoleAPICalled = (
|
||||
if (source === undefined) {
|
||||
state.completeness.exclude(
|
||||
"console_events",
|
||||
exclusionReasonForUrl(stringValue(frame.url)),
|
||||
exclusionReasonForUrl(cdpStringValue(frame.url)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const arguments_ = recordsValue(params.args);
|
||||
state.console.push({
|
||||
type: stringValue(params.type) ?? "unknown",
|
||||
type: cdpStringValue(params.type) ?? "unknown",
|
||||
timestamp: numberValue(params.timestamp) ?? 0,
|
||||
argument_types: arguments_.map(
|
||||
(argument) => stringValue(argument.type) ?? "unknown",
|
||||
(argument) => cdpStringValue(argument.type) ?? "unknown",
|
||||
),
|
||||
url: source.url,
|
||||
line: integerOrNull(frame.lineNumber),
|
||||
@@ -287,14 +287,14 @@ export const handleWebSocketFrame = (
|
||||
params: UnknownRecord,
|
||||
direction: "sent" | "received",
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const requestId = cdpStringValue(params.requestId);
|
||||
if (
|
||||
requestId === undefined ||
|
||||
(!state.network.has(requestId) && !state.allowedWebSockets.has(requestId))
|
||||
)
|
||||
return;
|
||||
const response = recordValue(params.response);
|
||||
const payload = stringValue(response?.payloadData) ?? "";
|
||||
const payload = cdpStringValue(response?.payloadData) ?? "";
|
||||
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
|
||||
const decoded = opcode === 1 ? undefined : decodeBase64(payload);
|
||||
if (opcode !== 1 && decoded === undefined)
|
||||
@@ -327,8 +327,8 @@ export const handleWebSocketCreated = (
|
||||
state: CdpCaptureEventsState,
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const rawUrl = stringValue(params.url);
|
||||
const requestId = cdpStringValue(params.requestId);
|
||||
const rawUrl = cdpStringValue(params.url);
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude(
|
||||
"websocket_connections",
|
||||
@@ -378,8 +378,8 @@ export const handleFrameNavigated = (
|
||||
)
|
||||
)
|
||||
return;
|
||||
const rawUrl = stringValue(frame?.url);
|
||||
const loaderId = stringValue(frame?.loaderId);
|
||||
const rawUrl = cdpStringValue(frame?.url);
|
||||
const loaderId = cdpStringValue(frame?.loaderId);
|
||||
// `frameNavigated` re-fires for a document already reported (same URL and
|
||||
// loader). Treating that as a navigation aborted a valid capture with
|
||||
// `target_changed`, which is a false claim that the target moved.
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
@@ -17,10 +17,10 @@ export const isMainFrameNavigation = (
|
||||
if (params === undefined) return false;
|
||||
const frameId =
|
||||
event.method === "Page.frameNavigated"
|
||||
? stringValue(recordValue(params.frame)?.id)
|
||||
? cdpStringValue(recordValue(params.frame)?.id)
|
||||
: event.method === "Page.frameRequestedNavigation" ||
|
||||
event.method === "Page.navigatedWithinDocument"
|
||||
? stringValue(params.frameId)
|
||||
? cdpStringValue(params.frameId)
|
||||
: undefined;
|
||||
return frameId === mainFrameId;
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export const initiatorLocation = (
|
||||
if (frame !== undefined) return frame;
|
||||
stack = recordValue(stack.parent);
|
||||
}
|
||||
return stringValue(initiator?.url) === undefined ? undefined : initiator;
|
||||
return cdpStringValue(initiator?.url) === undefined ? undefined : initiator;
|
||||
};
|
||||
|
||||
export const exclusionReasonForUrl = (
|
||||
@@ -69,7 +69,7 @@ export const isJsonContentType = (
|
||||
for (const [name, value] of Object.entries(headers))
|
||||
if (
|
||||
name.toLowerCase() === "content-type" &&
|
||||
isJsonMediaType(stringValue(value))
|
||||
isJsonMediaType(cdpStringValue(value))
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
@@ -85,7 +85,7 @@ export const isJsonMediaType = (value: string | null | undefined): boolean => {
|
||||
export const consolePrimitive = (
|
||||
value: UnknownRecord,
|
||||
): { readonly type: string; readonly text: string } | undefined => {
|
||||
const type = stringValue(value.type);
|
||||
const type = cdpStringValue(value.type);
|
||||
switch (type) {
|
||||
case "string":
|
||||
return typeof value.value === "string"
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
/** Capture redacted storage metadata for one authorized origin. */
|
||||
/** Capture redacted storage metadata for an authorized page and its origin. */
|
||||
export const captureStorage = async (
|
||||
context: {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -24,11 +24,12 @@ export const captureStorage = async (
|
||||
readonly input: InspectWebPageInput;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
origin: string,
|
||||
pageUrl: string,
|
||||
limitations: string[],
|
||||
): Promise<{
|
||||
readonly value: WebPageInspection["storage"];
|
||||
}> => {
|
||||
const origin = new URL(pageUrl).origin;
|
||||
const quota = recordValue(
|
||||
await optionalCdpCommand(
|
||||
context,
|
||||
@@ -67,6 +68,7 @@ export const captureStorage = async (
|
||||
? await captureStorageFingerprints({
|
||||
context,
|
||||
origin,
|
||||
cookieUrl: pageUrl,
|
||||
local,
|
||||
session,
|
||||
indexedDbNames: indexed,
|
||||
@@ -106,8 +108,8 @@ const capturedCaches = (
|
||||
values: readonly Record<string, unknown>[],
|
||||
): CapturedCache[] =>
|
||||
values.flatMap((cache) => {
|
||||
const name = stringValue(cache.cacheName);
|
||||
const id = stringValue(cache.cacheId);
|
||||
const name = cdpStringValue(cache.cacheName);
|
||||
const id = cdpStringValue(cache.cacheId);
|
||||
return name === undefined || id === undefined ? [] : [{ name, id }];
|
||||
});
|
||||
|
||||
@@ -128,8 +130,8 @@ const storageItems = async (
|
||||
return { items: [], complete: false };
|
||||
const items = entries.flatMap((entry) => {
|
||||
if (!Array.isArray(entry)) return [];
|
||||
const key = stringValue(entry[0]);
|
||||
const value = stringValue(entry[1]);
|
||||
const key = cdpStringValue(entry[0]);
|
||||
const value = cdpStringValue(entry[1]);
|
||||
return key === undefined || value === undefined ? [] : [{ key, value }];
|
||||
});
|
||||
return { items, complete: items.length === entries.length };
|
||||
@@ -138,7 +140,7 @@ const storageItems = async (
|
||||
const stringArray = (value: unknown): readonly string[] =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((item) => {
|
||||
const text = stringValue(item);
|
||||
const text = cdpStringValue(item);
|
||||
return text === undefined ? [] : [text];
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
import { compareCodePoints } from "../domain/canonicalOrdering.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js";
|
||||
import {
|
||||
recordValue,
|
||||
recordsValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
type StorageFingerprint =
|
||||
WebPageInspection["storage"]["content_fingerprints"][number];
|
||||
@@ -48,6 +52,7 @@ interface FingerprintRun {
|
||||
interface CaptureStorageFingerprintInput {
|
||||
readonly context: StorageFingerprintContext;
|
||||
readonly origin: string;
|
||||
readonly cookieUrl: string;
|
||||
readonly local: CapturedStorageItems;
|
||||
readonly session: CapturedStorageItems;
|
||||
readonly indexedDbNames: readonly string[];
|
||||
@@ -64,6 +69,7 @@ type StorageFingerprintCapture = {
|
||||
export const captureStorageFingerprints = async ({
|
||||
context,
|
||||
origin,
|
||||
cookieUrl,
|
||||
local,
|
||||
session,
|
||||
indexedDbNames,
|
||||
@@ -77,7 +83,7 @@ export const captureStorageFingerprints = async ({
|
||||
const run = { context, limitations, state };
|
||||
addStorageItems(state, "local_storage", local.items);
|
||||
addStorageItems(state, "session_storage", session.items);
|
||||
await addCookies(run, origin);
|
||||
await addCookies(run, cookieUrl);
|
||||
await addIndexedDb(run, origin, indexedDbNames);
|
||||
await addCaches(run, caches);
|
||||
state.items.sort((left, right) =>
|
||||
@@ -107,12 +113,15 @@ const addStorageItems = (
|
||||
|
||||
const addCookies = async (
|
||||
run: FingerprintRun,
|
||||
origin: string,
|
||||
pageUrl: string,
|
||||
): Promise<void> => {
|
||||
run.limitations.push(
|
||||
"Cookie fingerprints cover cookies applicable to the current main-frame URL, not every path on its origin.",
|
||||
);
|
||||
const raw = await optionalCdpCommand(
|
||||
run.context,
|
||||
"Network.getCookies",
|
||||
{ urls: [origin] },
|
||||
{ urls: [pageUrl] },
|
||||
run.limitations,
|
||||
);
|
||||
if (raw === undefined) {
|
||||
@@ -127,10 +136,10 @@ const addCookies = async (
|
||||
const cookies = recordsValue(cookieValues);
|
||||
if (cookies.length !== cookieValues.length) run.state.complete = false;
|
||||
for (const cookie of cookies) {
|
||||
const name = stringValue(cookie.name);
|
||||
const value = stringValue(cookie.value);
|
||||
const domain = stringValue(cookie.domain);
|
||||
const path = stringValue(cookie.path);
|
||||
const name = cdpStringValue(cookie.name);
|
||||
const value = cdpStringValue(cookie.value);
|
||||
const domain = cdpStringValue(cookie.domain);
|
||||
const path = cdpStringValue(cookie.path);
|
||||
if (
|
||||
name === undefined ||
|
||||
value === undefined ||
|
||||
@@ -171,7 +180,7 @@ const addIndexedDb = async (
|
||||
run.state.complete = false;
|
||||
continue;
|
||||
}
|
||||
if (stringValue(database.name) !== databaseName) {
|
||||
if (cdpStringValue(database.name) !== databaseName) {
|
||||
run.state.complete = false;
|
||||
continue;
|
||||
}
|
||||
@@ -189,7 +198,7 @@ const addIndexedDb = async (
|
||||
complete: indexedDbSchemaComplete(stores),
|
||||
});
|
||||
for (const store of stores) {
|
||||
const objectStoreName = stringValue(store.name);
|
||||
const objectStoreName = cdpStringValue(store.name);
|
||||
if (objectStoreName === undefined) {
|
||||
run.state.complete = false;
|
||||
continue;
|
||||
@@ -204,11 +213,11 @@ const addIndexedDb = async (
|
||||
};
|
||||
|
||||
const indexedDbStoreSchema = (store: Readonly<Record<string, unknown>>) => ({
|
||||
name: stringValue(store.name) ?? null,
|
||||
name: cdpStringValue(store.name) ?? null,
|
||||
key_path: stableValue(store.keyPath),
|
||||
auto_increment: store.autoIncrement === true,
|
||||
indexes: recordsValue(store.indexes).map((index) => ({
|
||||
name: stringValue(index.name) ?? null,
|
||||
name: cdpStringValue(index.name) ?? null,
|
||||
key_path: stableValue(index.keyPath),
|
||||
unique: index.unique === true,
|
||||
multi_entry: index.multiEntry === true,
|
||||
@@ -220,14 +229,14 @@ const indexedDbSchemaComplete = (
|
||||
): boolean =>
|
||||
stores.every(
|
||||
(store) =>
|
||||
stringValue(store.name) !== undefined &&
|
||||
cdpStringValue(store.name) !== undefined &&
|
||||
stableValueComplete(store.keyPath) &&
|
||||
typeof store.autoIncrement === "boolean" &&
|
||||
Array.isArray(store.indexes) &&
|
||||
recordsValue(store.indexes).length === store.indexes.length &&
|
||||
recordsValue(store.indexes).every(
|
||||
(index) =>
|
||||
stringValue(index.name) !== undefined &&
|
||||
cdpStringValue(index.name) !== undefined &&
|
||||
stableValueComplete(index.keyPath) &&
|
||||
typeof index.unique === "boolean" &&
|
||||
typeof index.multiEntry === "boolean",
|
||||
@@ -251,7 +260,6 @@ const addIndexedDbRecords = async (
|
||||
securityOrigin: input.origin,
|
||||
databaseName: input.databaseName,
|
||||
objectStoreName: input.objectStoreName,
|
||||
indexName: "",
|
||||
skipCount,
|
||||
pageSize: STORAGE_ENTRY_PAGE_SIZE,
|
||||
},
|
||||
@@ -285,7 +293,10 @@ const addIndexedDbRecords = async (
|
||||
primaryKey,
|
||||
},
|
||||
value,
|
||||
complete: remoteObjectComplete(entry.value),
|
||||
complete:
|
||||
remoteObjectComplete(entry.key) &&
|
||||
remoteObjectComplete(entry.primaryKey) &&
|
||||
remoteObjectComplete(entry.value),
|
||||
});
|
||||
}
|
||||
if (result.hasMore !== true) return;
|
||||
@@ -351,8 +362,8 @@ const addCacheEntry = async (
|
||||
cache: CapturedCache,
|
||||
entry: Readonly<Record<string, unknown>>,
|
||||
): Promise<void> => {
|
||||
const requestURL = stringValue(entry.requestURL);
|
||||
const requestMethod = stringValue(entry.requestMethod);
|
||||
const requestURL = cdpStringValue(entry.requestURL);
|
||||
const requestMethod = cdpStringValue(entry.requestMethod);
|
||||
if (requestURL === undefined || requestMethod === undefined) {
|
||||
run.state.complete = false;
|
||||
return;
|
||||
@@ -363,8 +374,8 @@ const addCacheEntry = async (
|
||||
}
|
||||
const headerRecords = recordsValue(entry.requestHeaders);
|
||||
const requestHeaders = headerRecords.flatMap((header) => {
|
||||
const name = stringValue(header.name);
|
||||
const value = stringValue(header.value);
|
||||
const name = cdpStringValue(header.name);
|
||||
const value = cdpStringValue(header.value);
|
||||
return name === undefined || value === undefined ? [] : [{ name, value }];
|
||||
});
|
||||
if (
|
||||
@@ -380,7 +391,7 @@ const addCacheEntry = async (
|
||||
{ cacheId: cache.id, requestURL, requestHeaders },
|
||||
run.limitations,
|
||||
);
|
||||
const body = stringValue(recordValue(recordValue(raw)?.response)?.body);
|
||||
const body = cdpStringValue(recordValue(recordValue(raw)?.response)?.body);
|
||||
const decoded = body === undefined ? null : decodeBase64(body);
|
||||
const complete = decoded !== null;
|
||||
if (!complete) run.state.complete = false;
|
||||
|
||||
@@ -20,7 +20,7 @@ export const recordsValue = (value: unknown): readonly UnknownRecord[] =>
|
||||
})
|
||||
: [];
|
||||
|
||||
export const stringValue = (value: unknown): string | undefined =>
|
||||
export const cdpStringValue = (value: unknown): string | undefined =>
|
||||
typeof value === "string" ? value : undefined;
|
||||
|
||||
export const numberValue = (value: unknown): number | undefined =>
|
||||
@@ -49,7 +49,7 @@ export const allowedSanitizedUrl = (
|
||||
value: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): ReturnType<typeof sanitizeBrowserUrl> | undefined => {
|
||||
const text = stringValue(value);
|
||||
const text = cdpStringValue(value);
|
||||
if (text === undefined) return undefined;
|
||||
const sanitized = sanitizeBrowserUrl(text);
|
||||
return sanitized.origin !== null && allowedOrigins.has(sanitized.origin)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
recordValue,
|
||||
recordsValue,
|
||||
requiredRecord,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { walkFrameTrees } from "./CdpCaptureDocuments.js";
|
||||
import {
|
||||
@@ -220,9 +220,9 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => {
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return undefined;
|
||||
if (event.method === "Page.frameNavigated")
|
||||
return stringValue(recordValue(params.frame)?.id);
|
||||
return cdpStringValue(recordValue(params.frame)?.id);
|
||||
if (event.method === "Page.navigatedWithinDocument")
|
||||
return stringValue(params.frameId);
|
||||
return cdpStringValue(params.frameId);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -238,15 +238,15 @@ const captureFrames = async (
|
||||
completeness.exclude("frames", "invalid_protocol_value"),
|
||||
)) {
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = stringValue(frame?.id);
|
||||
const path = await authorizedElectronFile(stringValue(frame?.url) ?? "");
|
||||
const frameId = cdpStringValue(frame?.id);
|
||||
const path = await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
|
||||
if (frameId === undefined || path === undefined) {
|
||||
completeness.exclude("frames", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
frames.push({
|
||||
frame_id: frameId,
|
||||
parent_frame_id: stringValue(frame?.parentId) ?? null,
|
||||
parent_frame_id: cdpStringValue(frame?.parentId) ?? null,
|
||||
file_path: path,
|
||||
});
|
||||
}
|
||||
@@ -274,7 +274,7 @@ const captureResources = async (
|
||||
if (tree === undefined) break;
|
||||
for (const resource of recordsValue(tree.resources)) {
|
||||
const path = await authorizedElectronFile(
|
||||
stringValue(resource.url) ?? "",
|
||||
cdpStringValue(resource.url) ?? "",
|
||||
);
|
||||
if (path === undefined) {
|
||||
completeness.exclude("resources", "out_of_target_scope");
|
||||
@@ -282,8 +282,8 @@ const captureResources = async (
|
||||
}
|
||||
const item = {
|
||||
file_path: path,
|
||||
type: stringValue(resource.type) ?? "Other",
|
||||
mime_type: stringValue(resource.mimeType) ?? "",
|
||||
type: cdpStringValue(resource.type) ?? "Other",
|
||||
mime_type: cdpStringValue(resource.mimeType) ?? "",
|
||||
content_size:
|
||||
numberValue(resource.contentSize) === undefined
|
||||
? null
|
||||
@@ -358,7 +358,7 @@ const captureDom = async (
|
||||
const mainFilePath = async (result: unknown): Promise<string | undefined> => {
|
||||
const frameTree = recordValue(requiredRecord(result).frameTree);
|
||||
const frame = recordValue(frameTree?.frame);
|
||||
return await authorizedElectronFile(stringValue(frame?.url) ?? "");
|
||||
return await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
|
||||
};
|
||||
|
||||
const arrayValue = (value: unknown): readonly unknown[] =>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { numberValue, recordValue, stringValue } from "./CdpCaptureValues.js";
|
||||
import {
|
||||
numberValue,
|
||||
recordValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
export interface ElectronScriptDraft {
|
||||
readonly scriptId: string;
|
||||
@@ -35,8 +39,8 @@ export const ingestElectronScriptEvent = (input: {
|
||||
}
|
||||
if (input.event.method !== "Debugger.scriptParsed") return;
|
||||
const value = recordValue(input.event.params);
|
||||
const scriptId = stringValue(value?.scriptId);
|
||||
const rawUrl = stringValue(value?.url);
|
||||
const scriptId = cdpStringValue(value?.scriptId);
|
||||
const rawUrl = cdpStringValue(value?.url);
|
||||
if (scriptId === undefined) {
|
||||
input.completeness.exclude("scripts", "invalid_protocol_value");
|
||||
return;
|
||||
@@ -48,10 +52,10 @@ export const ingestElectronScriptEvent = (input: {
|
||||
input.scripts.push({
|
||||
scriptId,
|
||||
rawUrl,
|
||||
hash: stringValue(value?.hash) ?? "",
|
||||
hash: cdpStringValue(value?.hash) ?? "",
|
||||
length: nonnegativeInteger(value?.length),
|
||||
isModule: value?.isModule === true,
|
||||
language: stringValue(value?.scriptLanguage) ?? null,
|
||||
language: cdpStringValue(value?.scriptLanguage) ?? null,
|
||||
executionContextKey: executionContextKey(value?.executionContextId),
|
||||
});
|
||||
};
|
||||
@@ -63,7 +67,7 @@ const retainExecutionContext = (
|
||||
const parameters = recordValue(event.params);
|
||||
const runtimeContext = recordValue(parameters?.context);
|
||||
const contextKey = executionContextKey(runtimeContext?.id);
|
||||
const frameId = stringValue(recordValue(runtimeContext?.auxData)?.frameId);
|
||||
const frameId = cdpStringValue(recordValue(runtimeContext?.auxData)?.frameId);
|
||||
if (contextKey === null || frameId === undefined) return;
|
||||
frames.set(contextKey, frameId);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { canonicalDigest } from "../domain/comparisonSemantics.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { requiredRecord, stringValue } from "./CdpCaptureValues.js";
|
||||
import { requiredRecord, cdpStringValue } from "./CdpCaptureValues.js";
|
||||
import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
|
||||
@@ -94,7 +94,7 @@ const captureScriptSource = async (
|
||||
input.signal,
|
||||
),
|
||||
);
|
||||
const text = stringValue(result.scriptSource) ?? "";
|
||||
const text = cdpStringValue(result.scriptSource) ?? "";
|
||||
return {
|
||||
source: {
|
||||
included: true,
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
} from "../domain/electronObservation.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js";
|
||||
import {
|
||||
recordValue,
|
||||
recordsValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
@@ -35,7 +39,7 @@ export const captureElectronWorkers = async (input: {
|
||||
}
|
||||
const workers: ElectronPageInspection["workers"] = [];
|
||||
for (const target of recordsValue(recordValue(result)?.targetInfos)) {
|
||||
const type = stringValue(target.type) ?? "";
|
||||
const type = cdpStringValue(target.type) ?? "";
|
||||
if (!type.includes("worker")) continue;
|
||||
const openerTargetId = boundedTargetField(target.openerId);
|
||||
const parentFrameId = boundedTargetField(target.parentFrameId);
|
||||
@@ -46,7 +50,7 @@ export const captureElectronWorkers = async (input: {
|
||||
input.completeness.exclude("workers", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
const path = await authorizedElectronFile(stringValue(target.url) ?? "");
|
||||
const path = await authorizedElectronFile(cdpStringValue(target.url) ?? "");
|
||||
if (path === undefined) {
|
||||
input.completeness.exclude("workers", "out_of_target_scope");
|
||||
continue;
|
||||
@@ -71,6 +75,6 @@ export const captureElectronWorkers = async (input: {
|
||||
};
|
||||
|
||||
const boundedTargetField = (value: unknown): string | null => {
|
||||
const field = stringValue(value);
|
||||
const field = cdpStringValue(value);
|
||||
return field === undefined || field === "" ? null : field;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
@@ -218,7 +218,7 @@ class TimelineCapture {
|
||||
this.#add({
|
||||
type: "navigation_requested",
|
||||
params,
|
||||
rawUrl: stringValue(params.url) ?? null,
|
||||
rawUrl: cdpStringValue(params.url) ?? null,
|
||||
requestId: null,
|
||||
detail: reason,
|
||||
});
|
||||
@@ -227,7 +227,7 @@ class TimelineCapture {
|
||||
#navigationCommitted(event: CdpEvent, params: UnknownRecord): void {
|
||||
const frame = recordValue(params.frame);
|
||||
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
|
||||
const rawUrl = stringValue(frame?.url);
|
||||
const rawUrl = cdpStringValue(frame?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: this.#pendingReload ? "same_origin_reload" : "navigation_committed",
|
||||
@@ -247,7 +247,7 @@ class TimelineCapture {
|
||||
|
||||
#sameDocument(event: CdpEvent, params: UnknownRecord): void {
|
||||
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
|
||||
const rawUrl = stringValue(params.url);
|
||||
const rawUrl = cdpStringValue(params.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: "same_document_navigation",
|
||||
@@ -265,21 +265,21 @@ class TimelineCapture {
|
||||
}
|
||||
|
||||
#redirect(params: UnknownRecord): void {
|
||||
const resourceType = stringValue(params.type);
|
||||
const resourceType = cdpStringValue(params.type);
|
||||
if (resourceType !== undefined && resourceType !== "Document") return;
|
||||
if (
|
||||
stringValue(params.frameId) !== this.mainFrameId ||
|
||||
cdpStringValue(params.frameId) !== this.mainFrameId ||
|
||||
recordValue(params.redirectResponse) === undefined
|
||||
)
|
||||
return;
|
||||
const request = recordValue(params.request);
|
||||
const rawUrl = stringValue(request?.url);
|
||||
const rawUrl = cdpStringValue(request?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: "redirect",
|
||||
params,
|
||||
rawUrl: rawUrl ?? null,
|
||||
requestId: stringValue(params.requestId) ?? null,
|
||||
requestId: cdpStringValue(params.requestId) ?? null,
|
||||
detail: null,
|
||||
});
|
||||
if (destination.scope !== "approved") {
|
||||
@@ -289,19 +289,19 @@ class TimelineCapture {
|
||||
}
|
||||
|
||||
#loadingFailed(params: UnknownRecord): void {
|
||||
const frame = stringValue(params.frameId);
|
||||
const frame = cdpStringValue(params.frameId);
|
||||
if (frame !== undefined && frame !== this.mainFrameId) return;
|
||||
this.#add({
|
||||
type: "load_failed",
|
||||
params,
|
||||
rawUrl: null,
|
||||
requestId: stringValue(params.requestId) ?? null,
|
||||
requestId: cdpStringValue(params.requestId) ?? null,
|
||||
detail: safeNetworkError(params.errorText),
|
||||
});
|
||||
}
|
||||
|
||||
#lifecycle(params: UnknownRecord): void {
|
||||
if (stringValue(params.frameId) !== this.mainFrameId) return;
|
||||
if (cdpStringValue(params.frameId) !== this.mainFrameId) return;
|
||||
this.#add({
|
||||
type: "lifecycle",
|
||||
params,
|
||||
@@ -344,7 +344,7 @@ const observationEventMatches = (
|
||||
return event.sessionId === sessionId;
|
||||
return (
|
||||
sessionId !== undefined &&
|
||||
stringValue(recordValue(event.params)?.sessionId) === sessionId
|
||||
cdpStringValue(recordValue(event.params)?.sessionId) === sessionId
|
||||
);
|
||||
};
|
||||
|
||||
@@ -405,7 +405,7 @@ const captureFinalUrl = async (
|
||||
};
|
||||
|
||||
const frameId = (result: unknown): string | undefined =>
|
||||
stringValue(
|
||||
cdpStringValue(
|
||||
recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id,
|
||||
);
|
||||
|
||||
@@ -423,17 +423,18 @@ const scopedUrl = (
|
||||
: { url: null, scope: "unsupported" };
|
||||
};
|
||||
|
||||
const boundedId = (value: unknown): string | null => stringValue(value) ?? null;
|
||||
const boundedId = (value: unknown): string | null =>
|
||||
cdpStringValue(value) ?? null;
|
||||
|
||||
const safeDetail = (value: unknown): string | null => {
|
||||
const detail = stringValue(value)?.toLowerCase();
|
||||
const detail = cdpStringValue(value)?.toLowerCase();
|
||||
return detail !== undefined && /^[a-z0-9_.:-]{1,100}$/u.test(detail)
|
||||
? detail
|
||||
: null;
|
||||
};
|
||||
|
||||
const safeNetworkError = (value: unknown): string | null => {
|
||||
const error = stringValue(value);
|
||||
const error = cdpStringValue(value);
|
||||
return error !== undefined && /^net::ERR_[A-Z0-9_]{1,100}$/u.test(error)
|
||||
? error
|
||||
: null;
|
||||
|
||||
@@ -144,7 +144,7 @@ const captureAuthorizedPage = async (
|
||||
);
|
||||
const storageCapture = await captureStorage(
|
||||
context,
|
||||
frames[0]?.origin ?? new URL(attachedUrl).origin,
|
||||
attachedUrl,
|
||||
limitations,
|
||||
);
|
||||
if (!input.include_storage_keys)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
requiredRecord,
|
||||
sourceExcluded,
|
||||
sourceResult,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CaptureContext } from "./CdpPageCapture.js";
|
||||
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
||||
@@ -115,7 +115,7 @@ const captureScriptSource = async (
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const content = stringValue(result.scriptSource) ?? "";
|
||||
const content = cdpStringValue(result.scriptSource) ?? "";
|
||||
return sourceResult(content);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isHttpUrl,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
cdpStringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CaptureContext } from "./CdpPageCapture.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
@@ -32,19 +32,19 @@ export const captureWorkers = async (
|
||||
const items: WebPageInspection["workers"] = [];
|
||||
if (result === undefined) completeness.unavailable("workers");
|
||||
for (const target of recordsValue(recordValue(result)?.targetInfos)) {
|
||||
const type = stringValue(target.type) ?? "";
|
||||
const type = cdpStringValue(target.type) ?? "";
|
||||
const url = allowedSanitizedUrl(target.url, allowedOrigins);
|
||||
const relatedToPage =
|
||||
stringValue(target.openerId) === context.target.id ||
|
||||
(stringValue(target.parentFrameId) !== undefined &&
|
||||
frameIds.has(stringValue(target.parentFrameId) ?? ""));
|
||||
cdpStringValue(target.openerId) === context.target.id ||
|
||||
(cdpStringValue(target.parentFrameId) !== undefined &&
|
||||
frameIds.has(cdpStringValue(target.parentFrameId) ?? ""));
|
||||
if (!type.includes("worker")) continue;
|
||||
if (!relatedToPage) {
|
||||
completeness.exclude("workers", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
if (url === undefined) {
|
||||
const rawUrl = stringValue(target.url);
|
||||
const rawUrl = cdpStringValue(target.url);
|
||||
completeness.exclude(
|
||||
"workers",
|
||||
rawUrl === undefined || rawUrl === ""
|
||||
@@ -56,13 +56,13 @@ export const captureWorkers = async (
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
target_id: stringValue(target.targetId) ?? "",
|
||||
target_id: cdpStringValue(target.targetId) ?? "",
|
||||
type,
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
attached: target.attached === true,
|
||||
opener_target_id: stringValue(target.openerId) ?? null,
|
||||
parent_frame_id: stringValue(target.parentFrameId) ?? null,
|
||||
opener_target_id: cdpStringValue(target.openerId) ?? null,
|
||||
parent_frame_id: cdpStringValue(target.parentFrameId) ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user