From 26881da76aa320c8529fcc741df0fc9898d97bdc Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:53:33 +0800 Subject: [PATCH] feat(ghidra): enable Windows x64 read-only analysis (#699) * feat(ghidra): enable Windows x64 read-only analysis * test(ghidra): exercise native Windows runtime boundaries * test(ghidra): verify current Windows inventory and isolate ACL observer * fix(ghidra): align Windows admission and address P0 review --- .github/workflows/ci.yml | 51 +- .github/workflows/real-ghidra-windows.yml | 34 +- .github/workflows/release.yml | 6 + README.md | 12 +- bridge/ghidra/ReaGhidraBridge.java | 30 +- docs/adr/README.md | 10 +- docs/architecture.mermaid | 5 + docs/installation.md | 30 +- docs/native-investigation.md | 6 +- docs/provider-evaluation.md | 41 +- docs/roadmap.md | 23 +- docs/testing.md | 9 +- docs/windows-ghidra-p0.md | 216 +++---- native/windows/README.md | 102 ++++ native/windows/src/addon.cc | 123 ++++ native/windows/src/authority.hpp | 120 ++++ native/windows/src/filesystem.cc | 531 ++++++++++++++++++ native/windows/src/process.cc | 271 +++++++++ package.json | 12 +- scripts/build-windows-native.mjs | 150 +++++ scripts/lib/verifier-run.mjs | 2 +- scripts/verify-packaged-ghidra-windows.mjs | 300 ++++++++++ scripts/verify-real-ghidra-windows.mjs | 67 ++- scripts/verify-windows-native-artifact.mjs | 39 ++ scripts/verify-windows-native.mjs | 427 ++++++++++++++ scripts/verify-windows-package.mjs | 2 + src/application/BinaryTargetResolver.ts | 6 +- src/application/Doctor.ts | 3 +- .../SetupRegistrationEnvironment.test.ts | 40 ++ .../SetupRegistrationEnvironment.ts | 3 +- src/application/WindowsCapabilities.ts | 15 +- src/ghidra/GhidraClient.ts | 14 +- src/ghidra/GhidraClientTypes.ts | 2 + src/ghidra/GhidraDiagnostics.ts | 4 + src/ghidra/GhidraDoctor.test.ts | 116 +++- src/ghidra/GhidraDoctor.ts | 63 ++- src/ghidra/GhidraInstallation.ts | 2 +- src/ghidra/GhidraLauncher.ts | 108 +++- src/ghidra/GhidraLauncherEnvironment.test.ts | 45 ++ src/ghidra/GhidraProvider.behavior.test.ts | 13 +- src/ghidra/GhidraProvider.ts | 16 +- src/ghidra/GhidraProviderCapabilities.ts | 40 +- src/ghidra/GhidraTargetSnapshot.ts | 24 + src/ghidra/GhidraTransport.ts | 23 + src/process/PrivateRuntimeRoot.ts | 21 +- src/process/ProviderProcess.ts | 55 +- src/process/WindowsAuthority.ts | 33 +- src/windows/WindowsNativeLoader.test.ts | 150 +++++ src/windows/WindowsNativeLoader.ts | 173 ++++++ src/windows/WindowsOwnedProcess.test.ts | 20 + src/windows/WindowsOwnedProcess.ts | 190 +++++++ src/windows/WindowsPrivateRuntime.ts | 163 ++++++ .../setupUnavailableProvider.test.ts | 95 ++++ .../boundary/process/providerProcess.test.ts | 3 +- .../providers/ghidra/ghidraLauncher.test.ts | 29 +- .../ghidra/ghidraTargetSnapshot.test.ts | 44 +- .../providers/ghidra/ghidraTransport.test.ts | 22 +- .../ghidra/realGhidraWindowsWorkflow.test.ts | 2 +- .../ghidra/windowsCapabilities.test.ts | 20 +- tests/fixtures/captureGhidraLaunch.mjs | 2 + tests/fixtures/ghidraRuntime.ts | 50 ++ tests/fixtures/providerProcess.ts | 7 +- tests/fixtures/windows/nonAdminRunner.cc | 112 ++++ tests/fixtures/windows/processBoundary.cc | 98 ++++ vitest.config.ts | 2 +- 65 files changed, 4091 insertions(+), 356 deletions(-) create mode 100644 native/windows/README.md create mode 100644 native/windows/src/addon.cc create mode 100644 native/windows/src/authority.hpp create mode 100644 native/windows/src/filesystem.cc create mode 100644 native/windows/src/process.cc create mode 100644 scripts/build-windows-native.mjs create mode 100644 scripts/verify-packaged-ghidra-windows.mjs create mode 100644 scripts/verify-windows-native-artifact.mjs create mode 100644 scripts/verify-windows-native.mjs create mode 100644 src/application/SetupRegistrationEnvironment.test.ts create mode 100644 src/ghidra/GhidraLauncherEnvironment.test.ts create mode 100644 src/windows/WindowsNativeLoader.test.ts create mode 100644 src/windows/WindowsNativeLoader.ts create mode 100644 src/windows/WindowsOwnedProcess.test.ts create mode 100644 src/windows/WindowsOwnedProcess.ts create mode 100644 src/windows/WindowsPrivateRuntime.ts create mode 100644 tests/boundary/filesystem/setupUnavailableProvider.test.ts create mode 100644 tests/fixtures/ghidraRuntime.ts create mode 100644 tests/fixtures/windows/nonAdminRunner.cc create mode 100644 tests/fixtures/windows/processBoundary.cc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32d14e3f0..8a8679e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,9 +215,30 @@ jobs: - name: Verify fixtures from a clean checkout run: npm run verify:fixtures + windows-native-build: + name: Build bundled Windows x64 native controls + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.package == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Prepare cross compiler + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 + - run: npm run build:windows-native + - run: npm run verify:windows-native:artifact + - uses: actions/upload-artifact@v4 + with: + name: windows-native-controls + path: native/windows/build/ + if-no-files-found: error + package-e2e: name: Package/install integration (${{ matrix.os }}) - needs: changes + needs: [changes, windows-native-build] if: github.event_name == 'push' || needs.changes.outputs.package == 'true' strategy: fail-fast: false @@ -231,6 +252,11 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci + - uses: actions/download-artifact@v4 + with: + name: windows-native-controls + path: native/windows/build/ + - run: npm run verify:windows-native:artifact - run: npm run verify:package apple-artifacts: @@ -302,7 +328,7 @@ jobs: windows-curated: name: Windows capability and package lane - needs: changes + needs: [changes, windows-native-build] if: github.event_name == 'push' || needs.changes.outputs.package == 'true' runs-on: windows-latest timeout-minutes: 25 @@ -315,6 +341,11 @@ jobs: - run: npm ci - run: npm run build:cached - run: npm run typecheck + - uses: actions/download-artifact@v4 + with: + name: windows-native-controls + path: native/windows/build/ + - run: npm run verify:windows-native:artifact - name: Record named Windows host capabilities run: node scripts/windows-capabilities.mjs | Out-File -Encoding utf8NoBOM windows-capabilities.json - name: Validate Windows host capability report @@ -327,18 +358,14 @@ jobs: # ignores unmatched paths, so a stale entry would otherwise report green # while skipping coverage. # - # Every path here must pass on windows-latest WITHOUT verified Windows - # native authority. src/ghidra/GhidraProvider.test.ts, - # src/ghidra/GhidraProvider.behavior.test.ts successors, and - # tests/boundary/process/providerProcess.test.ts are deliberately absent: - # they require Job Object process ownership and private-runtime DACL - # enforcement, which this runner does not provide by design. Those claims - # belong behind the same approval boundary as the real Ghidra Windows - # workflow, not in the default lane. + # The matching native artifact establishes real Job Object and private + # runtime boundaries for these fixtures. Ghidra and Java are not needed; + # real engine claims remain in the separate controlled-provider workflow. - name: Run curated Windows capability tests run: >- npx vitest run --coverage.enabled=false tests/boundary/filesystem/binaryTarget.test.ts + tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/asarInventoryFreshness.test.ts tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts tests/conformance/providers/ghidra/windowsCapabilities.test.ts @@ -347,6 +374,9 @@ jobs: tests/boundary/providers/ghidra/ghidraTransport.test.ts tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts tests/boundary/providers/ghidra/ghidraLauncher.test.ts + src/windows/WindowsNativeLoader.test.ts + src/windows/WindowsOwnedProcess.test.ts + src/ghidra/GhidraLauncherEnvironment.test.ts src/process/ProcessOwnership.test.ts src/process/ProcessOwnership.part2.test.ts src/process/ProcessOwnership.part3.test.ts @@ -354,6 +384,7 @@ jobs: src/application/Update.test.ts tests/process-global/updateMaintenance.test.ts tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts + - run: npm run verify:windows-native - run: npm run verify:package:windows - uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/real-ghidra-windows.yml b/.github/workflows/real-ghidra-windows.yml index a5e9f6be6..6bebfa623 100644 --- a/.github/workflows/real-ghidra-windows.yml +++ b/.github/workflows/real-ghidra-windows.yml @@ -11,9 +11,29 @@ permissions: contents: read jobs: + native-artifact: + name: Build matching Windows native artifact + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Prepare cross compiler + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 + - run: npm run build:windows-native + - run: npm run verify:windows-native:artifact + - uses: actions/upload-artifact@v4 + with: + name: real-ghidra-windows-native + path: native/windows/build/ + if-no-files-found: error + verify: name: Real Ghidra 12.1.4 Windows x64 PE proof if: github.ref == 'refs/heads/main' + needs: native-artifact environment: real-ghidra-windows runs-on: [self-hosted, Windows, x64, ghidra-12-1-2] timeout-minutes: 20 @@ -23,13 +43,25 @@ jobs: with: node-version-file: .nvmrc - run: npm ci + - uses: actions/download-artifact@v4 + with: + name: real-ghidra-windows-native + path: native/windows/build/ + - run: npm run verify:windows-native:artifact + - run: npm run verify:windows-native - name: Verify all admitted Windows Ghidra operations env: REA_ANALYSIS_PROVIDER: ghidra run: npm run verify:ghidra:windows | Tee-Object -FilePath windows-ghidra-proof.log + - name: Verify ordinary-user CLI and MCP operations + env: + REA_ANALYSIS_PROVIDER: ghidra + run: npm run verify:ghidra:windows:package | Tee-Object -FilePath windows-ghidra-package-proof.log - uses: actions/upload-artifact@v4 if: always() with: name: windows-ghidra-proof - path: windows-ghidra-proof.log + path: | + windows-ghidra-proof.log + windows-ghidra-package-proof.log retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cc61c04e..5995f4c7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,12 @@ jobs: - run: npm ci + - name: Prepare Windows native build toolchain + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 + + - name: Build and verify bundled Windows native controls + run: npm run build:windows-native && npm run verify:windows-native:artifact + - name: Verify packaged release before publishing run: npm run verify:package diff --git a/README.md b/README.md index 9baa85bff..9d75bc666 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Update either installation with `rea update`. Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-read-only-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed. -Windows Ghidra support is experimental and currently unavailable. The required Windows process ownership, private-directory permissions, and safe-path checks are not implemented. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for the remaining requirements. +Windows Ghidra support is experimental for native x86-64 PE applications on local NTFS. The package bundles native Job Object, private-DACL, and path-admission controls. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope. If something is not working, run: @@ -200,13 +200,13 @@ rea providers --json Setup checks the installations and saves their paths in your selected agents' configuration after approval. Ghidra and Java must already be installed; REA does not download or change them. -The adapter exposes **22 read-only operations** for functions, strings, symbols, assembly, decompilation, calls, references, instructions, and data types. These also support REA's overview, search, call-graph, and function-analysis workflows. Ghidra does not provide GUI controls or annotation changes through REA. +The adapter exposes **25 read-only operations** for functions, strings, symbols, assembly, decompilation, calls, references, instructions, and data types. These also support REA's overview, search, call-graph, and function-analysis workflows. Ghidra does not provide GUI controls or annotation changes through REA. REA analyzes a temporary copy of the target and removes the temporary project when the session closes. Results identify what Ghidra observed and what it could not resolve. Decompilation produces pseudocode rather than the original source. Ghidra also imports DOS MZ executables with an explicit 16-bit x86 real-mode profile. Function results include complete observed body ranges, distinguishing owned bytes from the enclosing span. See the [DOS analysis guide](docs/ghidra-dos.md) for addresses, packing, and verification boundaries. -Windows operations remain unavailable pending the controls described in the [Windows Ghidra P0 guide](docs/windows-ghidra-p0.md). See [Ghidra installation](docs/installation.md#ghidra), [provider evaluation](docs/provider-evaluation.md), and [testing](docs/testing.md) for configuration details, coverage, and real-provider verification. +Windows Ghidra P0 uses bundled native controls for its read-only native x86-64 PE boundary on local NTFS; see the [Windows Ghidra P0 guide](docs/windows-ghidra-p0.md). See [Ghidra installation](docs/installation.md#ghidra), [provider evaluation](docs/provider-evaluation.md), and [testing](docs/testing.md) for configuration details, coverage, and real-provider verification. To remove only REA-owned MCP registrations and the managed skill: @@ -335,7 +335,7 @@ REA supports native application, JavaScript, Electron, .NET, and browser investi - **Open questions:** Track unresolved findings, contradictions, and follow-up probes. Reconstruction checks report pass, fail, or unknown rather than treating missing evidence as a pass. - **Guided workflows:** Start six [MCP investigation workflows](docs/mcp-prompts.md) with suggestions based on your current session. -Windows Ghidra operations are currently unavailable. Hopper-only features, such as GUI controls and annotations, are not available through Ghidra. +Windows Ghidra P0 supports the read-only native x86-64 PE boundary on local NTFS. Hopper-only features, such as GUI controls and annotations, are not available through Ghidra. ### Website observation with CDP @@ -522,7 +522,7 @@ Use `--provider`, or `provider_id` in MCP, to choose Hopper or Ghidra for a targ With `auto`, REA selects the only available tool that supports the target. If both are available, specify one before opening the target. The session keeps that choice until you explicitly switch or close it; a failure never silently switches tools. Artifact-only analysis can work without a native analysis tool. -Run `rea providers` and `rea capabilities` to check availability and supported operations. Ghidra exposes 22 read-only operations on supported Linux and macOS hosts. GUI controls and annotation changes require Hopper. Windows Ghidra operations remain unavailable. +Run `rea providers` and `rea capabilities` to check availability and supported operations. Ghidra exposes 25 read-only operations on supported Linux and macOS hosts. GUI controls and annotation changes require Hopper. Windows Ghidra P0 supports native x86-64 PE applications on local NTFS with bundled native controls. The session also reports active work and cleanup status. If a caller times out, the analysis tool may still be busy; `analysis_activity` reports that state. A `cleanup_incomplete` result identifies resources whose shutdown or removal could not be verified. See [provider selection and analysis profiles](docs/adr/0001-provider-selection-and-analysis-profiles.md) for session, cache, and process-tracking details. @@ -600,7 +600,7 @@ Analysis runs locally. REA communicates with Hopper and Ghidra through authentic Runtime requests act on the declared target and lifecycle. Analysis tools and launched targets run with your user permissions, and native UI capture still depends on macOS Accessibility and Screen Recording access. Static JavaScript analysis does not execute extracted modules; use direct browser, Electron, or process capture when runtime behavior is needed. -Windows Ghidra operations are blocked until REA implements the required process ownership, private-directory permissions, and safe-path checks. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md). +Windows Ghidra P0 automatically uses native Job Objects, protected private-runtime DACLs, and handle-based admission. Its experimental scope is native x86-64 PE applications on local NTFS. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md). ## FAQ diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 4aa4db4f9..e6b9902d1 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -160,7 +160,14 @@ public final class ReaGhidraBridge extends HeadlessScript { } Path descriptorPath = Path.of(arguments[0]); SessionDescriptor descriptor = readDescriptor(descriptorPath); - Files.deleteIfExists(descriptorPath); + // Windows retains a native immutable bearer descriptor lease until + // runtime_close; POSIX removes it after this read. Normal cleanup removes + // the Windows file, while abrupt owner death can leave private residue. + // Its protected runtime DACL also protects the bearer token while the + // bridge is running. POSIX keeps its existing consume-and-delete flow. + if (!isWindowsHost()) { + Files.deleteIfExists(descriptorPath); + } if (currentProgram == null) { throw new IllegalStateException("REA bridge requires an imported program"); } @@ -232,18 +239,23 @@ public final class ReaGhidraBridge extends HeadlessScript { JsonObject endpoint = new JsonObject(); endpoint.addProperty("host", "127.0.0.1"); endpoint.addProperty("port", address.getPort()); + // The Windows native reader refuses a file while a writer still + // owns it. Closing CREATE_NEW publishes the complete record without + // requesting rename rights on the retained private directory guard. Files.writeString( - pendingPath, + isWindowsHost() ? endpointPath : pendingPath, GSON.toJson(endpoint) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE ); - try { - Files.move(pendingPath, endpointPath, StandardCopyOption.ATOMIC_MOVE); - } - catch (AtomicMoveNotSupportedException exception) { - Files.move(pendingPath, endpointPath); + if (!isWindowsHost()) { + try { + Files.move(pendingPath, endpointPath, StandardCopyOption.ATOMIC_MOVE); + } + catch (AtomicMoveNotSupportedException exception) { + Files.move(pendingPath, endpointPath); + } } acceptClient(server, descriptor); } @@ -253,6 +265,10 @@ public final class ReaGhidraBridge extends HeadlessScript { } } + private static boolean isWindowsHost() { + return System.getProperty("os.name", "").startsWith("Windows"); + } + private void acceptClient( ServerSocketChannel server, SessionDescriptor descriptor) throws Exception { diff --git a/docs/adr/README.md b/docs/adr/README.md index 8511e67de..5d6949079 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,8 +4,8 @@ Accepted decisions describe the architecture that implementation PRs must follow. Acceptance does not by itself mean the behavior is shipped; each record states its implementation status separately. -| ADR | Status | Implementation | -| ------------------------------------------------------------------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------- | -| [0001: Provider selection and analysis profiles](0001-provider-selection-and-analysis-profiles.md) | Accepted | Provider selection and 22 Ghidra operations shipped on Linux/macOS; Windows analysis blocked | -| [0002: Controlled JavaScript replay authority and sandbox policy](0002-controlled-replay-authority-and-sandbox.md) | Superseded | Historical design; the controlled JavaScript replay tool was removed | -| [0003: Managed-code evidence and provider boundary](0003-managed-code-evidence-and-provider-boundary.md) | Partially superseded | Seven static managed tools remain; runtime planning removed | +| ADR | Status | Implementation | +| ------------------------------------------------------------------------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| [0001: Provider selection and analysis profiles](0001-provider-selection-and-analysis-profiles.md) | Accepted | Provider selection and 22 Ghidra operations shipped on Linux/macOS; experimental Windows x64 P0 with bundled native controls | +| [0002: Controlled JavaScript replay authority and sandbox policy](0002-controlled-replay-authority-and-sandbox.md) | Superseded | Historical design; the controlled JavaScript replay tool was removed | +| [0003: Managed-code evidence and provider boundary](0003-managed-code-evidence-and-provider-boundary.md) | Partially superseded | Seven static managed tools remain; runtime planning removed | diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 80897a487..fc1841249 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -30,6 +30,8 @@ graph TD subgraph "Provider Process Foundation" PROCESS["src/process/
owned process groups, private roots
deadlines, bounded file reads and diagnostics, cleanup"] + WINDOWS["src/windows/
Windows host boundary adapters
package loader, opaque handles, owned jobs and private runtimes"] + WINDOWS_NATIVE["native/windows/src/
Node-API implementation
NTFS admission, protected DACLs and atomic Job Object assignment"] end subgraph "Contracts Layer" @@ -76,6 +78,9 @@ graph TD GHIDRA --> GHIDRA_BRIDGE HOPPER --> PROCESS GHIDRA --> PROCESS + GHIDRA --> WINDOWS + PROCESS --> WINDOWS + WINDOWS --> WINDOWS_NATIVE APP --> PROCESS APP --> DOMAIN JS_RECON --> AUX diff --git a/docs/installation.md b/docs/installation.md index c8b09bdb8..d4a516669 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -213,22 +213,26 @@ copying license secrets into logs, or killing unrelated Hopper processes. ## Ghidra -REA connects to an existing Ghidra installation on Linux x64 or macOS x64/arm64. +REA connects to an existing Ghidra installation on Linux x64, macOS x64/arm64, +or experimental Windows x64 P0. It requires Ghidra 12.1.4 and a 64-bit full JDK 21. On macOS, the installation must include the native decompiler for the host architecture; REA does not build it or change Gatekeeper quarantine settings. -The adapter exposes 22 read-only operations: ten inventory/name/search +The adapter exposes 25 read-only operations: thirteen inventory/name/search operations and twelve function-analysis operations. These cover metadata, decompilation, assembly, resolved calls, typed references, xrefs, function -dossiers, instructions, and recovered data types. GUI controls and annotation +dossiers, instructions, recovered data types, measured load mappings, loaded +memory bytes, and observed file offsets. Independent load-image attestation +currently supports DOS MZ only; PE returns its measurements with that limitation. +GUI controls and annotation changes require Hopper. -Windows Ghidra operations are currently unavailable. The adapter reports -`unsupported_host` until verified Job Object process ownership, private runtime -DACLs, and reparse-safe path admission are implemented. The -[Windows Ghidra P0 guide](windows-ghidra-p0.md) describes the intended boundary -and remaining controls. +Windows P0 admits native x86-64 PE applications on fixed local NTFS volumes. +The npm package bundles native Job Object ownership, protected private runtime +DACLs, and handle-based path admission; no separate addon installation is needed. +See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and +limitations. Extract Ghidra and install the JDK outside REA, then export absolute paths: @@ -239,7 +243,7 @@ rea doctor --json rea setup ``` -For Windows diagnostics, use the same installation paths in PowerShell. These settings do not enable the blocked analysis operations: +On Windows, configure the existing installation in PowerShell: ```powershell $env:GHIDRA_INSTALL_DIR = "C:\tools\ghidra_12.1.4_PUBLIC" @@ -252,8 +256,8 @@ rea providers --json REA skill after approval. Direct registrations use Node to launch REA's entry script; package-runner registrations use the pinned `npx` command. Hopper installation remains unavailable on Windows. Setup never installs Ghidra, -Java, or Python, and agent registration does not enable blocked Ghidra -operations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics. +Java, or Python. It preserves valid detected Ghidra/JDK settings in agent +registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics. Doctor validates the platform, architecture, application version, `support/analyzeHeadless` or `support/analyzeHeadless.bat`, Java @@ -268,8 +272,8 @@ home/cache/config/temp paths. REA passes `-readOnly`, `-deleteProject`, uses Ghidra's default analysis and resource settings, and loads its packaged Java bridge via `-scriptPath`; it never opens an existing user project. Linux and macOS use a current-user-only local bridge socket and descriptor. The -experimental Windows transport uses authenticated IPv4 loopback, but missing -native ownership and path controls keep Windows operations unavailable. +experimental Windows transport uses authenticated IPv4 loopback with a +private native-owned bearer descriptor and Job Object process ownership. Operations begin only after default auto-analysis completes. The provider startup deadline fails the open rather than exposing partial analysis. One diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f584af1c4..43627c33f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -146,9 +146,9 @@ actions may change application data or trigger network activity. Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to use them. Ghidra analysis supports Linux x64 and macOS x64/arm64; macOS requires -the matching native decompiler. Windows Ghidra analysis is unavailable until -Job Object process ownership, private runtime DACLs, and reparse-safe path checks -are implemented and verified. See [Windows Ghidra P0](windows-ghidra-p0.md) and +the matching native decompiler. Experimental Windows x64 P0 admits native +x86-64 PE applications on local NTFS using bundled Job Object ownership, +protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and [issue #527](https://github.com/morluto/rea/issues/527). Ghidra has no GUI or mutation authority, and REA never falls back automatically to Hopper. diff --git a/docs/provider-evaluation.md b/docs/provider-evaluation.md index dd6c2a61d..ca4f80b6d 100644 --- a/docs/provider-evaluation.md +++ b/docs/provider-evaluation.md @@ -4,18 +4,19 @@ Ghidra read-only analysis is available on Linux x64 and macOS x64/arm64. Install Ghidra 12.1.4 and a full 64-bit JDK 21 separately, then configure REA to use them. macOS installations need the matching native decompiler. -REA imports one target into a temporary project and exposes 22 read-only +REA imports one target into a temporary project and exposes 25 read-only operations after analysis completes. They cover inventories, search, decompilation, assembly, function metadata, resolved calls, references, control flow, instructions, and recovered types. Results identify the provider version, analysis settings, target digest, and any unavailable or incomplete facts. Ghidra does not expose Hopper's GUI or annotation operations. -Windows Ghidra analysis is unavailable, including on a correctly configured -Ghidra/JDK host. Job Object process ownership, private runtime DACLs, and -reparse-safe path checks are not implemented. Package and adapter tests do not -establish real Windows analysis support. See [Windows Ghidra P0](windows-ghidra-p0.md) -and [issue #527](https://github.com/morluto/rea/issues/527). +Windows x64 P0 supports native x86-64 PE applications on local NTFS with +bundled Job Object ownership, protected runtime DACLs, and handle-based path +admission. Real ordinary-user verification covers all 25 read-only operations +through the packaged CLI and MCP, target integrity, and cleanup. This remains +an experimental boundary; see [Windows Ghidra P0](windows-ghidra-p0.md) and +[issue #527](https://github.com/morluto/rea/issues/527). [ADR-0001](adr/0001-provider-selection-and-analysis-profiles.md) fixes the provider registry, deterministic selection, target binding, analysis profile, @@ -73,7 +74,7 @@ token-free endpoint record, but cannot start while the native authority is unavailable. Its `taskkill` cleanup code does not establish Job Object ownership, private DACLs, or reparse-safe paths. -The provider catalog lists the 22 Ghidra operations. GUI cursor, +The provider catalog lists the 25 Ghidra operations. GUI cursor, navigation, and mutation operations remain absent; the router therefore reports them unavailable instead of borrowing Hopper semantics or inferring capability from a successful import. @@ -122,12 +123,14 @@ Mach-O target coverage. It requires `clang`, LLD, and `lld-link`; set these tools before compilation. Keeping this matrix separate lets Linux host/provider acceptance run with only the host compiler. -`npm run verify:ghidra:windows` remains blocked by the missing Windows native -authority. After those controls are implemented, the lane must check the -source-owned native x86-64 PE fixture, all read-only operations, -target/snapshot/import SHA-256 linkage, and project, endpoint, process, and -runtime cleanup. The existence of this verifier is not evidence that Windows -Ghidra analysis works. +`npm run verify:ghidra:windows` checks the source-owned native x86-64 PE +fixture, all 25 read-only operations, target/snapshot/import SHA-256 linkage, +and project, endpoint, process, and runtime cleanup. The independent native +lane checks DACLs, handle admission, cancellation, and Job Object lifecycle. +`npm run verify:ghidra:windows:package` packs and installs REA into an isolated +prefix, then checks ordinary-user CLI/MCP operations against canonical contracts. +All lanes require matching native controls; package startup alone establishes +only package compatibility. ## Shared provider-process foundation @@ -149,12 +152,12 @@ only the generic process mechanisms. ## Shortlist -| Provider | License / automation surface | What it brings | REA fit and blockers | -| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Static analysis, multiple processors and formats, scripting, and project/database workflows | 22 read-only operations shipped on Linux/macOS with installation checks, temporary projects, a serial API queue, and real ELF/PE/Mach-O conformance. Windows analysis is blocked; GUI and mutation operations are unavailable. | -| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. | -| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. | -| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. | +| Provider | License / automation surface | What it brings | REA fit and blockers | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Static analysis, multiple processors and formats, scripting, and project/database workflows | 25 read-only operations shipped on Linux/macOS with installation checks, temporary projects, a serial API queue, and real ELF/PE/Mach-O conformance. Experimental Windows x64 P0 supports native x86-64 PE on local NTFS with bundled native controls and ordinary-user CLI/MCP verification; GUI and mutation operations are unavailable. | +| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. | +| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. | +| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. | ## Recommended order diff --git a/docs/roadmap.md b/docs/roadmap.md index ec4424a72..daac7d8ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,8 +47,8 @@ GitHub Copilot CLI, Command Code, and VS Code using each client's configuration Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.4 and a 64-bit full JDK 21. macOS also requires the matching native decompiler. The -adapter exposes ten inventory operations and twelve function-analysis -operations, for 22 read-only operations total. Approved setup saves verified +adapter exposes thirteen inventory operations and twelve function-analysis +operations, for 25 read-only operations total. Approved setup saves verified installation paths in agent configurations without installing or changing Ghidra or Java. @@ -57,11 +57,11 @@ a native type-layout object. A separate lane covers AArch64 ELF, PE, and Mach-O cross-target fixtures. See [testing](testing.md) for the prerequisites and exact commands. -Windows Ghidra operations remain unavailable until REA implements verified -Job Object process ownership, private runtime DACLs, and reparse-safe path -admission. Windows package and adapter tests do not establish a usable real -Ghidra session. The [Windows P0 guide](windows-ghidra-p0.md) describes the -intended boundary and remaining requirements. +Experimental Windows x64 P0 now supplies bundled Job Object ownership, +protected runtime DACLs, and handle-based admission for native x86-64 PE +applications on local NTFS. Real ordinary-user CLI/MCP verification covers +all 25 read-only operations and cleanup. The [Windows P0 guide](windows-ghidra-p0.md) +describes this boundary and unverified broader coverage. ## Ghidra maintenance boundary @@ -71,11 +71,10 @@ not expected to match; unresolved targetless flow remains unknown. Automatic Ghidra acquisition, if ever added, remains a separately planned and approved related-tool change; setup must never install Java. -Before enabling Windows Ghidra operations, add current-user-only DACL creation and -readback, handle-based reparse-point-safe path authority, a DACL-protected IPC -backend, and Job Object assignment before provider execution. Process capture, -Hopper, and broad filesystem-sensitive workflows remain separate Windows -projects rather than implied parity. +Maintain Windows P0 with independent DACL readback, handle-based admission, +private authenticated IPC, and Job Object assignment before provider execution. +Process capture, Hopper, and broad filesystem-sensitive workflows remain +separate Windows projects; P0 does not imply their parity. ## Capability-selective setup diff --git a/docs/testing.md b/docs/testing.md index 8db6a4202..1ce040233 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -103,8 +103,15 @@ that a host or target is covered when it was skipped. | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | -| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE | Ghidra 12.1.4, full JDK 21, and the Windows P0 fixture toolchain | +| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact | +Windows native conformance runs with `npm run verify:windows-native` and does +not require Ghidra or Java. An optional independently compiled Windows fixture +adds in-place reparse, breakaway, environment, and token observations. +`npm run verify:ghidra:windows:package` additionally packs and installs REA into +an isolated prefix and runs ordinary-user CLI/MCP operations against their +canonical schemas. Run the controlled fixture generator before that lane, or +supply an installed package root and an explicit fixture as arguments. The host-native Ghidra lane also verifies native value tracing through the production CLI and a separate stdio MCP process. It compares complete dependency graphs, validates Evidence and upstream/workflow profiles, checks capability diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 64ed8f5ae..582d3bebd 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -1,165 +1,165 @@ # Windows Ghidra P0 -Status: experimental and currently unavailable. The production adapter reports -`unsupported_host` because verified Job Object process ownership, private -runtime DACLs, and reparse-safe path admission are not implemented. Installation -paths or manual MCP registration do not enable operations. - -This guide describes the intended P0 boundary and adapter mechanisms. It does -not establish general Windows REA support. Even after the required controls -are implemented, hostile, sensitive, security-decision, and concurrently -mutable targets require separate acceptance. +Status: experimental Windows x64 support for the read-only P0 boundary. The +published package bundles native process, filesystem, and DACL controls. REA +uses them automatically when the analyst selects a Ghidra operation; no +additional permission flag or degraded mode is required. ## Supported boundary -Once the required native controls are implemented, the intended P0 is limited to: - -- a Windows x64 host; +- Windows 10+ x64 with fixed local NTFS target and temporary volumes; - Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+; - an operator-installed official Ghidra 12.1.4 distribution; - a 64-bit full JDK 21; - an explicit native, non-managed, non-DLL x86-64 PE application; and -- the 22 read-only Ghidra inventory and function-analysis operations. +- the 25 read-only Ghidra inventory, memory, and function-analysis operations. -Hopper, Ghidra GUI state, mutation, process capture, browser/Electron -observation, artifact extraction, and general Windows feature parity are not -implied by this boundary. Managed PE/CLI -inspection remains a separate execution-free provider and is not routed -through Windows Ghidra P0. +Loaded memory reads and file offsets preserve Ghidra's observed source mappings. +PE load-image inspection returns measurements with an explicit unsupported +attestation status; independent format-specific verification currently supports +DOS MZ only. -REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or -another package on Windows. The production adapter uses the packaged Java -`HeadlessScript`; Python and PyGhidra are not prerequisites. +This boundary does not establish general Windows feature parity. Ghidra GUI +state, annotations, other target architectures and formats, and Hopper-only +operations remain unavailable through this provider. Managed PE/CLI inspection +uses its separate execution-free provider. + +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. ## Configuration -For installation diagnostics, configure REA, Ghidra, and JDK 21 in PowerShell. Analysis remains blocked: - ```powershell npm install --global rea-agents $env:GHIDRA_INSTALL_DIR = "C:\tools\ghidra_12.1.4_PUBLIC" -$env:JAVA_HOME = "C:\Program Files\Java\jdk-21" +$env:JAVA_HOME = "C:\tools\jdk-21" $env:REA_ANALYSIS_PROVIDER = "ghidra" rea doctor --json rea providers --json -rea inspect "C:\approved-fixtures\sample.exe" --provider ghidra --format json +rea inspect "C:\fixtures\sample.exe" --provider ghidra --format json ``` -`rea doctor` checks the x64 host, exact Ghidra release, -`support\analyzeHeadless.bat`, `java.exe`, `javac.exe`, JDK bitness, and JDK -major version. `rea setup` can configure supported agent integrations and the -bundled REA skill on Windows after approval. It does not install Hopper, Ghidra, -or Java, and configuring an agent does not enable the blocked Ghidra operations. +`rea doctor` distinguishes installation prerequisites from native artifact or +OS failures. It checks the Ghidra release, headless launcher, full JDK, x64 +host, and native controls. A missing or mismatched addon requires the matching +Windows x64 package; changing MCP registration cannot supply that artifact. +A filesystem or access failure identifies the failed native constraint. +Other doctor checks, including unconfigured agent integrations, can remain +unready while Ghidra itself is available. -For an MCP client, register the resolved Node entry point and preserve the -three environment variables above. A representative configuration is: +`rea setup` can register supported agent integrations and the bundled skill. +Its existing preview and approval apply to configuration writes. Setup +preserves valid Ghidra and Java settings even when a separate runtime +prerequisite is unavailable. + +For MCP, register the resolved Node entry point and the same provider settings: ```json { "mcpServers": { - "rea-windows-ghidra-p0": { - "command": "C:\\Program Files\\nodejs\\node.exe", - "args": [ - "C:\\Users\\analyst\\AppData\\Roaming\\npm\\node_modules\\rea-agents\\scripts\\rea.mjs", - "mcp" - ], + "rea": { + "command": "C:\\tools\\node\\node.exe", + "args": ["C:\\tools\\rea\\scripts\\rea.mjs", "mcp"], "env": { "REA_ANALYSIS_PROVIDER": "ghidra", "GHIDRA_INSTALL_DIR": "C:\\tools\\ghidra_12.1.4_PUBLIC", - "JAVA_HOME": "C:\\Program Files\\Java\\jdk-21" + "JAVA_HOME": "C:\\tools\\jdk-21" } } } } ``` -Resolve the actual global package path on the intended machine; do not copy the -example path blindly. Restart the MCP client after changing its registration. +These paths are examples. Use the actual package location and restart the MCP +client after registration changes. Cold Ghidra import can exceed a client's +short default request timeout; the real verification lane allows the existing +330-second provider startup deadline to complete. -## Target and import identity +## Native authority and target identity -PE parsing records architecture, COFF executable role, and whether the CLI -header data-directory entry is non-empty. Header classification and SHA-256 -are read from the same open file handle. Windows admission then rejects: +The verified boundary excludes SUBST aliases and changing drive-letter/DOS-device +namespaces. REA does not claim a separate SUBST detector. Mounted-folder entries +that expose reparse tags fail component admission; broader namespace variants +have not been established by this lane. Requested paths, final handle paths, +volume serials, and file IDs remain separate observations. -- non-PE formats; -- x86, ARM, ARM64, or unknown architectures; -- DLL/shared-library and non-executable image roles; -- managed or incompletely classified PE targets; and -- malformed optional headers or data-directory declarations. +The loader validates package version, ABI, Node-API compatibility, PE machine, +and artifact SHA-256 before loading the package-owned addon. Native failure +reasons remain distinct from a valid installation and an unsupported target. -After admission, REA copies the target into the ephemeral Ghidra runtime and -requires the snapshot digest to match the admitted SHA-256. Ghidra imports only -that snapshot. Before serving a request, the Java bridge requires Ghidra's -`Program.getExecutableSHA256()` observation to match the same digest. A path -replacement that changes bytes therefore fails before Evidence is created. +PE header classification and SHA-256 come from the same open file. The Windows +provider admits native x86-64 PE applications and rejects unsupported roles, +architectures, managed images, and malformed headers. The original selected +source coordinate is preserved for native admission. -This linkage does not establish Windows reparse-point or hostile-path safety. -The current implementation has no handle-based `CreateFileW` authority backend -and no verified no-follow equivalent for every path component. +Admission opens and checks every path component without following a reparse +point. It retains handles, verifies local NTFS semantics, and observes volume +serial, 128-bit file ID, final handle path, requested path, and size. Reparse +paths, alternate streams, ambiguous coordinates, UNC paths, and other +filesystems are rejected with the actual constraint. -## Local transport and lifecycle +The snapshot streams from the admitted source handle on a background worker. +Its digest must match the parsed target digest. Ghidra imports that private, +immutable snapshot, and the bridge requires the imported program SHA-256 to +match before serving operations. Source and snapshot identities remain +separate diagnostic observations. Snapshot cancellation is checked between +64 KiB reads and cleanup waits for worker settlement. -Linux continues to use a mode-restricted Unix socket. Windows uses a Java -listener bound to `127.0.0.1` on an ephemeral port. The bridge atomically -publishes a bounded endpoint record containing only the literal loopback host -and port. The random 256-bit request token remains only in the -ephemeral session descriptor and every request is authenticated. +## Private runtime, transport, and lifecycle -`analyzeHeadless.bat` is invoked through an absolute `cmd.exe` with a fixed -argument sequence, command extensions enabled for the Ghidra script, delayed -expansion disabled, and Node shell execution disabled. Every batch token is -quoted. Paths containing command-interpreter metacharacters are rejected -instead of escaped heuristically. +Runtime creation installs a protected DACL permitting exactly the current user +and SYSTEM. Readback checks the actual owner and ACEs by handle. Descriptors +and snapshots remain locked until owner cleanup. Child files inherit the +private boundary. Analysis runs with the caller's authority; these controls +are not a sandbox for the Ghidra engine or other processes using the same user. -Close, cancellation, timeout, protocol failure, or provider exit removes the -project and runtime root. Windows P0 uses bounded `taskkill /T /F` process-tree -termination. It does not assign Ghidra to a Job Object and cannot make the same -PID-reuse and descendant-ownership claim as the POSIX process-group backend. +Windows uses an authenticated listener bound to `127.0.0.1` on an ephemeral +port. The endpoint record contains only the literal loopback host and port. +The bridge writes a new record and closes its writer before the native reader +can admit it. The reader refuses concurrent writers and verifies its opened +object's private DACL. The random 256-bit bearer token stays in the private, +immutable session descriptor and is redacted from diagnostic output. -## Known security gates +The batch launcher uses an absolute `cmd.exe`, fixed switches, delayed +expansion disabled, and quoted tokens. Command-interpreter metacharacters in +launch paths are unsupported. JVM home and temporary properties use quoted +`JDK_JAVA_OPTIONS`, preserving spaces. `APPDATA` and `LOCALAPPDATA` point into +the private runtime, isolating Ghidra preferences, logs, and caches. -The following remain explicitly unavailable: - -- verified current-user-only DACLs for runtime directories, descriptors, logs, - snapshots, and endpoint records; -- a DACL-protected Windows named pipe or equivalent OS-identity-restricted IPC; -- reparse-point-safe, handle-based input and output authority; -- Job Object assignment before child execution and kill-on-close proof; -- general extraction containment and filesystem-sensitive workflows; and -- independent security acceptance for hostile or mutable targets. - -Successful symlink creation or ConPTY availability does not satisfy any of -these gates. Inspect the machine-readable report with: - -```powershell -npm run probe:windows-capabilities -``` +Process creation assigns a suspended child atomically to an owned Job Object. +Membership and kill-on-owner-close policy are verified before resume. +Breakaway is disabled. Only the child's default file owner is normalized; +the caller token stays unchanged. Caller CPU affinity and priority are +preserved. Close, cancellation, timeout, startup failure, and provider failure +terminate the job and verify all members have settled before runtime cleanup. +Cleanup removes admitted objects by handle and unlinks reparse entries without +following their targets. Abrupt owner death kills the job but can leave +private runtime files; crash-time transactional deletion is not claimed. ## Verification -Hosted CI builds, type-checks, runs the curated Windows tests, records the named -host capability report, packs the real npm artifact, installs it in isolation, -and compares the packaged MCP catalog with `TOOL_CONTRACTS`. - -The controlled real-engine lane requires the missing native authority and currently remains blocked: +See [native controls](../native/windows/README.md) for the separate artifact +build lane and native boundary checks. Development tarballs without the built +artifact report native controls unavailable; publishing requires the verified +artifact and manifest. ```powershell -$env:GHIDRA_INSTALL_DIR = "C:\tools\ghidra_12.1.4_PUBLIC" -$env:JAVA_HOME = "C:\Program Files\Java\jdk-21" -npm ci +npm run verify:windows-native npm run verify:ghidra:windows +npm run verify:ghidra:windows:package -- C:\fixtures\installed-rea C:\fixtures\sample.exe ``` -The verifier requires the native authority before opening Ghidra. Its intended -checks include a deterministic x86-64 PE fixture, the read-only operation -contract, target/snapshot/import digest linkage, and endpoint, project, -process, and runtime cleanup. The GitHub workflow -accepts only the fixed `real-ghidra-windows` repository-dispatch event against -default-branch `main`, a protected `real-ghidra-windows` environment, and a -self-hosted runner labelled `Windows`, `x64`, and `ghidra-12-1-2`. +The package lane must run with an ordinary, non-elevated user token. It exercises +CLI inspection and every admitted MCP operation, validates named canonical +input/output contracts, checks the source digest stays unchanged, and verifies +runtime cleanup. Use a source-owned fixture, one analysis process at a time, +and a bounded heap for real-provider verification. -Build, package, or adapter-test success does not prove the P0 operation claim. -Real-engine acceptance requires the native controls and the separate -verification lane; broader target and IPC security requirements remain distinct. +The controlled GitHub workflow runs only default-branch code on its protected +self-hosted Windows environment. A separate Linux job builds the matching +native artifact. Hosted Windows CI verifies native controls and packaged +startup separately from real Ghidra acceptance. Broader hostile-target, +concurrently mutable-path, extraction, and IPC-security claims require their +own acceptance and are not implied by this P0 result. diff --git a/native/windows/README.md b/native/windows/README.md new file mode 100644 index 000000000..f98d974ca --- /dev/null +++ b/native/windows/README.md @@ -0,0 +1,102 @@ +# Windows native controls + +REA ships one Windows x64 Node-API 8 addon. Users do not compile it or install +another runtime. Ghidra and a full JDK remain bring-your-own prerequisites. +The addon uses Windows 10+ APIs and admits fixed local NTFS volumes only. + +The package owns the artifact path. The loader checks the package version, +ABI, Node-API compatibility, PE architecture, and SHA-256 before loading it. +There is no environment override for loading an arbitrary native module. + +## Build + +The artifact lane requires Linux, MinGW-w64 `x86_64-w64-mingw32-g++`, +`x86_64-w64-mingw32-dlltool`, and `tar`. It never installs these commands. +Node-API headers are reused from the build cache or downloaded from the +pinned official Node release with its published SHA-256 checked. + +```sh +npm run build:windows-native +npm run verify:windows-native:artifact +npm pack +``` + +`build/` is ignored by Git. The npm file inventory includes only the addon and +its manifest. Release CI builds both, and `prepublishOnly` rejects a missing, +stale, mismatched, or wrong-architecture artifact. A development tarball built +without this lane reports Windows native controls as unavailable. + +## Boundaries + +- Opaque Node objects carry a native type tag, resource kind, and closed state. + A pathname, serialized object, or forged handle cannot grant authority. +- Input admission opens every component with `OPEN_REPARSE_POINT`, verifies + the actual NTFS object, and retains handles that deny replacement and source + writing. An admitted child keeps each ancestor nonempty; ancestor handles + permit unrelated writes while NTFS rejects in-place reparse conversion. Volume serial, 128-bit file ID, final handle path, + requested path, size, and digest observations are preserved separately. +- New runtime roots and private child objects are created relative to admitted + parent handles with `NtCreateFile`. A protected DACL and current-user owner + are installed at creation. Readback permits exactly the current user and SYSTEM. Child + files inherit this boundary; private files are also checked by handle. +- Snapshots stream through a 64 KiB buffer on a Node async worker. Cancellation + is checked between reads, cleanup waits for settlement, and the digest + commits to the bytes written. Descriptor and snapshot handles remain held + until runtime cleanup. + Snapshot ownership is single-flight per runtime: an overlapping request fails + with `ERROR_BUSY` before acquiring cancellation ownership. The TypeScript owner + records only the accepted promise and abort listener; aborting a rejected copy + must not cancel the active copy. +- `PROC_THREAD_ATTRIBUTE_JOB_LIST` assigns a suspended child atomically to an + unnamed, non-inherited Job Object. Membership and kill-on-close policy are + checked before resume. Breakaway is disabled, and cleanup waits for all + job members and captured output, including detached descendants. +- Only the suspended child's default file owner is normalized. Caller token + privileges and ownership remain unchanged. Caller CPU affinity and process + priority are preserved in the child. Environment names are sorted and + deduplicated using Windows ordinal comparison, preserving their native Unicode + semantics and caller-selected values. +- Cleanup walks locked directory objects, removes files by handle, and unlinks + reparse entries without traversing their targets. Normal close, cancellation, + startup failure, and timeout release provider resources. Abrupt owner death + kills the owned job; it can leave private runtime files for later operator + cleanup and does not claim transactional deletion after a crash. + +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. + +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 +of private files after a crash. SUBST drive aliases and changing DOS-device +namespaces are outside the verified P0 scope and are not detected as a separate +alias policy. Mounted-folder paths that report a reparse tag are rejected by +component admission; broader namespace variants remain unverified. Preserve +requested and final handle coordinates rather than treating them as one identity. + +## Real verification + +```powershell +npm run verify:windows-native +npm run verify:ghidra:windows +``` + +The native lane checks DACL readback independently, source write/replacement +rejection, unrelated sibling renames, reparse rejection, immutable snapshots, +asynchronous cancellation, cleanup containment, +descendant termination, unrelated-process preservation, normal exit, owner +close, and forced owner exit. An optional independently built +`tests/fixtures/windows/processBoundary.cc` executable adds breakaway and +caller/child token observations and an in-place ancestor reparse attempt: + +```powershell +node scripts/verify-windows-native.mjs . C:\fixtures\process-boundary.exe +``` + +`nonAdminRunner.cc` is a test-only launcher for an elevated development shell. +It duplicates an existing non-elevated Explorer token belonging to the same +user, creates private temporary window-station objects, and launches the test +inside a kill-on-close fixture job. It does not create accounts, modify host +policy, or change the caller's token. A real-provider claim must additionally +verify the packaged CLI and MCP on that ordinary-user token. diff --git a/native/windows/src/addon.cc b/native/windows/src/addon.cc new file mode 100644 index 000000000..b401c2d58 --- /dev/null +++ b/native/windows/src/addon.cc @@ -0,0 +1,123 @@ +#include "authority.hpp" + +namespace rea { + +static const napi_type_tag RESOURCE_TAG = {0x45fcbf0b49e84fe3ULL, 0x9e3e5074130de843ULL}; + +void check(napi_status status) { + if (status != napi_ok) throw Failure("Invalid native boundary value", L"", ERROR_INVALID_PARAMETER); +} +napi_value object(napi_env env) { napi_value value; check(napi_create_object(env, &value)); return value; } +napi_value null(napi_env env) { napi_value value; check(napi_get_null(env, &value)); return value; } +napi_value string(napi_env env, const std::string& text) { + napi_value value; check(napi_create_string_utf8(env, text.data(), text.size(), &value)); return value; +} +napi_value string(napi_env env, const std::wstring& text) { + static_assert(sizeof(wchar_t) == sizeof(char16_t)); + napi_value value; + check(napi_create_string_utf16(env, reinterpret_cast(text.data()), text.size(), &value)); + return value; +} +napi_value number(napi_env env, double numeric) { + napi_value value; check(napi_create_double(env, numeric, &value)); return value; +} +napi_value boolean(napi_env env, bool flag) { + napi_value value; check(napi_get_boolean(env, flag, &value)); return value; +} +void set(napi_env env, napi_value target, const char* key, napi_value value) { + check(napi_set_named_property(env, target, key, value)); +} +std::wstring wide(napi_env env, napi_value value) { + size_t size = 0; + check(napi_get_value_string_utf16(env, value, nullptr, 0, &size)); + require(size <= 32767, "Windows value exceeds the native string limit", L"", ERROR_INVALID_PARAMETER); + std::vector buffer(size + 1); + check(napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &size)); + std::wstring result(reinterpret_cast(buffer.data()), size); + require(result.find(L'\0') == std::wstring::npos, "Windows value contains NUL", L"", ERROR_INVALID_PARAMETER); + return result; +} +double numeric(napi_env env, napi_value value) { + double result; check(napi_get_value_double(env, value, &result)); return result; +} +std::vector array(napi_env env, napi_value value) { + bool valid; check(napi_is_array(env, value, &valid)); + require(valid, "Native arguments must be an array", L"", ERROR_INVALID_PARAMETER); + uint32_t length; check(napi_get_array_length(env, value, &length)); + std::vector result(length); + for (uint32_t index = 0; index < length; ++index) + check(napi_get_element(env, value, index, &result[index])); + return result; +} +static void finalize(napi_env, void* data, void*) { delete static_cast(data); } +napi_value wrap(napi_env env, std::unique_ptr value) { + auto result = object(env); + check(napi_type_tag_object(env, result, &RESOURCE_TAG)); + check(napi_wrap(env, result, value.get(), finalize, nullptr, nullptr)); + value.release(); + return result; +} +Resource& resource(napi_env env, napi_value value, Kind kind) { + bool tagged = false; + check(napi_check_object_type_tag(env, value, &RESOURCE_TAG, &tagged)); + require(tagged, "Native handle does not belong to REA", L"", ERROR_INVALID_HANDLE); + void* pointer = nullptr; check(napi_unwrap(env, value, &pointer)); + auto& result = *static_cast(pointer); + require(result.kind == kind && !result.closed, "Native handle is closed or has the wrong kind", L"", ERROR_INVALID_HANDLE); + return result; +} + +napi_value failureValue(napi_env env, const Failure& failure) { + wchar_t* message = nullptr; + FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, failure.win32, 0, reinterpret_cast(&message), 0, nullptr); + std::wstring detail = message == nullptr ? L"" : message; + if (message != nullptr) LocalFree(message); + const std::wstring constraint(failure.constraint.begin(), failure.constraint.end()); + auto text = string(env, constraint + (failure.path.empty() ? L"" : L" [" + failure.path + L"]") + + L" (Win32 " + std::to_wstring(failure.win32) + L"): " + detail); + napi_value error; napi_create_error(env, nullptr, text, &error); + const char* code = failure.win32 == ERROR_FILE_NOT_FOUND || failure.win32 == ERROR_PATH_NOT_FOUND ? "ENOENT" + : failure.win32 == ERROR_ACCESS_DENIED ? "EACCES" : "ERR_REA_WINDOWS_NATIVE"; + set(env, error, "code", string(env, std::string(code))); + set(env, error, "constraint", string(env, failure.constraint)); + set(env, error, "requestedPath", string(env, failure.path)); + set(env, error, "win32Code", number(env, failure.win32)); + set(env, error, "win32Message", string(env, detail)); + return error; +} + +static napi_value call(napi_env env, napi_callback_info info) { + try { + std::array args; + size_t count = args.size(); + check(napi_get_cb_info(env, info, &count, args.data(), nullptr, nullptr)); + require(count == 2, "Native call requires operation and arguments", L"", ERROR_INVALID_PARAMETER); + const auto operation = wide(env, args[0]); + const auto values = array(env, args[1]); + if (operation == L"inspect") { + require(values.empty(), "Inspect takes no arguments", L"", ERROR_INVALID_PARAMETER); + return inspect(env); + } + if (operation.rfind(L"process_", 0) == 0) return processCall(env, operation, values); + return filesystemCall(env, operation, values); + } catch (const Failure& failure) { + auto error = failureValue(env, failure); + napi_throw(env, error); + return nullptr; + } catch (const std::exception& failure) { + napi_throw_error(env, "ERR_REA_WINDOWS_NATIVE", failure.what()); + return nullptr; + } +} + +static napi_value initialize(napi_env env, napi_value exports) { + napi_value function; + if (napi_create_function(env, "call", NAPI_AUTO_LENGTH, call, nullptr, &function) != napi_ok) return nullptr; + if (napi_set_named_property(env, exports, "call", function) != napi_ok) return nullptr; + return exports; +} + +} // namespace rea + +NAPI_MODULE(NODE_GYP_MODULE_NAME, rea::initialize) diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp new file mode 100644 index 000000000..5698b652c --- /dev/null +++ b/native/windows/src/authority.hpp @@ -0,0 +1,120 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rea { + +struct Failure : std::runtime_error { + DWORD win32; + std::wstring path; + std::string constraint; + Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError()) + : std::runtime_error(constraint), win32(code), path(std::move(path)), + constraint(std::move(constraint)) {} +}; + +inline void require(bool condition, const char* constraint, const std::wstring& path = L"") { + if (!condition) { + const auto code = GetLastError(); + throw Failure(constraint, path, code); + } +} +inline void require(bool condition, const char* constraint, const std::wstring& path, DWORD code) { + if (!condition) throw Failure(constraint, path, code); +} + +class Handle { + HANDLE value_ = INVALID_HANDLE_VALUE; +public: + Handle() = default; + explicit Handle(HANDLE value) : value_(value) {} + ~Handle() { reset(); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + Handle(Handle&& other) noexcept : value_(other.release()) {} + Handle& operator=(Handle&& other) noexcept { reset(other.release()); return *this; } + HANDLE get() const { return value_; } + bool valid() const { return value_ != INVALID_HANDLE_VALUE && value_ != nullptr; } + HANDLE release() { return std::exchange(value_, INVALID_HANDLE_VALUE); } + void reset(HANDLE next = INVALID_HANDLE_VALUE) { + if (valid()) CloseHandle(value_); + value_ = next; + } +}; + +enum class Kind { File, Runtime, Process }; +struct Resource { + const Kind kind; + bool closed = false; + explicit Resource(Kind value) : kind(value) {} + virtual ~Resource() = default; +}; +struct File : Resource { + std::wstring requestedPath; + std::wstring path; + std::vector handles; + explicit File(std::wstring path) : Resource(Kind::File), requestedPath(path), path(std::move(path)) {} + HANDLE get() const { return handles.back().get(); } +}; +struct Runtime : Resource { + std::wstring path; + std::vector parents; + Handle directory; + std::vector immutableFiles; + // Main-thread admission is single-flight; only the accepted copy owns the + // atomic cancellation flag consumed by its async worker. ERROR_BUSY rejects + // another copy before it can reset that flag or replace pending ownership. + bool snapshotPending = false; + std::atomic_bool snapshotCancelled{false}; + Runtime() : Resource(Kind::Runtime) {} +}; +struct Process : Resource { + Handle job, process, stdoutRead, stderrRead; + DWORD pid = 0; + Process() : Resource(Kind::Process) {} +}; + +void check(napi_status status); +napi_value failureValue(napi_env env, const Failure& failure); +napi_value object(napi_env env); +napi_value string(napi_env env, const std::string& value); +napi_value string(napi_env env, const std::wstring& value); +napi_value number(napi_env env, double value); +napi_value boolean(napi_env env, bool value); +napi_value null(napi_env env); +void set(napi_env env, napi_value object, const char* key, napi_value value); +std::wstring wide(napi_env env, napi_value value); +double numeric(napi_env env, napi_value value); +std::vector array(napi_env env, napi_value value); +napi_value wrap(napi_env env, std::unique_ptr resource); +Resource& resource(napi_env env, napi_value value, Kind kind); + +std::unique_ptr openFile(const std::wstring& path, DWORD access = GENERIC_READ, + bool directory = false); +napi_value identity(napi_env env, HANDLE handle, const std::wstring& path); +std::unique_ptr createRuntime(const std::wstring& parent, const std::wstring& prefix); +void closeRuntime(Runtime& runtime); +napi_value filesystemCall(napi_env env, const std::wstring& operation, + const std::vector& args); +std::unique_ptr spawnProcess(const std::wstring& command, const std::wstring& commandLine, + const std::wstring& cwd, const std::vector& environment); +napi_value processCall(napi_env env, const std::wstring& operation, + const std::vector& args); +napi_value inspect(napi_env env); + +} // namespace rea diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc new file mode 100644 index 000000000..c1a2288de --- /dev/null +++ b/native/windows/src/filesystem.cc @@ -0,0 +1,531 @@ +#include "authority.hpp" +#include +#include + +namespace rea { + +static std::wstring hex(const unsigned char* bytes, size_t count) { + constexpr wchar_t digits[] = L"0123456789abcdef"; + std::wstring result; + for (size_t index = 0; index < count; ++index) { + result += digits[bytes[index] >> 4]; result += digits[bytes[index] & 15]; + } + return result; +} + +static std::wstring localPath(std::wstring 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')), + "Only absolute local NTFS drive paths are supported", path, ERROR_NOT_SUPPORTED); + require(path.find(L'/', 0) == std::wstring::npos && path.find(L':', 2) == std::wstring::npos, + "Alternate streams and non-local path syntax are unsupported", path, ERROR_NOT_SUPPORTED); + require(GetDriveTypeW(path.substr(0, 3).c_str()) == DRIVE_FIXED, + "Only fixed local NTFS volumes are supported", path, ERROR_NOT_SUPPORTED); + if (path.size() > 3 && path.back() == L'\\') path.pop_back(); + require(path.size() == 3 || path.back() != L'\\', "Repeated path separators are unsupported", path, ERROR_NOT_SUPPORTED); + size_t begin = 3; + while (begin < path.size()) { + const auto end = path.find(L'\\', begin); + const auto component = path.substr(begin, end == std::wstring::npos ? end : end - begin); + require(!component.empty() && component != L"." && component != L".." && + component.back() != L'.' && component.back() != L' ', + "Ambiguous Windows path component is unsupported", path, ERROR_NOT_SUPPORTED); + if (end == std::wstring::npos) break; + begin = end + 1; + } + return path; +} + +static Handle openComponent(const std::wstring& path, DWORD access, bool directory, DWORD sharing = FILE_SHARE_READ) { + // Attribute-only opens do not participate in Windows share-access checks. + // Directory guards need FILE_LIST_DIRECTORY to prevent replacement and + // in-place reparse conversion while descendant paths are being consumed. + Handle handle(CreateFileW((L"\\\\?\\" + path).c_str(), access | FILE_READ_ATTRIBUTES | READ_CONTROL | + (directory ? FILE_LIST_DIRECTORY : 0), + sharing, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr)); + require(handle.valid(), "Open Windows path component failed", path); + FILE_ATTRIBUTE_TAG_INFO info{}; + require(GetFileInformationByHandleEx(handle.get(), FileAttributeTagInfo, &info, sizeof(info)), + "Read Windows path attributes failed", path); + if ((info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) + throw Failure("Reparse path component is unsupported (tag " + std::to_string(info.ReparseTag) + ")", + path, ERROR_NOT_SUPPORTED); + require(((info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) == directory, + directory ? "Expected a directory path component" : "Target is not a regular file", path, + directory ? ERROR_PATH_NOT_FOUND : ERROR_DIRECTORY); + require(GetFileType(handle.get()) == FILE_TYPE_DISK, "Windows object is not a disk file", path, ERROR_NOT_SUPPORTED); + return handle; +} + +std::unique_ptr openFile(const std::wstring& requested, DWORD access, bool directory) { + auto result = std::make_unique(localPath(requested)); + result->requestedPath = requested; + const auto& path = result->path; + require(directory || path.size() > 3, "Target is not a regular file", requested, ERROR_DIRECTORY); + result->handles.push_back(openComponent(path.substr(0, 3), 0, true)); + wchar_t filesystem[64]{}; + DWORD flags = 0; + require(GetVolumeInformationByHandleW(result->handles.front().get(), nullptr, 0, nullptr, nullptr, + &flags, filesystem, 64), "Read volume semantics failed", path); + require(std::wstring(filesystem) == L"NTFS" && (flags & FILE_PERSISTENT_ACLS) != 0, + "Backing volume must be NTFS with persistent ACLs", path, ERROR_NOT_SUPPORTED); + size_t offset = 3; + while (offset < path.size()) { + auto end = path.find(L'\\', offset); + const bool final = end == std::wstring::npos; + auto child = openComponent(path.substr(0, end), final ? access : 0, !final || directory); + // Once the next component is pinned against deletion, its parent cannot + // become empty and NTFS cannot convert that parent into a reparse point. + // Relax only ancestor write sharing so unrelated child renames can proceed. + // Independent evidence: tests/fixtures/windows/processBoundary.cc attempts + // FSCTL_SET_REPARSE_POINT on the nonempty pinned ancestor and observes denial. + // SUBST aliases and mounted-folder/reparse namespace variants are outside + // the admitted P0 evidence; requested and final handle paths remain distinct. + const auto parentPath = offset == 3 ? path.substr(0, 3) : path.substr(0, offset - 1); + result->handles.back() = openComponent(parentPath, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE); + result->handles.push_back(std::move(child)); + if (final) break; + offset = end + 1; + } + return result; +} + +napi_value identity(napi_env env, HANDLE handle, const std::wstring& path) { + FILE_ID_INFO info{}; + require(GetFileInformationByHandleEx(handle, FileIdInfo, &info, sizeof(info)), + "Stable 128-bit Windows file identity is unavailable", path); + std::array finalPath; + const auto length = GetFinalPathNameByHandleW(handle, finalPath.data(), finalPath.size(), FILE_NAME_NORMALIZED); + require(length > 0 && length < finalPath.size(), "Read final handle path failed", path); + LARGE_INTEGER size{}; + FILE_STANDARD_INFO standard{}; + require(GetFileInformationByHandleEx(handle, FileStandardInfo, &standard, sizeof(standard)), + "Read handle metadata failed", path); + size = standard.EndOfFile; + require(size.QuadPart >= 0 && size.QuadPart <= 9007199254740991LL, "File size exceeds exact numeric representation", path, + ERROR_NOT_SUPPORTED); + auto result = object(env); + set(env, result, "requestedPath", string(env, path)); + set(env, result, "finalPath", string(env, std::wstring(finalPath.data(), length))); + set(env, result, "filesystem", string(env, std::string("NTFS"))); + std::array serial; + for (size_t index = 0; index < serial.size(); ++index) + serial[index] = static_cast(info.VolumeSerialNumber >> ((7 - index) * 8)); + set(env, result, "volumeSerial", string(env, hex(serial.data(), serial.size()))); + set(env, result, "fileId", string(env, hex(info.FileId.Identifier, 16))); + set(env, result, "size", number(env, static_cast(size.QuadPart))); + set(env, result, "directory", boolean(env, standard.Directory != FALSE)); + return result; +} + +struct Security { + std::vector user; + std::array system{}; + std::vector acl; + SECURITY_DESCRIPTOR descriptor{}; + PSID userSid() const { return reinterpret_cast(user.data())->User.Sid; } + Security() { + Handle token; + HANDLE raw; + require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw), "Read current user token failed"); + token.reset(raw); + DWORD size = 0; + GetTokenInformation(token.get(), TokenUser, nullptr, 0, &size); + user.resize(size); + require(GetTokenInformation(token.get(), TokenUser, user.data(), size, &size), "Read current user SID failed"); + DWORD systemSize = system.size(); + require(CreateWellKnownSid(WinLocalSystemSid, nullptr, system.data(), &systemSize), "Create SYSTEM SID failed"); + acl.resize(sizeof(ACL) + 2 * (sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)) + GetLengthSid(userSid()) + systemSize); + auto access = reinterpret_cast(acl.data()); + require(InitializeAcl(access, acl.size(), ACL_REVISION), "Initialize private DACL failed"); + for (PSID sid : {userSid(), static_cast(system.data())}) + require(AddAccessAllowedAceEx(access, ACL_REVISION, OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + FILE_ALL_ACCESS, sid), "Populate private DACL failed"); + require(InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorOwner(&descriptor, userSid(), FALSE) && + SetSecurityDescriptorDacl(&descriptor, TRUE, access, FALSE) && + SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED), + "Create protected security descriptor failed"); + } +}; + +static Handle createRelative(HANDLE parent, const std::wstring& name, DWORD access, bool directory, + Security& security, const std::wstring& path) { + require(!name.empty() && name.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos && name.size() < 32768, + "Private object requires one filename component", path, ERROR_INVALID_PARAMETER); + UNICODE_STRING coordinate{}; + coordinate.Buffer = const_cast(name.data()); + coordinate.Length = static_cast(name.size() * sizeof(wchar_t)); + coordinate.MaximumLength = coordinate.Length; + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &coordinate, OBJ_CASE_INSENSITIVE, parent, &security.descriptor); + IO_STATUS_BLOCK observation{}; + HANDLE raw = INVALID_HANDLE_VALUE; + const auto status = NtCreateFile(&raw, access | SYNCHRONIZE, &attributes, &observation, nullptr, + FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_CREATE, + FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE), nullptr, 0); + if (status < 0) throw Failure("Create private object relative to its admitted parent failed", path, + RtlNtStatusToDosError(status)); + Handle result(raw); + require(result.valid() && observation.Information == FILE_CREATED, + "Private creation did not return a new object handle", path, ERROR_ACCESS_DENIED); + return result; +} + +static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) { + Security expected; + PSID owner = nullptr; + PACL acl = nullptr; + PSECURITY_DESCRIPTOR raw = nullptr; + const DWORD code = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &acl, nullptr, &raw); + if (code != ERROR_SUCCESS) throw Failure("Read private DACL by handle failed", path, code); + const auto descriptor = std::unique_ptr(raw, LocalFree); + require(owner != nullptr && EqualSid(owner, expected.userSid()), "Private object owner is not the current user", path, + ERROR_ACCESS_DENIED); + SECURITY_DESCRIPTOR_CONTROL control; + DWORD revision; + require(GetSecurityDescriptorControl(raw, &control, &revision), "Read DACL control flags failed", path); + require(acl != nullptr && (control & SE_DACL_PRESENT) != 0 && (!root || (control & SE_DACL_PROTECTED) != 0), + "Private object requires a present protected DACL", path, ERROR_ACCESS_DENIED); + require(acl->AceCount == 2, "Private DACL contains unexpected trustees", path, ERROR_ACCESS_DENIED); + bool user = false, system = false; + for (DWORD index = 0; index < acl->AceCount; ++index) { + void* rawAce; + require(GetAce(acl, index, &rawAce), "Read private DACL entry failed", path); + const auto ace = static_cast(rawAce); + require(ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && ace->Mask == FILE_ALL_ACCESS && + (ace->Header.AceFlags & INHERIT_ONLY_ACE) == 0 && + (!root || (ace->Header.AceFlags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) == + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)), + "Private DACL contains unsupported rights or ACE type", path, ERROR_ACCESS_DENIED); + PSID sid = const_cast(&ace->SidStart); + if (EqualSid(sid, expected.userSid())) user = true; + else if (EqualSid(sid, expected.system.data())) system = true; + else throw Failure("Private DACL permits an unrelated principal", path, ERROR_ACCESS_DENIED); + } + require(user && system, "Private DACL must allow only current user and SYSTEM", path, ERROR_ACCESS_DENIED); +} + +std::unique_ptr createRuntime(const std::wstring& parentPath, const std::wstring& prefix) { + auto parent = openFile(parentPath, 0, true); + require(!prefix.empty() && prefix.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos, + "Runtime prefix must be one filename component", parentPath, ERROR_INVALID_PARAMETER); + std::array random; + require(BCryptGenRandom(nullptr, random.data(), random.size(), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0, + "Generate runtime identity failed", parentPath, ERROR_GEN_FAILURE); + auto result = std::make_unique(); + result->path = parent->path + L"\\" + prefix + hex(random.data(), random.size()); + Security security; + result->directory = createRelative(parent->get(), prefix + hex(random.data(), random.size()), + DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL, + true, security, result->path); + try { + verifyPrivate(result->directory.get(), result->path, true); + // The owned child pins the parent nonempty before write sharing is relaxed. + parent->handles.back() = openComponent(parent->path, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE); + result->parents = std::move(parent->handles); + } catch (...) { + FILE_DISPOSITION_INFO deletion{TRUE}; + SetFileInformationByHandle(result->directory.get(), FileDispositionInfo, &deletion, sizeof(deletion)); + throw; + } + return result; +} + +static std::wstring relativePath(Runtime& root, const std::wstring& relative) { + require(!relative.empty() && relative[0] != L'\\' && relative.find(L':') == std::wstring::npos, + "Runtime coordinate must be a relative path", relative, ERROR_INVALID_PARAMETER); + return localPath(root.path + L"\\" + relative); +} + +static std::unique_ptr runtimeDirectory(Runtime& root, const std::wstring& path) { + auto result = std::make_unique(path); + HANDLE duplicate; + require(DuplicateHandle(GetCurrentProcess(), root.directory.get(), GetCurrentProcess(), &duplicate, 0, FALSE, + DUPLICATE_SAME_ACCESS), "Duplicate private directory handle failed", path); + result->handles.emplace_back(duplicate); + size_t offset = root.path.size() + 1; + while (offset < path.size()) { + const auto end = path.find(L'\\', offset); + result->handles.push_back(openComponent(path.substr(0, end), 0, true)); + if (end == std::wstring::npos) break; + offset = end + 1; + } + return result; +} + +static void directory(Runtime& root, const std::wstring& relative) { + const auto path = relativePath(root, relative); + const auto parentPath = path.substr(0, path.find_last_of(L'\\')); + auto parent = runtimeDirectory(root, parentPath); + verifyPrivate(parent->get(), parentPath, parentPath == root.path); + Security security; + try { + auto created = createRelative(parent->get(), path.substr(path.find_last_of(L'\\') + 1), + FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL, + true, security, path); + verifyPrivate(created.get(), path, false); + } catch (const Failure& failure) { + if (failure.win32 != ERROR_ALREADY_EXISTS && failure.win32 != ERROR_FILE_EXISTS) throw; + auto existing = runtimeDirectory(root, path); + verifyPrivate(existing->get(), path, false); + } +} + +static Handle createPrivateFile(Runtime& root, const std::wstring& relative) { + const auto path = relativePath(root, relative); + const auto parentPath = path.substr(0, path.find_last_of(L'\\')); + auto parent = runtimeDirectory(root, parentPath); + verifyPrivate(parent->get(), parentPath, parentPath == root.path); + Security security; + auto file = createRelative(parent->get(), path.substr(path.find_last_of(L'\\') + 1), + GENERIC_WRITE | GENERIC_READ | READ_CONTROL, false, security, path); + verifyPrivate(file.get(), path, false); + return file; +} + +static void writeBytes(HANDLE file, const void* bytes, DWORD count, const std::wstring& path) { + DWORD written; + require(WriteFile(file, bytes, count, &written, nullptr) && written == count, "Write private file failed", path); +} + +static void dispose(HANDLE handle, const std::wstring& path) { + FILE_BASIC_INFO info{}; + require(GetFileInformationByHandleEx(handle, FileBasicInfo, &info, sizeof(info)), "Read cleanup attributes failed", path); + if ((info.FileAttributes & FILE_ATTRIBUTE_READONLY) != 0) { + info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY; + require(SetFileInformationByHandle(handle, FileBasicInfo, &info, sizeof(info)), "Clear owned readonly attribute failed", path); + } + FILE_DISPOSITION_INFO deletion{TRUE}; + require(SetFileInformationByHandle(handle, FileDispositionInfo, &deletion, sizeof(deletion)), "Delete owned object by handle failed", path); +} + +void closeRuntime(Runtime& root) { + if (root.closed) return; + // Windows keeps the bearer descriptor and snapshot leases until this owner + // cleanup. POSIX removes its consumed descriptor earlier. Abrupt owner death + // closes process jobs but cannot run this walk, so private files can remain. + root.immutableFiles.clear(); + struct ReleaseRuntime { + Runtime& root; + ~ReleaseRuntime() { root.directory.reset(); root.parents.clear(); root.closed = true; } + } release{root}; + // Each traversed directory stays locked against replacement. Reparse entries + // are unlinked as objects; their targets are never enumerated or deleted. + struct Frame { + Handle directory; + HANDLE search = INVALID_HANDLE_VALUE; + std::wstring path; + WIN32_FIND_DATAW next{}; + bool pending = false; + Frame(Handle handle, std::wstring path) : directory(std::move(handle)), path(std::move(path)) { + search = FindFirstFileExW((L"\\\\?\\" + this->path + L"\\*").c_str(), FindExInfoBasic, &next, + FindExSearchNameMatch, nullptr, 0); + pending = search != INVALID_HANDLE_VALUE; + require(pending || GetLastError() == ERROR_FILE_NOT_FOUND, "Enumerate runtime cleanup failed", this->path); + } + ~Frame() { if (search != INVALID_HANDLE_VALUE) FindClose(search); } + }; + std::vector> frames; + HANDLE duplicate; + require(DuplicateHandle(GetCurrentProcess(), root.directory.get(), GetCurrentProcess(), &duplicate, 0, FALSE, + DUPLICATE_SAME_ACCESS), "Duplicate runtime cleanup handle failed", root.path); + frames.push_back(std::make_unique(Handle(duplicate), root.path)); + while (!frames.empty()) { + auto& frame = *frames.back(); + if (!frame.pending) { + if (frame.search != INVALID_HANDLE_VALUE) { FindClose(frame.search); frame.search = INVALID_HANDLE_VALUE; } + dispose(frame.directory.get(), frame.path); + frames.pop_back(); + continue; + } + const auto name = std::wstring(frame.next.cFileName); + frame.pending = FindNextFileW(frame.search, &frame.next) != FALSE; + require(frame.pending || GetLastError() == ERROR_NO_MORE_FILES, "Continue runtime cleanup enumeration failed", frame.path); + if (name == L"." || name == L"..") continue; + const auto path = frame.path + L"\\" + name; + Handle child(CreateFileW((L"\\\\?\\" + path).c_str(), DELETE | GENERIC_READ | FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr)); + require(child.valid(), "Open owned cleanup object failed", path); + FILE_ATTRIBUTE_TAG_INFO info{}; + require(GetFileInformationByHandleEx(child.get(), FileAttributeTagInfo, &info, sizeof(info)), "Read cleanup object tag failed", path); + if ((info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0) + frames.push_back(std::make_unique(std::move(child), path)); + else dispose(child.get(), path); + } +} + +struct SnapshotWork { + napi_env env; + napi_async_work work = nullptr; + napi_deferred deferred = nullptr; + napi_ref rootReference = nullptr; + Runtime* root; + std::unique_ptr source; + Handle destination; + std::wstring path; + std::wstring digest; + std::unique_ptr failure; + SnapshotWork(napi_env env, Runtime& root) : env(env), root(&root) {} +}; + +static void copySnapshot(napi_env, void* pointer) { + auto& work = *static_cast(pointer); + try { + BCRYPT_ALG_HANDLE algorithm = nullptr; + require(BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0, + "Initialize snapshot SHA-256 failed", work.source->path, ERROR_GEN_FAILURE); + struct Algorithm { BCRYPT_ALG_HANDLE handle; ~Algorithm() { BCryptCloseAlgorithmProvider(handle, 0); } } owner{algorithm}; + BCRYPT_HASH_HANDLE hash = nullptr; + require(BCryptCreateHash(algorithm, &hash, nullptr, 0, nullptr, 0, 0) == 0, + "Create snapshot SHA-256 state failed", work.source->path, ERROR_GEN_FAILURE); + struct Hash { BCRYPT_HASH_HANDLE handle; ~Hash() { BCryptDestroyHash(handle); } } hashOwner{hash}; + std::array buffer; + while (true) { + require(!work.root->snapshotCancelled.load(), "Admitted snapshot was cancelled", work.path, ERROR_OPERATION_ABORTED); + DWORD count; + require(ReadFile(work.source->get(), buffer.data(), buffer.size(), &count, nullptr), + "Read admitted source handle failed", work.source->path); + if (count == 0) break; + writeBytes(work.destination.get(), buffer.data(), count, work.path); + require(BCryptHashData(hash, buffer.data(), count, 0) == 0, + "Hash admitted snapshot bytes failed", work.source->path, ERROR_GEN_FAILURE); + } + std::array digest; + require(BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0, + "Finish snapshot SHA-256 failed", work.source->path, ERROR_GEN_FAILURE); + require(FlushFileBuffers(work.destination.get()), "Flush private snapshot failed", work.path); + work.digest = hex(digest.data(), digest.size()); + } catch (const Failure& failure) { + work.failure = std::make_unique(failure); + } catch (const std::exception& failure) { + work.failure = std::make_unique(failure.what(), work.path, ERROR_GEN_FAILURE); + } +} + +static void completeSnapshot(napi_env env, napi_status status, void* pointer) { + std::unique_ptr work(static_cast(pointer)); + work->root->snapshotPending = false; + try { + if (work->failure) throw *work->failure; + require(status == napi_ok && !work->root->snapshotCancelled.load(), "Native snapshot worker was cancelled", work->path, ERROR_OPERATION_ABORTED); + auto result = object(env); + set(env, result, "sha256", string(env, work->digest)); + set(env, result, "source", identity(env, work->source->get(), work->source->requestedPath)); + set(env, result, "snapshot", identity(env, work->destination.get(), work->path)); + work->root->immutableFiles.push_back(std::move(work->destination)); + check(napi_resolve_deferred(env, work->deferred, result)); + } catch (const Failure& failure) { + napi_reject_deferred(env, work->deferred, failureValue(env, failure)); + } catch (const std::exception& failure) { + napi_reject_deferred(env, work->deferred, + failureValue(env, Failure(failure.what(), work->path, ERROR_GEN_FAILURE))); + } + napi_delete_reference(env, work->rootReference); + napi_delete_async_work(env, work->work); +} + +static napi_value snapshot(napi_env env, napi_value rootValue, Runtime& root, + const std::wstring& sourcePath, const std::wstring& relative) { + auto work = std::make_unique(env, root); + work->source = openFile(sourcePath); + work->path = relativePath(root, relative); + work->destination = createPrivateFile(root, relative); + napi_value promise; + check(napi_create_promise(env, &work->deferred, &promise)); + check(napi_create_reference(env, rootValue, 1, &work->rootReference)); + const auto label = string(env, std::string("REA admitted Windows snapshot")); + root.snapshotCancelled.store(false); + try { + check(napi_create_async_work(env, nullptr, label, copySnapshot, completeSnapshot, work.get(), &work->work)); + check(napi_queue_async_work(env, work->work)); + } catch (...) { + if (work->work) napi_delete_async_work(env, work->work); + napi_delete_reference(env, work->rootReference); + throw; + } + root.snapshotPending = true; + work.release(); + return promise; +} + +napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector& args) { + const auto expected = operation == L"open" || operation == L"close" || operation == L"runtime_close" || operation == L"runtime_snapshot_cancel" ? 1 + : operation == L"runtime_create" || operation == L"runtime_mkdir" || operation == L"runtime_open" ? 2 : 3; + require(args.size() == static_cast(expected), "Wrong native filesystem argument count", L"", ERROR_INVALID_PARAMETER); + if (operation == L"open") { + auto file = openFile(wide(env, args[0])); + auto result = identity(env, file->get(), file->requestedPath); + set(env, result, "handle", wrap(env, std::move(file))); + return result; + } + if (operation == L"close") { + auto& file = static_cast(resource(env, args[0], Kind::File)); + file.handles.clear(); file.closed = true; + return null(env); + } + if (operation == L"read") { + auto& file = static_cast(resource(env, args[0], Kind::File)); + const auto position = numeric(env, args[1]); + const auto length = numeric(env, args[2]); + require(position >= 0 && position <= 9007199254740991.0 && position == static_cast(position) && + length >= 0 && length <= 65536 && length == static_cast(length), + "Read coordinates exceed the native chunk boundary", file.path, ERROR_INVALID_PARAMETER); + LARGE_INTEGER offset; offset.QuadPart = static_cast(position); + require(SetFilePointerEx(file.get(), offset, nullptr, FILE_BEGIN), "Seek admitted file handle failed", file.path); + std::array buffer; + DWORD count; + require(ReadFile(file.get(), buffer.data(), static_cast(length), &count, nullptr), "Read admitted file handle failed", file.path); + napi_value result; + check(napi_create_buffer_copy(env, count, buffer.data(), nullptr, &result)); + return result; + } + if (operation == L"runtime_create") { + auto root = createRuntime(wide(env, args[0]), wide(env, args[1])); + auto result = identity(env, root->directory.get(), root->path); + set(env, result, "path", string(env, root->path)); + set(env, result, "privateDacl", boolean(env, true)); + set(env, result, "handle", wrap(env, std::move(root))); + return result; + } + auto& root = static_cast(resource(env, args[0], Kind::Runtime)); + if (operation == L"runtime_snapshot_cancel") { root.snapshotCancelled.store(true); return null(env); } + require(!root.snapshotPending, "Runtime snapshot is still pending", root.path, ERROR_BUSY); + // Reject overlapping operations before snapshot() resets cancellation. The + // one accepted worker and WindowsPrivateRuntime retain the same root lease. + if (operation == L"runtime_close") { closeRuntime(root); return null(env); } + if (operation == L"runtime_mkdir") { directory(root, wide(env, args[1])); return null(env); } + if (operation == L"runtime_open") { + const auto path = relativePath(root, wide(env, args[1])); + auto file = runtimeDirectory(root, path.substr(0, path.find_last_of(L'\\'))); + file->path = path; + file->requestedPath = path; + file->handles.push_back(openComponent(path, GENERIC_READ, false)); + verifyPrivate(file->get(), path, false); + auto result = identity(env, file->get(), path); + set(env, result, "handle", wrap(env, std::move(file))); + return result; + } + if (operation == L"runtime_write") { + bool isBuffer; check(napi_is_buffer(env, args[2], &isBuffer)); + require(isBuffer, "Private write requires a byte buffer", root.path, ERROR_INVALID_PARAMETER); + void* bytes; size_t length; check(napi_get_buffer_info(env, args[2], &bytes, &length)); + require(length <= MAXDWORD, "Private write exceeds Win32 byte representation", root.path, ERROR_INVALID_PARAMETER); + auto file = createPrivateFile(root, wide(env, args[1])); + writeBytes(file.get(), bytes, static_cast(length), root.path); + require(FlushFileBuffers(file.get()), "Flush private runtime file failed", root.path); + root.immutableFiles.push_back(std::move(file)); + return null(env); + } + if (operation == L"runtime_snapshot") { + return snapshot(env, args[0], root, wide(env, args[1]), wide(env, args[2])); + } + throw Failure("Unknown native filesystem operation", L"", ERROR_INVALID_PARAMETER); +} + +} // namespace rea diff --git a/native/windows/src/process.cc b/native/windows/src/process.cc new file mode 100644 index 000000000..3e8028951 --- /dev/null +++ b/native/windows/src/process.cc @@ -0,0 +1,271 @@ +#include "authority.hpp" + +namespace rea { + +class Attributes { + std::vector storage_; +public: + explicit Attributes(DWORD count) { + SIZE_T size = 0; + InitializeProcThreadAttributeList(nullptr, count, 0, &size); + storage_.resize(size); + require(InitializeProcThreadAttributeList(get(), count, 0, &size), "Initialize process attributes failed"); + } + ~Attributes() { DeleteProcThreadAttributeList(get()); } + LPPROC_THREAD_ATTRIBUTE_LIST get() { return reinterpret_cast(storage_.data()); } + void add(DWORD_PTR key, void* value, SIZE_T size) { + require(UpdateProcThreadAttribute(get(), 0, key, value, size, nullptr, nullptr), + "Windows atomic process attribute is unavailable"); + } +}; + +static Handle createJob() { + Handle job(CreateJobObjectW(nullptr, nullptr)); + require(job.valid(), "Create owned Job Object failed"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION policy{}; + policy.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + require(SetInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &policy, sizeof(policy)), + "Apply kill-on-owner-close Job Object policy failed"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION actual{}; + require(QueryInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &actual, sizeof(actual), nullptr), + "Read Job Object policy failed"); + require(actual.BasicLimitInformation.LimitFlags == JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + "Job Object policy permits unexpected breakaway", L"", ERROR_ACCESS_DENIED); + return job; +} + +static void pipe(Handle& read, Handle& write) { + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + HANDLE rawRead, rawWrite; + require(CreatePipe(&rawRead, &rawWrite, &attributes, 0), "Create owned process output pipe failed"); + read.reset(rawRead); write.reset(rawWrite); + require(SetHandleInformation(read.get(), HANDLE_FLAG_INHERIT, 0), "Protect process output handle inheritance failed"); +} + +std::unique_ptr spawnProcess(const std::wstring& command, const std::wstring& commandLine, + const std::wstring& cwd, const std::vector& environment) { + require(command.size() >= 3 && command[1] == L':' && command[2] == L'\\', + "Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER); + auto result = std::make_unique(); + result->job = createJob(); + Handle stdoutWrite, stderrWrite; + pipe(result->stdoutRead, stdoutWrite); pipe(result->stderrRead, stderrWrite); + SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + Handle input(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + require(input.valid(), "Open owned process null input failed"); + Attributes attributes(2); + HANDLE job = result->job.get(); + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job)); + std::array inherited{input.get(), stdoutWrite.get(), stderrWrite.get()}; + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited.data(), sizeof(inherited)); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = input.get(); + startup.StartupInfo.hStdOutput = stdoutWrite.get(); + startup.StartupInfo.hStdError = stderrWrite.get(); + startup.lpAttributeList = attributes.get(); + // Windows environment names use ordinal case-insensitive comparison, not + // locale collation or JavaScript's expanding Unicode uppercase conversion. + auto orderedEnvironment = environment; + auto keyLength = [&](const std::wstring& entry) { + const auto end = entry.find(L'=', entry.front() == L'=' ? 1 : 0); + require(end != std::wstring::npos && end > 0, "Invalid child environment name", command, + ERROR_INVALID_PARAMETER); + return static_cast(end); + }; + for (const auto& entry : orderedEnvironment) { + require(!entry.empty(), "Empty child environment entry", command, ERROR_INVALID_PARAMETER); + keyLength(entry); + } + auto compareNames = [&](const std::wstring& left, const std::wstring& right) { + const auto result = CompareStringOrdinal(left.data(), keyLength(left), right.data(), keyLength(right), TRUE); + require(result != 0, "Compare Windows environment names failed", command); + return result; + }; + std::stable_sort(orderedEnvironment.begin(), orderedEnvironment.end(), + [&](const std::wstring& left, const std::wstring& right) { + return compareNames(left, right) == CSTR_LESS_THAN; + }); + std::vector uniqueEnvironment; + for (auto& entry : orderedEnvironment) { + if (!uniqueEnvironment.empty() && compareNames(uniqueEnvironment.back(), entry) == CSTR_EQUAL) + uniqueEnvironment.back() = std::move(entry); + else uniqueEnvironment.push_back(std::move(entry)); + } + std::vector variables; + for (const auto& entry : uniqueEnvironment) { + variables.insert(variables.end(), entry.begin(), entry.end()); variables.push_back(L'\0'); + } + variables.push_back(L'\0'); + if (uniqueEnvironment.empty()) variables.push_back(L'\0'); + std::vector line(commandLine.begin(), commandLine.end()); line.push_back(L'\0'); + PROCESS_INFORMATION process{}; + require(CreateProcessW(command.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); + result->process.reset(process.hProcess); + Handle thread(process.hThread); + result->pid = process.dwProcessId; + // An elevated token may default file ownership to Administrators. Normalize + // only the suspended child's default owner so its runtime files belong to the + // current user; the caller's token and privileges are not modified. + HANDLE rawToken; + require(OpenProcessToken(result->process.get(), TOKEN_QUERY | TOKEN_ADJUST_DEFAULT, &rawToken), + "Open suspended process default-owner token failed", command); + Handle token(rawToken); + DWORD tokenSize = 0; + GetTokenInformation(token.get(), TokenUser, nullptr, 0, &tokenSize); + std::vector tokenUser(tokenSize); + require(GetTokenInformation(token.get(), TokenUser, tokenUser.data(), tokenSize, &tokenSize), + "Read suspended process user identity failed", command); + TOKEN_OWNER owner{reinterpret_cast(tokenUser.data())->User.Sid}; + require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)), + "Set suspended process file owner failed", command); + DWORD_PTR affinity = 0, systemAffinity = 0; + require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity), + "Read caller process affinity failed", command); + if (affinity != 0) + require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command); + const DWORD priority = GetPriorityClass(GetCurrentProcess()); + require(priority != 0 && SetPriorityClass(result->process.get(), priority), + "Preserve caller process priority failed", command); + BOOL assigned = FALSE; + if (!IsProcessInJob(result->process.get(), result->job.get(), &assigned) || !assigned) { + TerminateJobObject(result->job.get(), 1); + throw Failure("Suspended process has no owned Job Object membership", command, ERROR_ACCESS_DENIED); + } + require(ResumeThread(thread.get()) != MAXDWORD, "Resume job-owned process failed", command); + return result; +} + +static napi_value readOutput(napi_env env, Handle& read, bool& ended) { + std::array bytes; + DWORD available = 0, count = 0; + if (!read.valid()) ended = true; + else if (!PeekNamedPipe(read.get(), nullptr, 0, nullptr, &available, nullptr)) { + require(GetLastError() == ERROR_BROKEN_PIPE, "Observe owned process output failed"); + read.reset(); ended = true; + } else if (available > 0) { + require(ReadFile(read.get(), bytes.data(), std::min(available, bytes.size()), &count, nullptr), + "Read owned process output failed"); + } + napi_value result; + check(napi_create_buffer_copy(env, count, bytes.data(), nullptr, &result)); + return result; +} + +static DWORD activeProcesses(Process& process) { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION state{}; + require(QueryInformationJobObject(process.job.get(), JobObjectBasicAccountingInformation, &state, sizeof(state), nullptr), + "Observe owned Job Object settlement failed"); + return state.ActiveProcesses; +} + +static napi_value callerToken(napi_env env) { + HANDLE raw; + require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &raw), "Read caller token failed"); + Handle token(raw); + DWORD size; + TOKEN_ELEVATION elevation{}; + require(GetTokenInformation(token.get(), TokenElevation, &elevation, sizeof(elevation), &size), "Read caller elevation failed"); + std::vector user(4096), owner(4096), integrity(4096); + require(GetTokenInformation(token.get(), TokenUser, user.data(), user.size(), &size) && + GetTokenInformation(token.get(), TokenOwner, owner.data(), owner.size(), &size) && + GetTokenInformation(token.get(), TokenIntegrityLevel, integrity.data(), integrity.size(), &size), + "Read caller token observations failed"); + HANDLE rawImpersonation; + require(DuplicateToken(token.get(), SecurityIdentification, &rawImpersonation), "Observe caller group membership failed"); + Handle impersonation(rawImpersonation); + std::array administrators; + DWORD administratorSize = administrators.size(); + require(CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, administrators.data(), &administratorSize), + "Create administrator group identity failed"); + BOOL administrator = FALSE, assigned = FALSE; + require(CheckTokenMembership(impersonation.get(), administrators.data(), &administrator), "Read caller administrator membership failed"); + require(IsProcessInJob(GetCurrentProcess(), nullptr, &assigned), "Read caller job membership failed"); + const auto sid = reinterpret_cast(integrity.data())->Label.Sid; + auto result = object(env); + set(env, result, "elevated", boolean(env, elevation.TokenIsElevated != 0)); + set(env, result, "administrator", boolean(env, administrator != FALSE)); + set(env, result, "integrityLevel", number(env, *GetSidSubAuthority(sid, *GetSidSubAuthorityCount(sid) - 1))); + set(env, result, "defaultOwnerIsUser", boolean(env, EqualSid(reinterpret_cast(user.data())->User.Sid, + reinterpret_cast(owner.data())->Owner))); + set(env, result, "inJob", boolean(env, assigned != FALSE)); + return result; +} + +napi_value processCall(napi_env env, const std::wstring& operation, const std::vector& args) { + if (operation == L"process_caller_token") { + require(args.empty(), "Caller token observation takes no arguments", L"", ERROR_INVALID_PARAMETER); + return callerToken(env); + } + if (operation == L"process_spawn") { + require(args.size() == 4, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER); + std::vector environment; + for (const auto& entry : array(env, args[3])) environment.push_back(wide(env, entry)); + auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment); + auto result = object(env); + set(env, result, "pid", number(env, process->pid)); + set(env, result, "jobAssigned", boolean(env, true)); + set(env, result, "killOnOwnerClose", boolean(env, true)); + set(env, result, "handle", wrap(env, std::move(process))); + return result; + } + require(args.size() == 1, "Wrong native process handle argument count", L"", ERROR_INVALID_PARAMETER); + auto& process = static_cast(resource(env, args[0], Kind::Process)); + if (operation == L"process_poll") { + auto result = object(env); + bool stdoutEnded = false, stderrEnded = false; + set(env, result, "stdout", readOutput(env, process.stdoutRead, stdoutEnded)); + set(env, result, "stderr", readOutput(env, process.stderrRead, stderrEnded)); + set(env, result, "stdoutEnded", boolean(env, stdoutEnded)); + set(env, result, "stderrEnded", boolean(env, stderrEnded)); + const auto exited = WaitForSingleObject(process.process.get(), 0); + require(exited == WAIT_OBJECT_0 || exited == WAIT_TIMEOUT, "Observe owned process exit failed"); + DWORD code = 0; + if (exited == WAIT_OBJECT_0) require(GetExitCodeProcess(process.process.get(), &code), "Read owned process exit code failed"); + set(env, result, "exitCode", exited == WAIT_OBJECT_0 ? number(env, code) : null(env)); + set(env, result, "activeProcesses", number(env, activeProcesses(process))); + return result; + } + if (operation == L"process_alive") return number(env, activeProcesses(process)); + if (operation == L"process_terminate") { + const auto active = activeProcesses(process); + if (active > 0) require(TerminateJobObject(process.job.get(), 1), "Terminate owned Job Object failed"); + return boolean(env, active > 0); + } + if (operation == L"process_close") { + // Closing the job is the crash-safe authority; no PID enumeration is used. + process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset(); + process.closed = true; + return null(env); + } + throw Failure("Unknown native process operation", L"", ERROR_INVALID_PARAMETER); +} + +napi_value inspect(napi_env env) { + wchar_t temp[32768]; + const auto length = GetTempPathW(32768, temp); + require(length > 0 && length < 32768, "Read temporary directory coordinate failed"); + auto root = createRuntime(std::wstring(temp, length), L"rea-native-probe-"); + auto filesystem = identity(env, root->directory.get(), root->path); + closeRuntime(*root); + auto job = createJob(); + Attributes attributes(1); + HANDLE raw = job.get(); + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &raw, sizeof(raw)); + auto result = object(env); + set(env, result, "abiVersion", number(env, 1)); + set(env, result, "nodeApiVersion", number(env, 8)); + set(env, result, "architecture", string(env, std::string("x64"))); + set(env, result, "filesystem", filesystem); + set(env, result, "privateDacl", boolean(env, true)); + set(env, result, "atomicJobAssignment", boolean(env, true)); + set(env, result, "killOnOwnerClose", boolean(env, true)); + return result; +} + +} // namespace rea diff --git a/package.json b/package.json index 4aa3a8c77..221ef970b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,10 @@ "scripts/prepare.mjs", "skills", "README.md", - "LICENSE" + "LICENSE", + "native/windows/build/manifest.json", + "native/windows/build/rea-windows-x64.node", + "scripts/verify-windows-native-artifact.mjs" ], "scripts": { "deps:check": "node scripts/check-dependency-install.mjs", @@ -156,7 +159,12 @@ "prepare": "node scripts/prepare.mjs", "verify:asset-catalog": "npm run build:cached && node scripts/verify-asset-catalog.mjs", "verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial", - "test:focused": "node scripts/run-development-tests.mjs focused" + "test:focused": "node scripts/run-development-tests.mjs focused", + "build:windows-native": "node scripts/build-windows-native.mjs", + "verify:windows-native:artifact": "node scripts/verify-windows-native-artifact.mjs", + "prepublishOnly": "npm run verify:windows-native:artifact", + "verify:windows-native": "node scripts/verify-windows-native.mjs", + "verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs" }, "lint-staged": { "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [ diff --git a/scripts/build-windows-native.mjs b/scripts/build-windows-native.mjs new file mode 100644 index 000000000..c77d5e7d3 --- /dev/null +++ b/scripts/build-windows-native.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const nodeVersion = "22.19.0"; +const compiler = "x86_64-w64-mingw32-g++"; +const cache = join(homedir(), ".cache", "rea-windows-native"); +const output = join(root, "native", "windows", "build"); +const run = (command, args) => { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 4 * 1_024 * 1_024, + timeout: 120_000, + }); + if (result.error !== undefined) + throw new Error( + `Windows native build lane requires ${command}: ${result.error.message}`, + ); + if (result.status !== 0) + throw new Error( + `Windows native build failed: ${result.stdout}${result.stderr}`, + ); + return result.stdout; +}; +if (process.platform !== "linux") + throw new Error( + "The Windows x64 artifact build lane currently requires Linux and MinGW-w64.", + ); +run(compiler, ["--version"]); +await mkdir(cache, { recursive: true }); +await mkdir(output, { recursive: true }); +const exists = async (path) => + access(path).then( + () => true, + () => false, + ); +const fetchBytes = async (name) => { + const response = await fetch( + `https://nodejs.org/dist/v${nodeVersion}/${name}`, + { + signal: AbortSignal.timeout(60_000), + }, + ); + if (!response.ok) + throw new Error( + `Node build prerequisite download failed: ${name}: HTTP ${response.status}`, + ); + return Buffer.from(await response.arrayBuffer()); +}; +const checksums = (await fetchBytes("SHASUMS256.txt")).toString("utf8"); +const verifiedDownload = async (name, path) => { + if (!(await exists(path))) await writeFile(path, await fetchBytes(name)); + const digest = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const expected = checksums + .split("\n") + .find((line) => line.slice(66) === name) + ?.slice(0, 64); + if (expected === undefined || digest !== expected) + throw new Error(`Node build prerequisite SHA-256 mismatch: ${name}`); +}; +const cachedHeaders = join( + homedir(), + ".cache", + "node-gyp", + nodeVersion, + "include", + "node", +); +let headers = cachedHeaders; +if (!(await exists(join(headers, "node_api.h")))) { + const archive = join(cache, `node-v${nodeVersion}-headers.tar.gz`); + await verifiedDownload(`node-v${nodeVersion}-headers.tar.gz`, archive); + run("tar", ["xzf", archive, "-C", cache]); + headers = join(cache, `node-v${nodeVersion}`, "include", "node"); +} +const artifact = "rea-windows-x64.node"; +// MSVC short-import libraries do not produce a correct Node import table with +// GNU ld. Generate a GNU import library for the exact stable Node-API symbols. +const sources = ["addon.cc", "filesystem.cc", "process.cc"]; +const sourceText = ( + await Promise.all( + sources.map((path) => + readFile(join(root, "native", "windows", "src", path), "utf8"), + ), + ) +).join("\n"); +const symbols = [ + ...new Set( + [...sourceText.matchAll(/\b(napi_[a-z0-9_]+)\s*\(/gu)].map( + (match) => match[1], + ), + ), +].sort(); +const definition = join(cache, "node-api.def"); +const library = join(cache, "libnode-api.a"); +await writeFile( + definition, + `LIBRARY node.exe\nEXPORTS\n${symbols.join("\n")}\n`, +); +run("x86_64-w64-mingw32-dlltool", ["-d", definition, "-l", library]); +run(compiler, [ + "-std=c++17", + "-O2", + "-DNAPI_VERSION=8", + "-D_WIN32_WINNT=0x0A00", + "-shared", + "-static", + "-static-libgcc", + "-static-libstdc++", + "-Wl,--no-insert-timestamp", + `-I${headers}`, + "native/windows/src/addon.cc", + "native/windows/src/filesystem.cc", + "native/windows/src/process.cc", + library, + "-ladvapi32", + "-lbcrypt", + "-lntdll", + "-o", + join(output, artifact), +]); +const packageJson = JSON.parse( + await readFile(join(root, "package.json"), "utf8"), +); +const bytes = await readFile(join(output, artifact)); +const metadata = { + packageVersion: packageJson.version, + platform: "win32", + architecture: "x64", + abiVersion: 1, + nodeApiVersion: 8, + artifact, + artifactSha256: createHash("sha256").update(bytes).digest("hex"), +}; +await writeFile( + join(output, "manifest.json"), + `${JSON.stringify(metadata, null, 2)}\n`, +); +process.stdout.write( + `${JSON.stringify({ ...metadata, artifactBytes: bytes.length })}\n`, +); diff --git a/scripts/lib/verifier-run.mjs b/scripts/lib/verifier-run.mjs index c3e0d2c28..0b8f08919 100644 --- a/scripts/lib/verifier-run.mjs +++ b/scripts/lib/verifier-run.mjs @@ -39,7 +39,7 @@ const observeVerifierLineage = async (run, observedAt) => { run, observedAt, null, - "Windows verifier lineage ownership is unavailable without Job Objects", + "The verifier has no retained native Job Object lease for lineage observation; provider jobs are verified by their native owners", ); try { const { members, observerPid } = await processSnapshot(); diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs new file mode 100644 index 000000000..7c3fa36c1 --- /dev/null +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +if (process.platform !== "win32" || process.arch !== "x64") + throw new Error( + "Packaged Windows Ghidra acceptance requires a Windows x64 host.", + ); +const workspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-")); +const exec = promisify(execFile); +let packageRoot = resolve(process.argv[2] ?? "."); +// The default lane verifies the npm artifact in an isolated prefix. Explicit +// package roots support an already-installed artifact without a second install. +if (process.argv[2] === undefined) { + try { + const npmEntry = + process.env.npm_execpath ?? + join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"); + const packed = JSON.parse( + ( + await exec( + process.execPath, + [ + npmEntry, + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + workspace, + ], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, + ) + ).stdout, + ); + assert.equal( + packed.length, + 1, + "The package lane must produce one artifact.", + ); + const prefix = join(workspace, "installed"); + await exec( + process.execPath, + [ + npmEntry, + "install", + "--prefix", + prefix, + "--ignore-scripts", + "--omit=dev", + "--no-audit", + "--no-fund", + join(workspace, packed[0].filename), + ], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, + ); + packageRoot = join(prefix, "node_modules", "rea-agents"); + } catch (cause) { + await rm(workspace, { recursive: true, force: true }); + throw cause; + } +} +const target = resolve( + process.argv[3] ?? "build/fixtures/rea-ghidra-windows.exe", +); +let TOOL_CONTRACTS, native, token, sha256; +try { + ({ TOOL_CONTRACTS } = await import( + pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js")) + )); + const { requireWindowsNativeAuthority } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js")) + ); + native = requireWindowsNativeAuthority(); + token = native.call("process_caller_token", []); + assert.equal( + token.administrator, + false, + "The real-provider lane must run as an ordinary user.", + ); + assert.equal( + token.elevated, + false, + "The real-provider lane must use a non-elevated token.", + ); + const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); + sha256 = digest(await readFile(target)); +} catch (cause) { + await rm(workspace, { recursive: true, force: true }); + throw cause; +} +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const runtimeParent = join(workspace, "runtime with spaces"); +await mkdir(runtimeParent); +const environment = { + ...process.env, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_LOG_LEVEL: "error", + GHIDRA_HEADLESS_MAXMEM: "512m", + NODE_OPTIONS: "--max-old-space-size=512", + TEMP: runtimeParent, + TMP: runtimeParent, + HOME: workspace, + USERPROFILE: workspace, + APPDATA: join(workspace, "appdata"), + LOCALAPPDATA: join(workspace, "localappdata"), +}; +const entry = join(packageRoot, "scripts/rea.mjs"); +const report = { + ok: false, + platform: "win32", + architecture: "x64", + nonAdmin: true, + targetSha256: sha256, + cli: {}, + mcpOperations: [], + nativeControls: "available", + runtimeCleanup: false, +}; +const assertCleanup = async () => { + const entries = await readdir(runtimeParent); + report.runtimeResidualCount = entries.length; + report.runtimeResidualKinds = entries.map((name) => + name.startsWith("rea-") + ? "rea-runtime" + : name.startsWith("hsperfdata_") + ? "jvm-performance-data" + : "other", + ); + assert.equal(entries.length, 0, "Provider runtime cleanup left files."); +}; +try { + const providers = JSON.parse( + ( + await exec(process.execPath, [entry, "providers", "--json"], { + env: environment, + timeout: 30_000, + }) + ).stdout, + ); + assert.ok(JSON.stringify(providers).includes("ghidra")); + report.cli.providers = true; + const inspected = await exec( + process.execPath, + [entry, "inspect", target, "--provider", "ghidra", "--format", "json"], + { env: environment, timeout: 360_000, maxBuffer: 8 * 1024 * 1024 }, + ); + const inspection = JSON.parse(inspected.stdout); + assert.ok(inspection.error === undefined); + report.cli.inspect = true; + await assertCleanup(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entry, "mcp"], + env: environment, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-packaged-windows-ghidra", + version: "1.0.0", + }); + const call = async (name, arguments_) => { + report.lastOperation = name; + if (process.argv[4] !== undefined) + await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); + const contract = TOOL_CONTRACTS.find((value) => value.name === name); + assert.ok(contract !== undefined, `Missing canonical contract: ${name}`); + const argumentsParsed = contract.inputSchema.parse(arguments_); + const result = await client.callTool( + { name, arguments: argumentsParsed }, + { timeout: 360_000 }, + ); + assert.notEqual(result.isError, true, `Packaged operation failed: ${name}`); + const structured = + result.structuredContent ?? + JSON.parse(result.content.find((item) => item.type === "text").text); + const parsed = contract.outputSchema.parse(structured); + report.mcpOperations.push(name); + return parsed.result; + }; + try { + await client.connect(transport); + await call("open_binary", { path: target, provider_id: "ghidra" }); + assert.equal((await call("list_documents", {})).length, 1); + await call("list_names", {}); + const procedures = await call("list_procedures", {}); + assert.ok(procedures.length > 0); + assert.ok((await call("list_segments", {})).length > 0); + await call("list_strings", {}); + const procedure = + procedures.find((item) => item.procedure?.external === false) ?? + procedures[0]; + assert.ok(procedure !== undefined); + const loadImage = await call("inspect_native_load_image", {}); + assert.equal( + loadImage.status, + "unsupported", + "PE load-image attestation must remain explicitly unsupported.", + ); + assert.ok(loadImage.observations !== undefined); + const memory = await call("read_bytes", { + address: procedure.address, + length: 16, + }); + const mapping = await call("address_to_file_offset", { + address: procedure.address, + }); + assert.equal(memory.complete, true); + assert.equal(memory.returned_bytes, 16); + assert.equal( + memory.bytes_hex, + (await readFile(target)) + .subarray(mapping.file_offset, mapping.file_offset + 16) + .toString("hex"), + ); + await call("address_name", { address: procedure.address }); + assert.equal( + await call("procedure_address", { procedure: procedure.value }), + procedure.address, + ); + await call("resolve_containing_procedure", { address: procedure.address }); + await call("search_procedures", { pattern: procedure.value }); + await call("search_strings", { pattern: "rea" }); + for (const name of [ + "procedure_assembly", + "procedure_callees", + "procedure_callers", + "procedure_info", + "procedure_pseudo_code", + "read_function_instructions", + "procedure_references", + "analyze_function", + ]) + await call(name, { procedure: procedure.value }); + await call("xrefs", { address: procedure.address }); + await call("inspect_native_data_type", { type: "/undefined8" }); + await call("inspect_native_instruction", { address: procedure.address }); + await call("resolve_native_call_targets", { address: procedure.address }); + const expected = ( + await import( + pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) + ) + ).GHIDRA_SESSION_CAPABILITIES.filter( + (name) => name !== "ping" && name !== "shutdown", + ); + assert.ok( + expected.every((name) => report.mcpOperations.includes(name)), + "Packaged verification omitted an admitted operation.", + ); + await call("binary_session", {}); + await call("close_binary", {}); + await assertCleanup(); + } finally { + await client.close(); + } + assert.equal( + digest(await readFile(target)), + sha256, + "Analysis modified the selected target.", + ); + assert.deepEqual( + native.call("process_caller_token", []), + token, + "Analysis changed caller token authority.", + ); + report.runtimeCleanup = true; + report.ok = true; + if (process.argv[4] !== undefined) + await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); + process.stdout.write(`${JSON.stringify(report)}\n`); +} catch (cause) { + report.failure = { + name: cause.name, + message: cause.message + .replaceAll(packageRoot, "") + .replaceAll(target, "") + .replaceAll(workspace, ""), + }; + if (process.argv[4] !== undefined) + await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); + throw cause; +} finally { + await rm(workspace, { recursive: true, force: true }); +} diff --git a/scripts/verify-real-ghidra-windows.mjs b/scripts/verify-real-ghidra-windows.mjs index 0dbd9b269..08f05cffb 100644 --- a/scripts/verify-real-ghidra-windows.mjs +++ b/scripts/verify-real-ghidra-windows.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { access } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -74,6 +74,7 @@ if (!profile.ok || profile.value.profile === null) throw new Error("Windows Ghidra profile could not be committed"); const client = new GhidraClient({ + platform: installation.platform, launcher: new GhidraHeadlessLauncher({ analyzeHeadlessPath: installation.analyzeHeadlessPath, ...(process.env.JAVA_HOME === undefined @@ -113,19 +114,41 @@ try { if ( !Array.isArray(documents) || documents.length !== 1 || - !pageHasItems(procedures) || + !Array.isArray(procedures) || + procedures.length === 0 || !Array.isArray(segments) || segments.length === 0 || - !isPage(names) || - !isPage(strings) + !Array.isArray(names) || + !Array.isArray(strings) ) throw new Error("Windows Ghidra inventory proof was incomplete"); - const procedure = procedures.items.find( + const procedure = procedures.find( (item) => item.procedure?.external === false, ); if (procedure === undefined) throw new Error("Windows Ghidra fixture exposed no local procedure"); + await inventory("inspect_native_load_image", {}); + const memory = await inventory("read_bytes", { + address: procedure.address, + length: 16, + }); + const mapping = await inventory("address_to_file_offset", { + address: procedure.address, + }); + const sourceBytes = await readFile(targetPath); + if ( + memory.complete !== true || + memory.returned_bytes !== 16 || + memory.bytes_hex !== + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 16) + .toString("hex") + ) + throw new Error( + "Windows loaded bytes disagree with the observed source-file mapping", + ); + await inventory("address_name", { address: procedure.address }); const resolvedAddress = await inventory("procedure_address", { procedure: procedure.value, @@ -149,6 +172,13 @@ try { ]) await functionOperation(operation, { procedure: procedure.value }); await functionOperation("xrefs", { address: procedure.address }); + await functionOperation("inspect_native_data_type", { type: "/undefined8" }); + await functionOperation("inspect_native_instruction", { + address: procedure.address, + }); + await functionOperation("resolve_native_call_targets", { + address: procedure.address, + }); await functionOperation("analyze_function", { procedure: procedure.value, }); @@ -178,11 +208,16 @@ try { transport: "authenticated-loopback-tcp", operations: [...observed].sort((left, right) => left.localeCompare(right)), cleanup: "complete", + native_controls: { + job_object: true, + protected_dacl: true, + handle_admission: true, + }, limitations: [ "approved-non-sensitive-fixtures-only", - "no-job-object-ownership", - "no-private-dacl-proof", - "no-reparse-point-authority", + "windows-x64-local-ntfs-only", + "native-x86-64-pe-applications-only", + "no-gui-or-mutation-authority", ], }; } finally { @@ -226,7 +261,9 @@ function assertSession(session, targetSha256, profileDigest) { session.read_only !== true || session.analysis_complete !== true || session.analysis_timed_out !== false || - session.capabilities.join(",") !== SESSION_CAPABILITIES.join(",") + session.capabilities.length !== SESSION_CAPABILITIES.length || + new Set(session.capabilities).size !== SESSION_CAPABILITIES.length || + SESSION_CAPABILITIES.some((name) => !session.capabilities.includes(name)) ) throw new Error( `Windows Ghidra session commitment drifted: ${JSON.stringify(session)}`, @@ -240,7 +277,9 @@ function assertRuntimeCoordinates(runtime, targetSha256) { typeof runtime.runtime_root !== "string" || typeof runtime.endpoint_path !== "string" || typeof runtime.project_root !== "string" || - typeof runtime.process_id !== "number" + typeof runtime.process_id !== "number" || + typeof runtime.target_admission !== "object" || + runtime.target_admission === null ) throw new Error( `Windows Ghidra runtime coordinates are incomplete: ${JSON.stringify(runtime)}`, @@ -262,14 +301,6 @@ async function assertCleanup(runtime) { ); } -const isPage = (value) => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Array.isArray(value.items); - -const pageHasItems = (value) => isPage(value) && value.items.length > 0; - async function exists(path) { try { await access(path); diff --git a/scripts/verify-windows-native-artifact.mjs b/scripts/verify-windows-native-artifact.mjs new file mode 100644 index 000000000..1a6777322 --- /dev/null +++ b/scripts/verify-windows-native-artifact.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +const root = new URL("../", import.meta.url); +const packageJson = JSON.parse( + await readFile(new URL("package.json", root), "utf8"), +); +const manifest = JSON.parse( + await readFile(new URL("native/windows/build/manifest.json", root), "utf8"), +); +const bytes = await readFile( + new URL("native/windows/build/rea-windows-x64.node", root), +); +if ( + manifest.packageVersion !== packageJson.version || + manifest.platform !== "win32" || + manifest.architecture !== "x64" || + manifest.abiVersion !== 1 || + manifest.nodeApiVersion !== 8 || + manifest.artifact !== "rea-windows-x64.node" || + createHash("sha256").update(bytes).digest("hex") !== manifest.artifactSha256 +) + throw new Error( + "Windows native artifact metadata or SHA-256 does not match this package; run the Windows native build lane.", + ); +const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length; +if ( + bytes.toString("ascii", 0, 2) !== "MZ" || + offset > bytes.length - 26 || + bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" || + bytes.readUInt16LE(offset + 4) !== 0x8664 || + bytes.readUInt16LE(offset + 24) !== 0x20b +) + throw new Error("Windows native artifact must be a Windows x64 PE32+ image."); +process.stdout.write( + `${JSON.stringify({ ok: true, ...manifest, artifactBytes: bytes.length })}\n`, +); diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs new file mode 100644 index 000000000..68c2ce60b --- /dev/null +++ b/scripts/verify-windows-native.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + access, + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + writeFile, + open, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +if (process.platform !== "win32") + throw new Error( + "Windows native conformance requires a real Windows x64 host.", + ); +const packageRoot = resolve(process.argv[2] ?? "."); +const { requireWindowsNativeAuthority } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js")) +); +const native = requireWindowsNativeAuthority(); +const { WindowsPrivateRuntime } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsPrivateRuntime.js")) +); +const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-")); +const environment = Object.entries(process.env) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${value}`); +const quote = (value) => + `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`; +const delay = () => new Promise((resolve) => setTimeout(resolve, 25)); +const exists = (path) => + access(path).then( + () => true, + () => false, + ); +const live = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; +const launch = (arguments_) => + native.call("process_spawn", [ + process.execPath, + [process.execPath, ...arguments_].map(quote).join(" "), + workspace, + environment, + ]); +const wait = async (child) => { + let output = ""; + for (let attempt = 0; attempt < 400; attempt++) { + const state = native.call("process_poll", [child.handle]); + output += state.stdout.toString(); + if ( + state.exitCode !== null && + state.activeProcesses === 0 && + state.stdoutEnded && + state.stderrEnded + ) + return { ...state, output }; + await delay(); + } + throw new Error("Owned job did not settle within the fixture deadline."); +}; +const report = { ok: false, artifact: "verified", controls: {}, processes: {} }; +try { + const sourceDirectory = join(workspace, "source"); + await mkdir(sourceDirectory); + const source = join(sourceDirectory, "target.bin"); + await writeFile(source, Buffer.alloc(1024 * 1024, 0x37)); + const snapshotOwner = WindowsPrivateRuntime.create( + workspace, + "single-flight-", + ); + try { + const rejectedController = new AbortController(); + const accepted = snapshotOwner.snapshot(source, "accepted.bin"); + const rejected = snapshotOwner.snapshot( + source, + "rejected.bin", + rejectedController.signal, + ); + rejectedController.abort(); + await assert.rejects(rejected, /Runtime snapshot is still pending/u); + const completed = await accepted; + assert.equal( + completed.sha256, + createHash("sha256") + .update(await readFile(source)) + .digest("hex"), + ); + assert.equal( + await exists(join(snapshotOwner.observation.path, "rejected.bin")), + false, + ); + report.controls.singleFlightCancellationOwnership = true; + } finally { + await snapshotOwner.close(); + } + const unrelatedFile = join(workspace, "unrelated-file"); + await writeFile(unrelatedFile, "preserve unrelated writes"); + const admitted = native.call("open", [source]); + try { + await assert.rejects( + rename(sourceDirectory, `${sourceDirectory}-replacement`), + ); + await assert.rejects(open(source, "r+")); + await rename(unrelatedFile, `${unrelatedFile}-renamed`); + if (process.argv[3] !== undefined) { + const observation = JSON.parse( + execFileSync( + resolve(process.argv[3]), + ["reparse-directory", sourceDirectory, workspace], + { encoding: "utf8" }, + ), + ); + assert.equal( + observation.reparseConversionDenied, + true, + "An admitted ancestor was converted into a junction.", + ); + report.controls.inPlaceAncestorReparseDenied = true; + } + assert.throws(() => native.call("read", [{}, 0, 1])); + assert.throws(() => native.call("read", [admitted.handle, NaN, 1])); + assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37); + } finally { + native.call("close", [admitted.handle]); + } + assert.throws(() => native.call("read", [admitted.handle, 0, 1])); + const junction = join(workspace, "junction"); + await symlink(sourceDirectory, junction, "junction"); + assert.throws( + () => native.call("open", [join(junction, "target.bin")]), + /Reparse/u, + ); + assert.throws(() => native.call("open", [source + ":stream"])); + const runtime = native.call("runtime_create", [ + workspace, + "private root with spaces-", + ]); + const sentinel = join(sourceDirectory, "sentinel"); + await writeFile(sentinel, "preserve"); + try { + const acl = JSON.parse( + execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$a=Get-Acl -LiteralPath $env:REA_TEST_PRIVATE_RUNTIME_PATH; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count} | ConvertTo-Json -Compress", + ], + { + encoding: "utf8", + // A pwsh caller can supply module paths incompatible with the + // Windows PowerShell observer. Let that observer build its own path. + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name.toLowerCase() !== "psmodulepath", + ), + ), + REA_TEST_PRIVATE_RUNTIME_PATH: runtime.path, + }, + }, + ), + ); + assert.deepEqual(acl, { + protected: true, + ownerMatchesUser: true, + trusteesOnlyUserAndSystem: true, + count: 2, + }); + native.call("runtime_mkdir", [runtime.handle, "inputs"]); + native.call("runtime_write", [ + runtime.handle, + "descriptor.json", + Buffer.from("fixture"), + ]); + assert.throws(() => + native.call("runtime_write", [ + runtime.handle, + "descriptor.json", + Buffer.from("replace"), + ]), + ); + assert.throws(() => + native.call("runtime_mkdir", [runtime.handle, "..\\escape"]), + ); + const pending = native.call("runtime_snapshot", [ + runtime.handle, + source, + "inputs\\target.bin", + ]); + assert.ok(pending instanceof Promise); + assert.throws( + () => native.call("runtime_close", [runtime.handle]), + /pending/u, + ); + const snapshot = await pending; + assert.equal( + snapshot.sha256, + createHash("sha256") + .update(await readFile(source)) + .digest("hex"), + ); + assert.equal(snapshot.source.requestedPath, source); + await assert.rejects( + writeFile(join(runtime.path, "inputs", "target.bin"), "replace"), + ); + await symlink( + sourceDirectory, + join(runtime.path, "outside-junction"), + "junction", + ); + const cancelled = native.call("runtime_snapshot", [ + runtime.handle, + source, + "cancelled.bin", + ]); + native.call("runtime_snapshot_cancel", [runtime.handle]); + await assert.rejects(cancelled, /cancelled/u); + report.controls = { + ...report.controls, + protectedDaclReadback: true, + sourceReplacementDenied: true, + sourceWriteDenied: true, + unrelatedFilesystemRenamePreserved: true, + junctionAdmissionDenied: true, + alternateStreamsDenied: true, + forgedAndClosedHandlesDenied: true, + immutableSnapshotDigest: true, + asynchronousSnapshot: true, + snapshotCancellation: true, + }; + } finally { + native.call("runtime_close", [runtime.handle]); + } + assert.equal(await exists(runtime.path), false); + assert.equal(await readFile(sentinel, "utf8"), "preserve"); + report.controls.cleanupDoesNotTraverseJunctions = true; + const fixture = join(workspace, "tree.mjs"); + await writeFile( + fixture, + `import {spawn} from 'node:child_process'; import {writeFileSync} from 'node:fs'; +const depth=Number(process.argv[2]); writeFileSync(process.argv[3]+depth,String(process.pid)); +if(depth>0)spawn(process.execPath,[import.meta.filename,String(depth-1),process.argv[3]],{detached:true,stdio:'ignore'}); +setInterval(()=>{},1000);\n`, + ); + const pidPrefix = join(workspace, "pid-"); + const child = launch(["--max-old-space-size=128", fixture, "2", pidPrefix]); + const unrelated = spawn( + process.execPath, + ["--max-old-space-size=128", "-e", "setInterval(()=>{},1000)"], + { stdio: "ignore" }, + ); + try { + for ( + let attempt = 0; + attempt < 200 && !(await exists(pidPrefix + "0")); + attempt++ + ) + await delay(); + const pids = await Promise.all( + [0, 1, 2].map(async (depth) => + Number(await readFile(pidPrefix + depth, "utf8")), + ), + ); + assert.ok(native.call("process_poll", [child.handle]).activeProcesses >= 3); + native.call("process_terminate", [child.handle]); + assert.equal((await wait(child)).activeProcesses, 0); + assert.equal(pids.some(live), false); + assert.ok(unrelated.pid !== undefined && live(unrelated.pid)); + report.processes.descendantsAndDetachedDescendantsTerminated = true; + report.processes.unrelatedProcessPreserved = true; + } finally { + native.call("process_terminate", [child.handle]); + await wait(child); + native.call("process_close", [child.handle]); + if (unrelated.exitCode === null) { + const closed = new Promise((resolve) => unrelated.once("close", resolve)); + unrelated.kill(); + await closed; + } + } + const ownerClose = launch([ + "--max-old-space-size=128", + fixture, + "0", + join(workspace, "owner-close-"), + ]); + native.call("process_close", [ownerClose.handle]); + for (let attempt = 0; attempt < 100 && live(ownerClose.pid); attempt++) + await delay(); + assert.equal(live(ownerClose.pid), false); + report.processes.killOnOwnerClose = true; + const exited = launch([ + "--max-old-space-size=128", + "-e", + "console.log('observed');process.exit(7)", + ]); + const settled = await wait(exited); + assert.equal(settled.exitCode, 7); + assert.match(settled.output, /observed/u); + native.call("process_close", [exited.handle]); + report.processes.normalExitAndOutput = true; + const boundaryFixture = process.argv[3]; + if (boundaryFixture !== undefined) { + const before = JSON.parse( + execFileSync(boundaryFixture, [], { encoding: "utf8" }), + ); + const boundary = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} breakaway`, + workspace, + environment, + ]); + const denied = await wait(boundary); + native.call("process_close", [boundary.handle]); + assert.equal(JSON.parse(denied.output).breakawayDenied, true); + const tokenChild = native.call("process_spawn", [ + boundaryFixture, + quote(boundaryFixture), + workspace, + environment, + ]); + const observed = await wait(tokenChild); + native.call("process_close", [tokenChild.handle]); + assert.equal(JSON.parse(observed.output).defaultOwnerIsUser, true); + const after = JSON.parse( + execFileSync(boundaryFixture, [], { encoding: "utf8" }), + ); + assert.deepEqual(after, before); + report.processes.breakawayDenied = true; + report.processes.childOwnerNormalizedWithoutChangingCaller = true; + report.callerToken = before; + const environmentChild = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} environment`, + workspace, + [ + "z_REA=z", + "_REA=underscore", + "A_REA=a", + "REA_CASE=first", + "rea_case=last", + "REA_ß=sharp", + "REA_SS=double", + ], + ]); + const childEnvironment = await wait(environmentChild); + native.call("process_close", [environmentChild.handle]); + assert.deepEqual(JSON.parse(childEnvironment.output), { + ordinallySorted: true, + lastDuplicateSelected: true, + unicodeNamesPreserved: true, + }); + report.processes.windowsEnvironmentSemantics = true; + } + const ownerFixture = join(workspace, "owner.mjs"); + const ownerPidFile = join(workspace, "crash-child.pid"); + const moduleUrl = pathToFileURL( + join(packageRoot, "dist/windows/WindowsNativeLoader.js"), + ).href; + await writeFile( + ownerFixture, + `import {writeFileSync} from 'node:fs'; +import {requireWindowsNativeAuthority} from ${JSON.stringify(moduleUrl)}; +const native=requireWindowsNativeAuthority(); +const env=Object.entries(process.env).map(([k,v])=>k+'='+v); +const child=native.call('process_spawn',[process.execPath,${JSON.stringify([process.execPath, "--max-old-space-size=128", fixture, "0", join(workspace, "crash-descendant-")].map(quote).join(" "))},${JSON.stringify(workspace)},env]); +writeFileSync(${JSON.stringify(ownerPidFile)},String(child.pid));setInterval(()=>{},1000);\n`, + ); + const owner = spawn( + process.execPath, + ["--max-old-space-size=128", ownerFixture], + { stdio: "ignore" }, + ); + try { + for ( + let attempt = 0; + attempt < 200 && !(await exists(ownerPidFile)); + attempt++ + ) + await delay(); + const pid = Number(await readFile(ownerPidFile, "utf8")); + const closed = new Promise((resolve) => owner.once("close", resolve)); + owner.kill("SIGKILL"); + await closed; + for (let attempt = 0; attempt < 100 && live(pid); attempt++) await delay(); + assert.equal(live(pid), false); + report.processes.ownerForcedExitKillsOwnedChild = true; + } finally { + if (owner.exitCode === null && owner.signalCode === null) { + const closed = new Promise((resolve) => owner.once("close", resolve)); + owner.kill("SIGKILL"); + await closed; + } + } + report.ok = true; + if (process.argv[4] !== undefined) + await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); + process.stdout.write(`${JSON.stringify(report)}\n`); +} catch (cause) { + console.error( + "Native conformance failed", + cause.code ?? cause.name, + cause.constraint ?? "assertion", + ); + throw cause; +} finally { + await rm(workspace, { recursive: true, force: true }).catch((cause) => + console.error("Fixture cleanup failed", cause.code), + ); +} diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index 5414483a3..52361cad3 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -49,6 +49,8 @@ try { const packagedPaths = new Set(packageResult.files.map(({ path }) => path)); for (const required of [ "bridge/ghidra/ReaGhidraBridge.java", + "native/windows/build/manifest.json", + "native/windows/build/rea-windows-x64.node", "dist/main.js", "dist/cli.js", "scripts/rea.mjs", diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index c33d67fc2..76a43820b 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -83,7 +83,7 @@ export const parseBinaryTarget = async ( ) return ok({ path, - sourcePath: canonical, + sourcePath: process.platform === "win32" ? candidate : canonical, sha256: await sha256Handle(handle), kind: "database", format: "analysis-database", @@ -92,7 +92,7 @@ export const parseBinaryTarget = async ( if (artifactFormat !== undefined) { const identity = { path, - sourcePath: canonical, + sourcePath: process.platform === "win32" ? candidate : canonical, sha256: await sha256Handle(handle), }; return isArchiveFormat(artifactFormat) @@ -103,7 +103,7 @@ export const parseBinaryTarget = async ( if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); return ok({ path, - sourcePath: canonical, + sourcePath: process.platform === "win32" ? candidate : canonical, sha256: await sha256Handle(handle), kind: "executable", ...detected.value, diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 3d474eeae..b1864fc7a 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -63,7 +63,8 @@ export interface DoctorProviderInspection { readonly configured: boolean; readonly available: boolean; readonly providerVersion: string | null; - /** Exact non-secret variables safe to persist in an approved registration. */ + /** Adapter-validated non-secret settings, independent of runtime readiness. + * Invalid installations publish no settings. */ readonly registrationEnvironment: Readonly>; readonly checks: readonly DoctorProviderCheck[]; } diff --git a/src/application/SetupRegistrationEnvironment.test.ts b/src/application/SetupRegistrationEnvironment.test.ts new file mode 100644 index 000000000..295794c76 --- /dev/null +++ b/src/application/SetupRegistrationEnvironment.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import type { DoctorProviderInspection } from "./Doctor.js"; +import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js"; + +const inspection = ( + overrides: Partial = {}, +): DoctorProviderInspection => ({ + id: "fixture", + configured: true, + available: false, + providerVersion: "1", + registrationEnvironment: { + REA_FIXTURE_PROVIDER_ROOT: "C:\\Tools\\Provider", + }, + checks: [], + ...overrides, +}); + +describe("provider registration settings", () => { + it("retains valid published paths when runtime capabilities are unavailable", () => { + expect(providerRegistrationEnvironment([inspection()])).toEqual({ + REA_FIXTURE_PROVIDER_ROOT: "C:\\Tools\\Provider", + }); + }); + + it("does not invent settings when the adapter cannot publish an installation", () => { + expect( + providerRegistrationEnvironment([ + inspection({ configured: false, registrationEnvironment: {} }), + ]), + ).toEqual({}); + }); + + it("keeps available-provider settings unchanged", () => { + expect( + providerRegistrationEnvironment([inspection({ available: true })]), + ).toEqual({ REA_FIXTURE_PROVIDER_ROOT: "C:\\Tools\\Provider" }); + }); +}); diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 147d08702..7a46f56e1 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -1,12 +1,11 @@ import type { DoctorProviderInspection } from "./Doctor.js"; -/** Select exact non-secret settings published by available providers. */ +/** Preserve adapter-published settings independently of runtime readiness. */ export const providerRegistrationEnvironment = ( inspections: readonly DoctorProviderInspection[], ): Readonly> => Object.fromEntries( inspections - .filter(({ available }) => available) .flatMap(({ registrationEnvironment }) => Object.entries(registrationEnvironment), ) diff --git a/src/application/WindowsCapabilities.ts b/src/application/WindowsCapabilities.ts index df7368c9e..3e95bed1d 100644 --- a/src/application/WindowsCapabilities.ts +++ b/src/application/WindowsCapabilities.ts @@ -26,6 +26,7 @@ export interface WindowsCapabilityDependencies { probePrivateAcl(): WindowsCapabilityOutcome; probeUnixDomainSocket(): WindowsCapabilityOutcome; probePty(): Promise; + nativeCapabilities?(): WindowsNativeCapabilitySet; } /** Machine-readable facts for features that need Windows-specific security. */ @@ -63,7 +64,9 @@ export const probeWindowsCapabilities = async ( ? { available: true, reason: null } : { available: false, reason: pty.reason }, }, - security: windowsNativeCapabilities(dependencies.platform), + security: + dependencies.nativeCapabilities?.() ?? + windowsNativeCapabilities(dependencies.platform), }; }; @@ -73,10 +76,9 @@ const systemDependencies = (): WindowsCapabilityDependencies => ({ probeSymlinkCreation, probeNoFollowOpen: () => systemNoFollowOpenCapability(process.platform, constants.O_NOFOLLOW), - probePrivateAcl: () => ({ - available: false, - reason: "Windows ACL enforcement is not implemented by this REA build", - }), + probePrivateAcl: () => + windowsNativeCapabilities(process.platform).private_runtime_dacl, + nativeCapabilities: () => windowsNativeCapabilities(process.platform), probeUnixDomainSocket: () => process.platform === "win32" ? { @@ -95,7 +97,8 @@ export const systemNoFollowOpenCapability = ( if (platform === "win32") return { available: false, - reason: "Windows reparse-safe handle admission is not implemented", + reason: + "Node pathname O_NOFOLLOW is not Windows handle authority; native admission is reported separately in security.", }; return typeof noFollow === "number" ? { available: true, reason: null } diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index b838b7a92..edbd9018b 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -62,7 +62,7 @@ export type GhidraOperation = /** Owns one authenticated, private, read-only Ghidra headless session. */ export class GhidraClient { readonly #options: Required< - Pick + Pick > & GhidraClientOptions; readonly #logger: Logger; @@ -76,6 +76,7 @@ export class GhidraClient { #process: ProviderProcessSupervisor | undefined; #runtimeRoot: PrivateRuntimeRoot | undefined; #snapshotPath: string | undefined; + #targetAdmission: JsonValue | undefined; #token: string | undefined; // Retain authentication identities for diagnostics from late request settlement. readonly #authenticationTokens = new Set(); @@ -120,6 +121,7 @@ export class GhidraClient { this.#options = { ...options, startupTimeoutMs: options.startupTimeoutMs ?? GHIDRA_STARTUP_TIMEOUT_MS, + platform: options.platform ?? process.platform, transport: options.transport ?? "unix-socket", }; this.#logger = options.logger ?? silentLogger; @@ -281,6 +283,7 @@ export class GhidraClient { this.#runtimeRoot = await PrivateRuntimeRoot.create({ parent: SESSION_ROOT, prefix: "rea-ghidra-", + platform: this.#options.platform, }); } catch (cause: unknown) { return err( @@ -302,9 +305,12 @@ export class GhidraClient { this.#options.targetPath, this.#runtimeRoot.path, this.#options.targetSha256, + { signal: deadline.signal, platform: this.#options.platform }, ); this.#snapshotPath = snapshot.path; + this.#targetAdmission = snapshot.admission; } catch (cause: unknown) { + if (deadline.signal.aborted) return this.#startupInterrupted(deadline); const failure = this.#failure( "start", "Ghidra target snapshot failed admission", @@ -403,6 +409,9 @@ export class GhidraClient { providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, targetSha256: this.#options.targetSha256, + ...(this.#targetAdmission === undefined + ? {} + : { targetAdmission: this.#targetAdmission }), ...(this.#options.expectedLanguageId === undefined ? {} : { expectedLanguageId: this.#options.expectedLanguageId }), @@ -583,6 +592,9 @@ export class GhidraClient { return createGhidraDiagnostics({ targetPath: this.#options.targetPath, targetSha256: this.#options.targetSha256, + ...(this.#targetAdmission === undefined + ? {} + : { targetAdmission: this.#targetAdmission }), transport: this.#options.transport, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, diff --git a/src/ghidra/GhidraClientTypes.ts b/src/ghidra/GhidraClientTypes.ts index a843b6488..c5ad6c041 100644 --- a/src/ghidra/GhidraClientTypes.ts +++ b/src/ghidra/GhidraClientTypes.ts @@ -36,6 +36,8 @@ export interface GhidraClientOptions { readonly runId?: string; readonly targetPath: string; readonly targetSha256: string; + /** Installation host shared by runtime allocation and snapshot admission. */ + readonly platform?: NodeJS.Platform; readonly transport?: GhidraTransportKind; readonly providerVersion: string; readonly profileDigest: string; diff --git a/src/ghidra/GhidraDiagnostics.ts b/src/ghidra/GhidraDiagnostics.ts index eebc79bd1..059738ceb 100644 --- a/src/ghidra/GhidraDiagnostics.ts +++ b/src/ghidra/GhidraDiagnostics.ts @@ -9,6 +9,7 @@ import type { GhidraTransportKind } from "./GhidraTransport.js"; export interface GhidraDiagnosticsOptions { readonly targetPath: string; readonly targetSha256: string; + readonly targetAdmission?: JsonValue; readonly transport: GhidraTransportKind; readonly providerVersion: string; readonly profileDigest: string; @@ -37,6 +38,9 @@ export const createGhidraDiagnostics = ( return { target_path: options.targetPath, target_sha256: options.targetSha256, + ...(options.targetAdmission === undefined + ? {} + : { target_admission: options.targetAdmission }), transport: options.transport, provider_version: options.providerVersion, profile_digest: options.profileDigest, diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 5d6a1fc1c..b7c55e8ac 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { runDoctor, type DoctorHost } from "../application/Doctor.js"; import { createDoctorHostFixture } from "../application/Doctor.fixture.js"; +import { providerRegistrationEnvironment } from "../application/SetupRegistrationEnvironment.js"; +import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js"; import { inspectGhidraInstallation, type GhidraInstallationHost, @@ -99,28 +101,6 @@ describe("Ghidra doctor integration", () => { }); }); - it("accepts the experimental Windows x64 Ghidra provider boundary", async () => { - const ghidra = inspection({ - installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC", - platform: "win32", - }); - const result = await runDoctor(undefined, { - ...doctorHost(ghidra), - platform: "win32", - architecture: "x64", - linuxDistribution: () => Promise.resolve(undefined), - }); - - expect(result.healthy).toBe(true); - expect(result.checks.find(({ name }) => name === "host")).toMatchObject({ - ok: true, - detail: "win32 x64", - }); - expect(result.checks.find(({ name }) => name === "ghidra")).toMatchObject({ - ok: true, - }); - }); - it.each([ [ "bad installation", @@ -181,3 +161,95 @@ describe("Ghidra doctor integration", () => { }); }); }); + +describe("Windows Ghidra doctor authority", () => { + it("distinguishes a valid Windows installation from unavailable native authority", async () => { + const ghidra = inspection({ + installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC", + platform: "win32", + }); + const result = await runDoctor(undefined, { + ...doctorHost(ghidra), + platform: "win32", + architecture: "x64", + linuxDistribution: () => Promise.resolve(undefined), + }); + + expect(result.healthy).toBe(false); + expect(result.providerInspections?.[0]).toMatchObject({ + available: false, + providerVersion: "12.1.4", + registrationEnvironment: { + GHIDRA_INSTALL_DIR: "C:\\tools\\ghidra_12.1.4_PUBLIC", + }, + }); + expect( + providerRegistrationEnvironment(result.providerInspections ?? []), + ).toMatchObject({ + GHIDRA_INSTALL_DIR: "C:\\tools\\ghidra_12.1.4_PUBLIC", + }); + expect(result.checks.find(({ name }) => name === "host")).toMatchObject({ + ok: true, + detail: "win32 x64", + }); + expect(result.checks.find(({ name }) => name === "ghidra")).toMatchObject({ + ok: true, + }); + expect( + result.checks.find(({ name }) => name === "ghidra-java"), + ).toMatchObject({ ok: true }); + expect( + result.checks.find(({ name }) => name === "ghidra-native_authority"), + ).toMatchObject({ + ok: false, + classification: "unsupported_host", + detail: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + remediation: expect.stringContaining("cannot enable"), + }); + }); + + it("fails provider-scoped Windows readiness without blaming installation", async () => { + const ghidra = inspection({ + installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC", + platform: "win32", + }); + const result = await runDoctor( + undefined, + { + ...doctorHost(ghidra), + platform: "win32", + linuxDistribution: () => Promise.resolve(undefined), + }, + { providers: ["ghidra"] }, + ); + expect(result.healthy).toBe(false); + expect(result.scope_checks).toContainEqual( + expect.objectContaining({ + name: "ghidra-native_authority", + classification: "unsupported_host", + detail: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + }), + ); + }); + + it("keeps missing Windows Java actionable as a dependency failure", () => { + const projected = projectGhidraDoctorInspection( + inspection( + { + installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC", + platform: "win32", + }, + { probeJava: () => undefined }, + ), + ); + expect(projected.available).toBe(false); + expect(projected.checks).toContainEqual( + expect.objectContaining({ + name: "java", + ok: false, + classification: "missing_dependency", + remediation: expect.any(String), + }), + ); + }); +}); diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts index 1fe173811..7e8351cc5 100644 --- a/src/ghidra/GhidraDoctor.ts +++ b/src/ghidra/GhidraDoctor.ts @@ -2,31 +2,60 @@ import type { DoctorProviderCheck, DoctorProviderInspection, } from "../application/Doctor.js"; +import { + hasWindowsNativeAuthority, + windowsNativeAuthorityUnavailableReason, +} from "../process/WindowsAuthority.js"; import { inspectGhidraInstallation, type GhidraInstallationCheck, type GhidraInstallationInspection, } from "./GhidraInstallation.js"; -/** Project adapter-owned installation facts into the generic doctor contract. */ +/** Project installation and runtime-authority facts into the doctor contract. */ export const projectGhidraDoctorInspection = ( inspection: GhidraInstallationInspection, -): DoctorProviderInspection => ({ - id: "ghidra", - configured: inspection.installDir !== null, - available: inspection.status === "available", - providerVersion: inspection.providerVersion, - registrationEnvironment: - inspection.status === "available" - ? { - GHIDRA_INSTALL_DIR: inspection.installDir, - JAVA_HOME: inspection.javaHome, - } - : {}, - checks: selectedChecks(inspection).map((check) => - projectCheck(check, inspection.installDir !== null), - ), -}); +): DoctorProviderInspection => { + const windowsAuthorityUnavailable = + inspection.status === "available" && + inspection.platform === "win32" && + !hasWindowsNativeAuthority(inspection.platform); + return { + id: "ghidra", + configured: inspection.installDir !== null, + available: + inspection.status === "available" && !windowsAuthorityUnavailable, + providerVersion: inspection.providerVersion, + registrationEnvironment: + inspection.status === "available" + ? { + GHIDRA_INSTALL_DIR: inspection.installDir, + JAVA_HOME: inspection.javaHome, + } + : {}, + checks: [ + ...selectedChecks(inspection).map((check) => + projectCheck(check, inspection.installDir !== null), + ), + ...(inspection.status === "available" && inspection.platform === "win32" + ? [ + { + name: "native_authority", + ok: !windowsAuthorityUnavailable, + code: windowsAuthorityUnavailable ? "unsupported_host" : null, + detail: windowsAuthorityUnavailable + ? windowsNativeAuthorityUnavailableReason(inspection.platform) + : "Verified Windows native Job Object, private DACL, and reparse-safe handle controls; local NTFS only.", + remediation: windowsAuthorityUnavailable + ? "Use the matching Windows x64 REA package with its bundled native addon and a writable local NTFS temporary directory. The native failure above identifies the failed constraint; Ghidra installation or registration changes cannot enable a missing native backend." + : null, + classification: "unsupported_host" as const, + }, + ] + : []), + ], + }; +}; /** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */ export const inspectSystemGhidraProvider = diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 200ad7172..59e1fef5c 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -487,7 +487,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ probeJava(command, environment) { const observed = spawnSync( command, - ["-XshowSettings:properties", "-version"], + ["-XX:-UsePerfData", "-XshowSettings:properties", "-version"], { encoding: "utf8", env: environment, diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 472257b93..a5fda7063 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -3,6 +3,8 @@ import { basename, dirname, join, win32 } from "node:path"; import writeFileAtomic from "write-file-atomic"; +import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; + import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -80,17 +82,8 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; try { - await Promise.all( - [ - paths.projectRoot, - paths.homeRoot, - paths.tempRoot, - paths.cacheRoot, - paths.configRoot, - paths.dataRoot, - ].map((path) => mkdir(path, { recursive: true, mode: 0o700 })), - ); - await writeFileAtomic( + await createGhidraRuntimeDirectories(paths, platform); + await writeGhidraRuntimeFile( paths.descriptorPath, `${JSON.stringify({ transport: session.transport, @@ -101,7 +94,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { provider_version: session.providerVersion, profile_digest: session.profileDigest, })}\n`, - { encoding: "utf8", mode: 0o600 }, + platform, ); if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); @@ -138,7 +131,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, env: ghidraLaunchEnvironment(paths, this.options.javaHome, platform), }); - await writeFileAtomic( + await writeGhidraRuntimeFile( paths.ownershipPath, `${JSON.stringify({ run_id: session.runId, @@ -146,13 +139,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { process_group_id: started.ownership.processGroupId, parent_pid: process.pid, ownership_kind: - platform === "win32" - ? "windows-process-tree-p0" - : "posix-process-group", + platform === "win32" ? "windows-job-object" : "posix-process-group", launcher: this.options.analyzeHeadlessPath, created_at: new Date().toISOString(), })}\n`, - { encoding: "utf8", mode: 0o600 }, + platform, ); if (isAborted(options.signal)) { await cleanupStartedProcess(started, platform); @@ -258,9 +249,11 @@ const cleanupStartedProcess = ( started: SpawnedOwnedProviderProcess, platform: NodeJS.Platform, ): Promise => - platform === "win32" - ? cleanupWindowsProcessTree(started.ownership.leaderPid) - : cleanupOwnedProcessGroup(started.ownership); + started.cleanup !== undefined + ? started.cleanup() + : platform === "win32" + ? cleanupWindowsProcessTree(started.ownership.leaderPid) + : cleanupOwnedProcessGroup(started.ownership); /** Paths encoded into one bounded analyzeHeadless invocation. */ export interface GhidraHeadlessArgumentOptions { @@ -332,22 +325,62 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({ scriptLogPath: join(runtimeRoot, "script.log"), }); +const createGhidraRuntimeDirectories = async ( + paths: ReturnType, + platform: NodeJS.Platform, +): Promise => { + if (platform === "win32") { + const runtime = windowsPrivateRuntime(dirname(paths.projectRoot)); + for (const path of [ + paths.projectRoot, + paths.homeRoot, + paths.tempRoot, + paths.cacheRoot, + paths.configRoot, + paths.dataRoot, + ]) + runtime.mkdir(basename(path)); + return; + } + await Promise.all( + [ + paths.projectRoot, + paths.homeRoot, + paths.tempRoot, + paths.cacheRoot, + paths.configRoot, + paths.dataRoot, + ].map((path) => mkdir(path, { recursive: true, mode: 0o700 })), + ); +}; + +const writeGhidraRuntimeFile = async ( + path: string, + content: string, + platform: NodeJS.Platform, +): Promise => { + if (platform === "win32") { + windowsPrivateRuntime(dirname(path)).writeFile(basename(path), content); + return; + } + await writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 }); +}; + const ghidraLaunchEnvironment = ( paths: ReturnType, javaHome: string | undefined, platform: NodeJS.Platform, ): NodeJS.ProcessEnv => { - // analyzeHeadless.bat wraps GHIDRA_HEADLESS_JAVA_OPTIONS in its own quotes - // when it builds VMARG_LIST. Nested quotes break cmd.exe tokenization before - // Ghidra starts, so keep this option list unquoted on every platform. - const javaOptions = `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`; return { ...ghidraJavaEnvironment(javaHome, process.env, platform), + ...ghidraHeadlessJavaOptions(paths.homeRoot, paths.tempRoot, platform), HOME: paths.homeRoot, TMPDIR: paths.tempRoot, ...(platform === "win32" ? { USERPROFILE: paths.homeRoot, + APPDATA: paths.configRoot, + LOCALAPPDATA: paths.cacheRoot, TEMP: paths.tempRoot, TMP: paths.tempRoot, } @@ -355,8 +388,33 @@ const ghidraLaunchEnvironment = ( XDG_CACHE_HOME: paths.cacheRoot, XDG_CONFIG_HOME: paths.configRoot, XDG_DATA_HOME: paths.dataRoot, - GHIDRA_HEADLESS_JAVA_OPTIONS: javaOptions, }; }; +/** Encode provider-owned JVM paths for the parser that actually consumes them. */ +export const ghidraHeadlessJavaOptions = ( + homeRoot: string, + tempRoot: string, + platform: NodeJS.Platform, +): Pick< + NodeJS.ProcessEnv, + "JDK_JAVA_OPTIONS" | "GHIDRA_HEADLESS_JAVA_OPTIONS" +> => { + const options = [`-Duser.home=${homeRoot}`, `-Djava.io.tmpdir=${tempRoot}`]; + // analyzeHeadless.bat quotes VMARG_LIST, then launch.bat expands it again. + // Inner quotes fail in cmd.exe; unquoted paths with spaces split at the JVM. + // JDK_JAVA_OPTIONS is parsed by Java itself, outside that batch expansion. + return platform === "win32" + ? { + JDK_JAVA_OPTIONS: [...options, "-XX:-UsePerfData"] + .map(quoteWindowsBatchToken) + .join(" "), + GHIDRA_HEADLESS_JAVA_OPTIONS: "", + } + : { + JDK_JAVA_OPTIONS: "", + GHIDRA_HEADLESS_JAVA_OPTIONS: options.join(" "), + }; +}; + const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/ghidra/GhidraLauncherEnvironment.test.ts b/src/ghidra/GhidraLauncherEnvironment.test.ts new file mode 100644 index 000000000..a66037515 --- /dev/null +++ b/src/ghidra/GhidraLauncherEnvironment.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { ghidraHeadlessJavaOptions } from "./GhidraLauncher.js"; + +describe("Ghidra headless JVM environment", () => { + it("passes Windows paths with spaces to Java's environment parser", () => { + expect( + ghidraHeadlessJavaOptions( + "C:\\REA Runtime\\home", + "C:\\REA Runtime\\tmp", + "win32", + ), + ).toEqual({ + JDK_JAVA_OPTIONS: + '"-Duser.home=C:\\REA Runtime\\home" "-Djava.io.tmpdir=C:\\REA Runtime\\tmp" "-XX:-UsePerfData"', + GHIDRA_HEADLESS_JAVA_OPTIONS: "", + }); + }); + + it.each(["linux", "darwin"] as const)( + "keeps the %s headless-script option list and clears inherited JDK options", + (platform) => { + expect( + ghidraHeadlessJavaOptions("/tmp/rea/home", "/tmp/rea/tmp", platform), + ).toEqual({ + JDK_JAVA_OPTIONS: "", + GHIDRA_HEADLESS_JAVA_OPTIONS: + "-Duser.home=/tmp/rea/home -Djava.io.tmpdir=/tmp/rea/tmp", + }); + }, + ); + + it.each(["%TEMP%", 'quote"', "amp&", "line\n", "nul\0"])( + "rejects interpreter metacharacters in either Windows JVM path: %s", + (suffix) => { + for (const [home, temp] of [ + [`C:\\runtime\\${suffix}`, "C:\\runtime\\tmp"], + ["C:\\runtime\\home", `C:\\runtime\\${suffix}`], + ] as const) + expect(() => ghidraHeadlessJavaOptions(home, temp, "win32")).toThrow( + /metacharacters/u, + ); + }, + ); +}); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 2cd7fcfcb..f49e7fdf6 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -224,7 +224,9 @@ describe("Ghidra platform support", () => { expect(ghidra.inspectAvailability()).toMatchObject({ status: "unavailable", code: "unsupported_host", - reason: expect.stringContaining("native authority is unavailable"), + reason: expect.stringContaining( + "Windows native controls are unavailable", + ), diagnostics: { windows_security: { job_object_process_ownership: expect.objectContaining({ @@ -249,8 +251,12 @@ describe("Ghidra platform support", () => { available: false, availabilityCode: "unsupported_host", limitations: expect.arrayContaining([ - expect.stringContaining("bounded taskkill cleanup"), - expect.stringContaining("private DACL enforcement"), + expect.stringContaining( + "matching packaged Windows x64 native addon", + ), + expect.stringContaining( + "private DACLs, and Job Object ownership automatically", + ), ]), }), ]), @@ -354,6 +360,7 @@ describe("Ghidra client projection", () => { expect(factoryOptions).toEqual([ expect.objectContaining({ runId: "11111111-1111-4111-8111-111111111111", + platform: "linux", }), ]); expect(toolCalls).toEqual([ diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 34c1a32c0..c980319e5 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -58,7 +58,7 @@ import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; import { CAPABILITIES, - WINDOWS_P0_CAPABILITIES, + windowsP0Capabilities, GHIDRA_PROVIDER_IDENTITY, GHIDRA_PROVIDER_TOOL_CONTRACTS, healthLimitations, @@ -66,7 +66,7 @@ import { limitationsFor, } from "./GhidraProviderCapabilities.js"; import { - WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + windowsNativeAuthorityUnavailableReason, hasWindowsNativeAuthority, windowsNativeCapabilities, } from "../process/WindowsAuthority.js"; @@ -99,7 +99,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { capabilities(): readonly CapabilityDescriptor[] { return (this.installationHost?.platform ?? process.platform) === "win32" - ? WINDOWS_P0_CAPABILITIES + ? windowsP0Capabilities() : CAPABILITIES; } @@ -114,7 +114,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { return { status: "unavailable", code: "unsupported_host", - reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + reason: windowsNativeAuthorityUnavailableReason(installation.platform), diagnostics: { ...diagnostics, windows_security: jsonObjectSchema.parse( @@ -213,6 +213,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { ] : []; const client = this.clientFactory({ + platform: installation.platform, launcher: new GhidraHeadlessLauncher({ analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, ...(this.config.ghidraJavaHome === undefined @@ -225,7 +226,10 @@ export class GhidraProvider implements AnalysisProviderCandidate { ...(target.format === "dos-com" ? { dosCom: true } : {}), platform: installation.platform, }), - targetPath: target.path, + targetPath: + installation.platform === "win32" + ? (target.sourcePath ?? target.path) + : target.path, targetSha256: target.sha256, transport: installation.platform === "win32" @@ -450,7 +454,7 @@ const ghidraClientPrerequisites = ( new AnalysisCapabilityUnavailableError( "ghidra", "health", - WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + windowsNativeAuthorityUnavailableReason(installation.platform), ), ); if (profile === undefined || profile.provider.id !== "ghidra") diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index ecdba0f4a..be9ee8acf 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -2,7 +2,10 @@ import type { CapabilityDescriptor, ProviderIdentity, } from "../application/AnalysisProvider.js"; -import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js"; +import { + hasWindowsNativeAuthority, + windowsNativeAuthorityUnavailableReason, +} from "../process/WindowsAuthority.js"; import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js"; import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js"; import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js"; @@ -44,7 +47,7 @@ export const healthLimitations = Object.freeze([ export const windowsP0Limitations = Object.freeze([ "Windows Ghidra P0 accepts approved native x86-64 PE applications only; DLL, managed, hostile, sensitive, and mutable-path targets are unsupported.", "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", - "Windows P0 is not admitted until a native authority proves Job Object ownership, private DACL enforcement, and reparse-safe path admission; bounded taskkill cleanup and chmod(0700) do not prove those controls.", + "Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.", ]); /** Limitation text for one admitted operation, including the common base. */ @@ -172,18 +175,23 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( ); /** Capabilities advertised for the experimental Windows x64 P0 boundary. */ -export const WINDOWS_P0_CAPABILITIES: readonly CapabilityDescriptor[] = - Object.freeze( - CAPABILITIES.map((capability) => - Object.freeze({ - ...capability, - available: false, - reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, - availabilityCode: "unsupported_host" as const, - limitations: Object.freeze([ - ...capability.limitations, - ...windowsP0Limitations, - ]), - }), - ), +export const windowsP0Capabilities = (): readonly CapabilityDescriptor[] => { + const available = hasWindowsNativeAuthority("win32"); + return Object.freeze( + CAPABILITIES.map((capability): CapabilityDescriptor => { + const limitations = Object.freeze([ + ...capability.limitations, + ...windowsP0Limitations, + ]); + return available + ? Object.freeze({ ...capability, limitations }) + : Object.freeze({ + ...capability, + available: false, + reason: windowsNativeAuthorityUnavailableReason("win32"), + availabilityCode: "unsupported_host", + limitations, + }); + }), ); +}; diff --git a/src/ghidra/GhidraTargetSnapshot.ts b/src/ghidra/GhidraTargetSnapshot.ts index ee847cfa4..d30618d0f 100644 --- a/src/ghidra/GhidraTargetSnapshot.ts +++ b/src/ghidra/GhidraTargetSnapshot.ts @@ -3,10 +3,14 @@ import { constants, createReadStream } from "node:fs"; import { chmod, copyFile, rm } from "node:fs/promises"; import { extname, join } from "node:path"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; + /** Immutable target copy admitted to one ephemeral Ghidra import. */ export interface GhidraTargetSnapshot { readonly path: string; readonly sha256: string; + readonly admission?: JsonValue; } /** Copy one target and require its bytes to match the parsed target identity. */ @@ -14,6 +18,10 @@ export const createGhidraTargetSnapshot = async ( sourcePath: string, runtimeRoot: string, expectedSha256: string, + options: { + readonly signal?: AbortSignal; + readonly platform?: NodeJS.Platform; + } = {}, ): Promise => { if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) throw new TypeError("Ghidra target SHA-256 commitment is invalid"); @@ -22,6 +30,22 @@ export const createGhidraTargetSnapshot = async ( runtimeRoot, `target-${expectedSha256.slice(0, 12)}${suffix}`, ); + if ((options.platform ?? process.platform) === "win32") { + const observed = await windowsPrivateRuntime(runtimeRoot).snapshot( + sourcePath, + `target-${expectedSha256.slice(0, 12)}${suffix}`, + options.signal, + ); + if (observed.sha256 !== expectedSha256) + throw new Error( + `Ghidra target snapshot digest mismatch: expected ${expectedSha256}, observed ${observed.sha256}`, + ); + return { + path: snapshotPath, + sha256: observed.sha256, + admission: { source: observed.source, snapshot: observed.snapshot }, + }; + } try { await copyFile(sourcePath, snapshotPath, constants.COPYFILE_EXCL); await chmod(snapshotPath, 0o600); diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index c98f6c85c..7cb24146d 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -1,5 +1,7 @@ import { access, open } from "node:fs/promises"; +import { readWindowsPrivateRuntimeFile } from "../windows/WindowsPrivateRuntime.js"; + import { err, ok, type Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; @@ -37,6 +39,24 @@ export const observeGhidraEndpoint = async ( ); } } + if (process.platform === "win32") { + try { + return parseTcpEndpoint( + endpoint, + readWindowsPrivateRuntimeFile(endpoint.path), + ); + } catch (cause: unknown) { + return isMissing(cause) || isWindowsEndpointWriterBusy(cause) + ? ok(null) + : err( + endpointFailure( + endpoint, + "Ghidra endpoint observation failed", + cause, + ), + ); + } + } let handle; try { handle = await open(endpoint.path, "r"); @@ -120,3 +140,6 @@ const endpointFailure = ( const isMissing = (cause: unknown): boolean => cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +const isWindowsEndpointWriterBusy = (cause: unknown): boolean => + cause instanceof Error && "win32Code" in cause && cause.win32Code === 32; diff --git a/src/process/PrivateRuntimeRoot.ts b/src/process/PrivateRuntimeRoot.ts index 541d38367..40d4414b7 100644 --- a/src/process/PrivateRuntimeRoot.ts +++ b/src/process/PrivateRuntimeRoot.ts @@ -2,6 +2,8 @@ import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; + import { windowsNativeCapabilities, type WindowsNativeCapability, @@ -43,7 +45,7 @@ export class PrivateRuntimeRootUnavailableError extends Error { } /** - * Owns one POSIX mode-0700 temporary runtime root and removes it idempotently. + * Owns a private temporary runtime root and removes it idempotently. * * Protocol adapters decide what belongs inside the directory; this primitive * establishes a private filesystem boundary on POSIX and deterministic cleanup. @@ -52,7 +54,10 @@ export class PrivateRuntimeRootUnavailableError extends Error { export class PrivateRuntimeRoot { #closePromise: Promise | undefined; - private constructor(readonly path: string) {} + private constructor( + readonly path: string, + private readonly windowsRuntime?: WindowsPrivateRuntime, + ) {} /** Allocate a new private runtime root without performing protocol work. */ static async create( @@ -62,6 +67,13 @@ export class PrivateRuntimeRoot { const capability = privateRuntimeRootCapability(platform); if (!capability.available) throw new PrivateRuntimeRootUnavailableError(capability.reason); + if (platform === "win32") { + const runtime = WindowsPrivateRuntime.create( + options.parent ?? tmpdir(), + options.prefix ?? "rea-provider-", + ); + return new PrivateRuntimeRoot(runtime.observation.path, runtime); + } const path = await mkdtemp( join(options.parent ?? tmpdir(), options.prefix ?? "rea-provider-"), ); @@ -76,7 +88,10 @@ export class PrivateRuntimeRoot { /** Remove the runtime root; concurrent and repeated callers share cleanup. */ close(): Promise { - this.#closePromise ??= rm(this.path, { recursive: true, force: true }); + this.#closePromise ??= + this.windowsRuntime === undefined + ? rm(this.path, { recursive: true, force: true }) + : Promise.resolve().then(() => this.windowsRuntime?.close()); return this.#closePromise; } } diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 743b2512a..04a0955a1 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -1,6 +1,9 @@ import { spawn, type ChildProcess } from "node:child_process"; +import type { EventEmitter } from "node:events"; import type { Readable } from "node:stream"; +import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js"; + import type { OwnedProcessGroup, ProcessCleanupResult, @@ -28,20 +31,34 @@ export interface OwnedProviderProcessSpawnOptions { /** Spawned process paired with the identity proof required for group cleanup. */ export interface SpawnedOwnedProviderProcess { - readonly process: ChildProcess; + readonly process: ProviderProcessHandle; readonly ownership: OwnedProcessGroup; + readonly cleanup?: () => Promise; +} + +/** Process events and streams required by provider lifecycle supervision. */ +export interface ProviderProcessHandle extends Pick< + EventEmitter, + "on" | "once" | "off" +> { + readonly pid?: number | undefined; + readonly stdout: Readable | null; + readonly stderr: Readable | null; + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals | number): boolean; } /** Process handle returned by a provider-specific launcher. */ export type ProviderProcessLaunch = | { - readonly process: ChildProcess; + readonly process: ProviderProcessHandle; readonly ownsProcessLifetime: false; readonly ownership?: never; readonly cleanup?: never; } | { - readonly process: ChildProcess; + readonly process: ProviderProcessHandle; readonly ownsProcessLifetime: true; readonly ownership?: OwnedProcessGroup; readonly cleanup?: () => Promise; @@ -102,7 +119,7 @@ export type ProviderProcessStopResult = | { readonly status: "incomplete"; readonly reason: string }; /** - * Spawn a provider in a dedicated POSIX process group with an ownership token. + * Spawn a provider in an owned Windows job or dedicated POSIX process group. * * The caller remains responsible for persisting any ownership manifest and for * selecting provider-specific command arguments or environment values. @@ -112,17 +129,37 @@ export const spawnOwnedProviderProcess = async ( ): Promise => { const platform = options.platform ?? process.platform; const hostEnvironment = options.hostEnvironment ?? process.env; + const environment = { + ...hostEnvironment, + ...options.env, + REA_PROCESS_RUN_ID: options.runId, + }; + if (platform === "win32" && process.platform === "win32") { + const child = new WindowsOwnedProcess( + options.command, + options.arguments, + options.cwd, + environment, + options.windowsVerbatimArguments ?? false, + ); + return { + process: child, + ownership: { + runId: options.runId, + leaderPid: child.pid, + processGroupId: child.pid, + expectedParentPid: process.pid, + }, + cleanup: () => child.cleanup(), + }; + } const child = spawn(options.command, [...options.arguments], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], detached: platform !== "win32", windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, - env: { - ...hostEnvironment, - ...options.env, - REA_PROCESS_RUN_ID: options.runId, - }, + env: environment, ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); await waitForSpawn(child); diff --git a/src/process/WindowsAuthority.ts b/src/process/WindowsAuthority.ts index 971c947c8..ccd041312 100644 --- a/src/process/WindowsAuthority.ts +++ b/src/process/WindowsAuthority.ts @@ -1,8 +1,6 @@ -/** - * Provider-neutral security controls that require a verified Windows native - * authority. Runtime probes and POSIX permissions do not satisfy these - * controls. - */ +import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; + +/** Windows controls are proven by the packaged native boundary, not POSIX flags. */ export type WindowsNativeCapability = | { readonly available: true; @@ -24,7 +22,7 @@ export interface WindowsNativeCapabilitySet { /** Stable explanation used whenever the native Windows authority is absent. */ export const WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON = - "Verified Windows native authority is unavailable: Job Object process ownership, private runtime DACL enforcement, and reparse-safe path admission are not implemented; chmod(0700) is insufficient for Windows private DACL proof, and taskkill is not a Job Object security proof"; + "Windows native controls are unavailable in this runtime; use the Windows x64 REA package with its matching bundled native artifact."; /** * Describe native Windows controls without inferring them from taskkill, @@ -46,6 +44,22 @@ export const windowsNativeCapabilities = ( }; } + if (process.platform === "win32") { + const loaded = systemWindowsNativeAuthority(); + const capability = loaded.available + ? ({ available: true, reason: null, proof: "native-authority" } as const) + : ({ + available: false, + reason: loaded.reason, + proof: "not-proven", + } as const); + return { + job_object_process_ownership: capability, + private_runtime_dacl: capability, + reparse_safe_path_admission: capability, + }; + } + const notProven = { available: false, reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, @@ -63,3 +77,10 @@ export const hasWindowsNativeAuthority = (platform: NodeJS.Platform): boolean => Object.values(windowsNativeCapabilities(platform)).every( ({ available }) => available, ); + +/** Preserve the actual loader or operating-system constraint in provider errors. */ +export const windowsNativeAuthorityUnavailableReason = ( + platform: NodeJS.Platform, +): string => + windowsNativeCapabilities(platform).job_object_process_ownership.reason ?? + WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON; diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts new file mode 100644 index 000000000..1220a2822 --- /dev/null +++ b/src/windows/WindowsNativeLoader.test.ts @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + loadWindowsNativeAuthority, + type WindowsNativeLoadHost, +} from "./WindowsNativeLoader.js"; + +const fixture = () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ"); + bytes.writeUInt32LE(64, 0x3c); + bytes.write("PE\0\0", 64, "ascii"); + bytes.writeUInt16LE(0x8664, 68); + bytes.writeUInt16LE(0x20b, 88); + const manifest = { + packageVersion: "1.0.0", + platform: "win32", + architecture: "x64", + abiVersion: 1, + nodeApiVersion: 8, + artifact: "rea-windows-x64.node", + artifactSha256: createHash("sha256").update(bytes).digest("hex"), + }; + const identity = { + requestedPath: "D:\\fixture\\runtime", + finalPath: "\\\\?\\D:\\fixture\\runtime", + filesystem: "NTFS", + volumeSerial: "0123456789abcdef", + fileId: "0123456789abcdef0123456789abcdef", + size: 0, + directory: true, + }; + let loads = 0; + const host: WindowsNativeLoadHost = { + platform: "win32", + architecture: "x64", + nodeApiVersion: "8", + packageVersion: "1.0.0", + readManifest: () => manifest, + readArtifact: () => bytes, + loadArtifact: () => { + loads++; + return { + call: () => ({ + abiVersion: 1, + nodeApiVersion: 8, + architecture: "x64", + filesystem: identity, + privateDacl: true, + atomicJobAssignment: true, + killOnOwnerClose: true, + }), + }; + }, + }; + return { host, manifest, bytes, loads: () => loads }; +}; + +describe("packaged Windows native admission", () => { + it("loads only after compatibility and digest checks, preserving producer identity", () => { + const value = fixture(); + expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ + available: true, + authority: { + inspection: { filesystem: { requestedPath: "D:\\fixture\\runtime" } }, + }, + }); + expect(value.loads()).toBe(1); + }); + + it.each([ + { platform: "linux" as const }, + { architecture: "arm64" }, + { nodeApiVersion: "7" }, + { nodeApiVersion: undefined }, + { packageVersion: "2.0.0" }, + ])("rejects incompatible host metadata before loading: %j", (override) => { + const value = fixture(); + expect( + loadWindowsNativeAuthority({ ...value.host, ...override }).available, + ).toBe(false); + expect(value.loads()).toBe(0); + }); + + it("rejects tampering even when the original file was a valid PE image", () => { + const value = fixture(); + value.bytes[127] = 1; + expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ + available: false, + reason: expect.stringContaining("SHA-256"), + }); + expect(value.loads()).toBe(0); + }); + + it.each(["abiVersion", "nodeApiVersion", "architecture", "artifact"])( + "rejects invalid manifest %s", + (key) => { + const value = fixture(); + expect( + loadWindowsNativeAuthority({ + ...value.host, + readManifest: () => ({ ...value.manifest, [key]: "invalid" }), + }).available, + ).toBe(false); + expect(value.loads()).toBe(0); + }, + ); + + it("rejects a digest-valid artifact for another machine", () => { + const value = fixture(); + value.bytes.writeUInt16LE(0xaa64, 68); + value.manifest.artifactSha256 = createHash("sha256") + .update(value.bytes) + .digest("hex"); + expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ + available: false, + reason: expect.stringContaining("PE32+"), + }); + expect(value.loads()).toBe(0); + }); + + it("preserves host permission failure rather than promoting a partial probe", () => { + const value = fixture(); + expect( + loadWindowsNativeAuthority({ + ...value.host, + loadArtifact: () => ({ + call: () => { + throw new Error("private DACL: access denied"); + }, + }), + }), + ).toMatchObject({ + available: false, + reason: expect.stringContaining("private DACL: access denied"), + }); + }); + + it("requires every inspected native fact", () => { + const value = fixture(); + expect( + loadWindowsNativeAuthority({ + ...value.host, + loadArtifact: () => ({ call: () => ({ privateDacl: true }) }), + }).available, + ).toBe(false); + }); +}); diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts new file mode 100644 index 000000000..ee07d78cf --- /dev/null +++ b/src/windows/WindowsNativeLoader.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { PACKAGE_METADATA } from "../generatedPackageMetadata.js"; + +/** A native resource remains opaque until the addon validates its object tag. */ +export const windowsHandleSchema = z.custom( + (value): value is object => + typeof value === "object" && value !== null && !Array.isArray(value), +); + +/** Observed identity of an opened local NTFS object. */ +export const windowsFileIdentitySchema = z.strictObject({ + requestedPath: z.string(), + finalPath: z.string(), + filesystem: z.literal("NTFS"), + volumeSerial: z.string().regex(/^[a-f0-9]{16}$/u), + fileId: z.string().regex(/^[a-f0-9]{32}$/u), + size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + directory: z.boolean(), +}); + +/** Metadata bound to one packaged Windows x64 Node-API artifact. */ +export const windowsNativeManifestSchema = z.strictObject({ + packageVersion: z.string(), + platform: z.literal("win32"), + architecture: z.literal("x64"), + abiVersion: z.literal(1), + nodeApiVersion: z.literal(8), + artifact: z.literal("rea-windows-x64.node"), + artifactSha256: z.string().regex(/^[a-f0-9]{64}$/u), +}); + +const inspectionSchema = z.strictObject({ + abiVersion: z.literal(1), + nodeApiVersion: z.literal(8), + architecture: z.literal("x64"), + filesystem: windowsFileIdentitySchema, + privateDacl: z.literal(true), + atomicJobAssignment: z.literal(true), + killOnOwnerClose: z.literal(true), +}); + +type NativeCall = ( + operation: string, + arguments_: readonly unknown[], +) => unknown; +const bindingSchema = z.strictObject({ + call: z.custom( + (value): value is NativeCall => typeof value === "function", + ), +}); + +/** Validated native operations and the observations establishing readiness. */ +export interface WindowsNativeAuthority { + readonly call: NativeCall; + readonly inspection: z.infer; +} + +/** Availability of the bundled native implementation, including load failures. */ +export type WindowsNativeLoadResult = + | { readonly available: true; readonly authority: WindowsNativeAuthority } + | { readonly available: false; readonly reason: string }; + +/** Trusted package reads and module loading, replaceable in loader tests. */ +export interface WindowsNativeLoadHost { + readonly platform: NodeJS.Platform; + readonly architecture: string; + readonly nodeApiVersion: string | undefined; + readonly packageVersion: string; + readManifest(): unknown; + readArtifact(): Buffer; + loadArtifact(): unknown; +} + +/** Validate compatibility and artifact identity before invoking the native addon. */ +export const loadWindowsNativeAuthority = ( + host: WindowsNativeLoadHost, +): WindowsNativeLoadResult => { + if (host.platform !== "win32" || host.architecture !== "x64") + return { + available: false, + reason: "REA Windows native controls require a Windows x64 host.", + }; + try { + const manifest = windowsNativeManifestSchema.parse(host.readManifest()); + if (manifest.packageVersion !== host.packageVersion) + throw new Error("Windows native artifact belongs to another REA version"); + const nodeApiVersion = Number(host.nodeApiVersion); + if (!Number.isSafeInteger(nodeApiVersion) || nodeApiVersion < 8) + throw new Error("Windows native artifact requires Node-API 8 or newer"); + const artifact = host.readArtifact(); + if ( + createHash("sha256").update(artifact).digest("hex") !== + manifest.artifactSha256 + ) + throw new Error( + "Windows native artifact SHA-256 does not match package metadata", + ); + assertWindowsX64Artifact(artifact); + const binding = bindingSchema.parse(host.loadArtifact()); + const inspection = inspectionSchema.parse(binding.call("inspect", [])); + return { available: true, authority: { call: binding.call, inspection } }; + } catch (cause: unknown) { + return { + available: false, + reason: `REA Windows native controls are unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } +}; + +const assertWindowsX64Artifact = (bytes: Buffer): void => { + if (bytes.length < 64 || bytes.toString("ascii", 0, 2) !== "MZ") + throw new Error("Windows native artifact is not a PE image"); + const offset = bytes.readUInt32LE(0x3c); + if ( + offset > bytes.length - 26 || + bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" || + bytes.readUInt16LE(offset + 4) !== 0x8664 || + bytes.readUInt16LE(offset + 24) !== 0x20b + ) + throw new Error("Windows native artifact is not a Windows x64 PE32+ image"); +}; + +let cached: WindowsNativeLoadResult | undefined; + +/** Load only the package-owned artifact, lazily and once per process. */ +export const systemWindowsNativeAuthority = (): WindowsNativeLoadResult => { + cached ??= loadWindowsNativeAuthority({ + platform: process.platform, + architecture: process.arch, + nodeApiVersion: process.versions.napi, + packageVersion: PACKAGE_METADATA.version, + readManifest: () => { + const encoded = readFileSync( + new URL("../../native/windows/build/manifest.json", import.meta.url), + "utf8", + ); + const value: unknown = JSON.parse(encoded); + return value; + }, + readArtifact: () => + readFileSync( + new URL( + "../../native/windows/build/rea-windows-x64.node", + import.meta.url, + ), + ), + loadArtifact: () => { + const value: unknown = createRequire(import.meta.url)( + fileURLToPath( + new URL( + "../../native/windows/build/rea-windows-x64.node", + import.meta.url, + ), + ), + ); + return value; + }, + }); + return cached; +}; + +/** Require the verified backend when an operation depends on native authority. */ +export const requireWindowsNativeAuthority = (): WindowsNativeAuthority => { + const loaded = systemWindowsNativeAuthority(); + if (!loaded.available) throw new Error(loaded.reason); + return loaded.authority; +}; diff --git a/src/windows/WindowsOwnedProcess.test.ts b/src/windows/WindowsOwnedProcess.test.ts new file mode 100644 index 000000000..d4fff6ef8 --- /dev/null +++ b/src/windows/WindowsOwnedProcess.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { quoteWindowsProcessArgument } from "./WindowsOwnedProcess.js"; + +describe("Windows process argument representation", () => { + it.each([ + ["", '""'], + ["plain", '"plain"'], + ["two words", '"two words"'], + ['embedded"quote', '"embedded\\"quote"'], + ["D:\\folder\\", '"D:\\folder\\\\"'], + ['slash\\"quote', '"slash\\\\\\"quote"'], + ])("quotes %j without changing its CRT argument", (input, expected) => { + expect(quoteWindowsProcessArgument(input)).toBe(expected); + }); + + it("rejects NUL before crossing the native boundary", () => { + expect(() => quoteWindowsProcessArgument("a\0b")).toThrow("NUL"); + }); +}); diff --git a/src/windows/WindowsOwnedProcess.ts b/src/windows/WindowsOwnedProcess.ts new file mode 100644 index 000000000..a7d65ada9 --- /dev/null +++ b/src/windows/WindowsOwnedProcess.ts @@ -0,0 +1,190 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +import { z } from "zod"; + +import type { ProcessCleanupResult } from "../process/ProcessOwnership.js"; +import { + requireWindowsNativeAuthority, + windowsHandleSchema, + type WindowsNativeAuthority, +} from "./WindowsNativeLoader.js"; + +const launchSchema = z.strictObject({ + pid: z.number().int().positive().max(0xffff_ffff), + handle: windowsHandleSchema, + jobAssigned: z.literal(true), + killOnOwnerClose: z.literal(true), +}); +const pollSchema = z.strictObject({ + stdout: z.instanceof(Buffer), + stderr: z.instanceof(Buffer), + stdoutEnded: z.boolean(), + stderrEnded: z.boolean(), + exitCode: z.number().int().nonnegative().max(0xffff_ffff).nullable(), + activeProcesses: z.number().int().nonnegative(), +}); + +/** Encode one ordinary Windows argv token using the CRT backslash rules. */ +export const quoteWindowsProcessArgument = (value: string): string => { + if (value.includes("\0")) + throw new TypeError("Windows process argument contains NUL"); + return `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`; +}; + +/** Process events and streams backed by a retained native Job Object. */ +export class WindowsOwnedProcess extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly signalCode: NodeJS.Signals | null = null; + exitCode: number | null = null; + readonly pid: number; + readonly #handle: object; + readonly #authority: WindowsNativeAuthority; + readonly #settled: Promise; + #settle: (() => void) | undefined; + #closed = false; + #verifiedSettlement = false; + #failure: string | undefined; + #timer: NodeJS.Timeout | undefined; + #cleanup: Promise | undefined; + + constructor( + command: string, + arguments_: readonly string[], + cwd: string | undefined, + environment: NodeJS.ProcessEnv, + verbatim: boolean, + ) { + super(); + this.#authority = requireWindowsNativeAuthority(); + const entries = Object.entries(environment) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`); + const line = [ + quoteWindowsProcessArgument(command), + ...arguments_.map((value) => + verbatim ? value : quoteWindowsProcessArgument(value), + ), + ].join(" "); + const launched = launchSchema.parse( + this.#authority.call("process_spawn", [ + command, + line, + cwd ?? "", + entries, + ]), + ); + this.pid = launched.pid; + this.#handle = launched.handle; + this.#settled = new Promise((resolve) => { + this.#settle = resolve; + }); + this.#schedule(); + } + + /** Terminate the retained job, never a PID supplied by another caller. */ + kill(signal?: NodeJS.Signals | number): boolean { + if (signal === 0) + return ( + !this.#closed && + z + .number() + .parse(this.#authority.call("process_alive", [this.#handle])) > 0 + ); + return this.#closed + ? false + : z + .boolean() + .parse(this.#authority.call("process_terminate", [this.#handle])); + } + + /** Terminate and verify that the entire job has settled before releasing it. */ + cleanup(): Promise { + this.#cleanup ??= this.#stop(); + return this.#cleanup; + } + + async #stop(): Promise { + if (this.#closed) + return this.#verifiedSettlement + ? { cleaned: true, signaled: false } + : { + cleaned: false, + reason: + this.#failure ?? "Windows Job Object settlement was not verified", + }; + let timer: NodeJS.Timeout | undefined; + try { + const signaled = this.kill(); + const settled = await Promise.race([ + this.#settled.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 5_000); + }), + ]); + return settled && this.#verifiedSettlement + ? { cleaned: true, signaled } + : { + cleaned: false, + reason: + this.#failure ?? + "Windows Job Object did not settle within the cleanup deadline", + }; + } catch (cause: unknown) { + return { + cleaned: false, + reason: cause instanceof Error ? cause.message : String(cause), + }; + } finally { + if (timer !== undefined) clearTimeout(timer); + if (!this.#closed) this.#release(); + } + } + + #schedule(): void { + this.#timer = setTimeout(() => this.#poll(), 25); + } + + #poll(): void { + if (this.#closed) return; + try { + const state = pollSchema.parse( + this.#authority.call("process_poll", [this.#handle]), + ); + if (state.stdout.length > 0) this.stdout.write(state.stdout); + if (state.stderr.length > 0) this.stderr.write(state.stderr); + if (state.exitCode !== null && this.exitCode === null) { + this.exitCode = state.exitCode; + this.emit("exit", state.exitCode, null); + } + if ( + state.exitCode !== null && + state.stdoutEnded && + state.stderrEnded && + state.activeProcesses === 0 + ) { + this.#verifiedSettlement = true; + this.#release(); + } else this.#schedule(); + } catch (cause: unknown) { + const failure = cause instanceof Error ? cause : new Error(String(cause)); + this.#failure = failure.message; + // Native lifecycle failures belong to the error/cleanup channels, never + // the captured stderr observation produced by the child itself. + if (this.listenerCount("error") > 0) this.emit("error", failure); + this.#release(); + } + } + + #release(): void { + if (this.#closed) return; + if (this.#timer !== undefined) clearTimeout(this.#timer); + this.#authority.call("process_close", [this.#handle]); + this.#closed = true; + this.stdout.end(); + this.stderr.end(); + this.#settle?.(); + this.emit("close", this.exitCode, null); + } +} diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts new file mode 100644 index 000000000..5d91dcfb8 --- /dev/null +++ b/src/windows/WindowsPrivateRuntime.ts @@ -0,0 +1,163 @@ +import { win32 } from "node:path"; + +import { z } from "zod"; + +import { + requireWindowsNativeAuthority, + windowsFileIdentitySchema, + windowsHandleSchema, + type WindowsNativeAuthority, +} from "./WindowsNativeLoader.js"; + +const runtimeSchema = windowsFileIdentitySchema.extend({ + path: z.string(), + handle: windowsHandleSchema, + privateDacl: z.literal(true), +}); +const snapshotSchema = z.strictObject({ + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + source: windowsFileIdentitySchema, + snapshot: windowsFileIdentitySchema, +}); +const fileSchema = windowsFileIdentitySchema.extend({ + handle: windowsHandleSchema, +}); +const runtimes = new Map(); + +/** Retain private DACL and path handles until native recursive cleanup settles. */ +export class WindowsPrivateRuntime { + // One native snapshot can own cancellation for this runtime at a time. A + // second request throws ERROR_BUSY before #pending or its abort listener is + // replaced, so aborting a rejected request cannot cancel the accepted copy. + #pending: Promise | undefined; + #closed = false; + #closePromise: Promise | undefined; + private constructor( + readonly observation: z.infer, + private readonly authority: WindowsNativeAuthority, + ) {} + + /** Create a new directory with its private DACL installed at creation. */ + static create(parent: string, prefix: string): WindowsPrivateRuntime { + const authority = requireWindowsNativeAuthority(); + const observation = runtimeSchema.parse( + authority.call("runtime_create", [parent, prefix]), + ); + const runtime = new WindowsPrivateRuntime(observation, authority); + runtimes.set(observation.path.toLowerCase(), runtime); + return runtime; + } + + /** Open a private child directory through retained, verified parent handles. */ + mkdir(relativePath: string): void { + this.authority.call("runtime_mkdir", [ + this.observation.handle, + relativePath, + ]); + } + + /** Write a new private file once and retain its immutable handle. */ + writeFile(relativePath: string, content: string): void { + this.authority.call("runtime_write", [ + this.observation.handle, + relativePath, + Buffer.from(content), + ]); + } + + /** Copy admitted source bytes and digest the bytes actually written. */ + async snapshot( + sourcePath: string, + relativePath: string, + signal?: AbortSignal, + ): Promise> { + // Admission is synchronous; record ownership only after native acceptance. + const pending = Promise.resolve( + this.authority.call("runtime_snapshot", [ + this.observation.handle, + sourcePath, + relativePath, + ]), + ); + this.#pending = pending; + const cancel = (): void => { + this.authority.call("runtime_snapshot_cancel", [this.observation.handle]); + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted === true) cancel(); + try { + return snapshotSchema.parse(await pending); + } finally { + signal?.removeEventListener("abort", cancel); + if (this.#pending === pending) this.#pending = undefined; + } + } + + /** Read a private endpoint from an opened file, without re-opening its path. */ + readFile(relativePath: string): string { + const file = fileSchema.parse( + this.authority.call("runtime_open", [ + this.observation.handle, + relativePath, + ]), + ); + try { + const chunks: Buffer[] = []; + for (let offset = 0; offset < file.size;) { + const value = z + .instanceof(Buffer) + .parse( + this.authority.call("read", [ + file.handle, + offset, + Math.min(65_536, file.size - offset), + ]), + ); + if (value.length === 0) + throw new Error( + "Private runtime file ended before its observed size", + ); + offset += value.length; + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); + } finally { + this.authority.call("close", [file.handle]); + } + } + + /** Remove owned objects by handle, unlinking reparse entries without traversal. */ + close(): Promise { + this.#closePromise ??= this.#remove(); + return this.#closePromise; + } + + async #remove(): Promise { + if (this.#closed) return; + if (this.#pending !== undefined) + this.authority.call("runtime_snapshot_cancel", [this.observation.handle]); + await this.#pending?.catch(() => undefined); + try { + this.authority.call("runtime_close", [this.observation.handle]); + } finally { + this.#closed = true; + runtimes.delete(this.observation.path.toLowerCase()); + } + } +} + +/** Select an already-owned runtime; an arbitrary pathname cannot grant authority. */ +export const windowsPrivateRuntime = ( + rootPath: string, +): WindowsPrivateRuntime => { + const runtime = runtimes.get(rootPath.toLowerCase()); + if (runtime === undefined) + throw new Error(`No native private runtime owns ${rootPath}`); + return runtime; +}; + +/** Read a runtime endpoint using the lease that owns its parent directory. */ +export const readWindowsPrivateRuntimeFile = (path: string): string => { + const parent = win32.dirname(path); + return windowsPrivateRuntime(parent).readFile(win32.basename(path)); +}; diff --git a/tests/boundary/filesystem/setupUnavailableProvider.test.ts b/tests/boundary/filesystem/setupUnavailableProvider.test.ts new file mode 100644 index 000000000..26c4532a4 --- /dev/null +++ b/tests/boundary/filesystem/setupUnavailableProvider.test.ts @@ -0,0 +1,95 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js"; +import { systemSetupHost } from "../../../src/application/SetupHost.js"; +import { runSetup } from "../../../src/application/Setup.js"; +import { options } from "../../../src/application/Setup.fixture.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("setup with configured but unavailable providers", () => { + it("preserves Hopper settings and makes the second setup perform no writes", async () => { + const root = await createTestTempDirectory("rea-setup-unavailable-"); + roots.push(root); + const client = supportedClients(root, "darwin", {}).find( + (candidate) => candidate.name === "cursor", + ); + if (client === undefined) throw new Error("Cursor registration is missing"); + const original = '{"theme":"dark"}\n'; + await mkdir(dirname(client.configPath), { recursive: true }); + await writeFile(client.configPath, original); + const environment = { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }; + const doctor = createDoctorHostFixture({ + validTarget: () => Promise.resolve(false), + executable: () => Promise.resolve(false), + clientRegistrations: () => + readClientRegistrationStatuses(root, undefined, { + platform: "darwin", + environment: {}, + }), + providerInspections: () => + Promise.resolve([ + { + id: "hopper", + configured: true, + available: false, + providerVersion: "6", + registrationEnvironment: environment, + checks: [ + { + name: "hopper-license", + ok: false, + code: "unavailable", + detail: "Runtime license is unavailable", + classification: "missing_analysis_engine", + remediation: null, + }, + ], + }, + ]), + }); + const host = { + ...systemSetupHost(doctor), + detectedClients: () => Promise.resolve([client]), + supportedClients: () => Promise.resolve([client]), + }; + const selected = { + ...options(true), + clientIds: ["cursor"], + installSkill: false, + proposeHopper: false, + // This lane checks registration effects; no deep provider is required. + readinessScope: { clients: [], providers: [], skill: false }, + }; + const first = await runSetup(selected, host); + expect(first.status).toBe("ready"); + expect(first.clients.cursor).toMatchObject({ status: "configured" }); + const configured = await readFile(client.configPath, "utf8"); + expect(JSON.parse(configured)).toMatchObject({ + theme: "dark", + mcpServers: { rea: { env: environment } }, + }); + await expect( + readFile(`${client.configPath}.rea.backup`, "utf8"), + ).resolves.toBe(original); + + const second = await runSetup(selected, host); + expect(second.status).toBe("ready"); + expect(second.plannedActions).toEqual([]); + expect(second.appliedActions).toEqual([]); + await expect(readFile(client.configPath, "utf8")).resolves.toBe(configured); + await expect( + readFile(`${client.configPath}.rea.backup`, "utf8"), + ).resolves.toBe(original); + }); +}); diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index b3b1788c6..24f817962 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -14,6 +14,7 @@ import { spawnOwnedProviderProcess, type ProviderProcessDiagnostic, } from "../../../src/process/ProviderProcess.js"; +import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../../../src/process/WindowsAuthority.js"; import { spawnProviderProcessFixture, stopProviderProcessFixture, @@ -69,7 +70,7 @@ describe("provider process runtime and wait primitives", () => { PrivateRuntimeRoot.create({ platform: "win32" }), ).rejects.toMatchObject({ code: "private-runtime-root-authority-unavailable", - message: expect.stringContaining("chmod(0700)"), + message: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, }); }); diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index c85b2faba..3220ca610 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js"; +import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { ghidraHeadlessArguments, @@ -21,6 +23,7 @@ const fixturePath = fileURLToPath( ), ); const roots: string[] = []; +const runtimes: PrivateRuntimeRoot[] = []; const expectIsolatedEnvironment = ( environment: Record, @@ -32,6 +35,8 @@ const expectIsolatedEnvironment = ( ...(process.platform === "win32" ? { USERPROFILE: join(runtimeRoot, "home"), + APPDATA: join(runtimeRoot, "config"), + LOCALAPPDATA: join(runtimeRoot, "cache"), TEMP: join(runtimeRoot, "tmp"), TMP: join(runtimeRoot, "tmp"), } @@ -42,7 +47,10 @@ const expectIsolatedEnvironment = ( XDG_DATA_HOME: join(runtimeRoot, "data"), GHIDRA_JAVA_OPTIONS: "", JAVA_TOOL_OPTIONS: "", - JDK_JAVA_OPTIONS: "", + JDK_JAVA_OPTIONS: + process.platform === "win32" + ? `"-Duser.home=${join(runtimeRoot, "home")}" "-Djava.io.tmpdir=${join(runtimeRoot, "tmp")}" "-XX:-UsePerfData"` + : "", _JAVA_OPTIONS: "", JAVA_HOME: javaHome, REA_PROCESS_RUN_ID: "d6fcbb66-e829-4ff6-a535-0035aec63139", @@ -59,6 +67,7 @@ beforeAll(async () => { }); afterEach(async () => { vi.unstubAllEnvs(); + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); await Promise.all( roots.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); @@ -191,8 +200,11 @@ describe("Ghidra headless launcher", () => { vi.stubEnv("JAVA_TOOL_OPTIONS", "-Duser.home=/unapproved/home"); vi.stubEnv("JDK_JAVA_OPTIONS", "-XX:MaxRAMPercentage=99"); vi.stubEnv("_JAVA_OPTIONS", "-Xmx99G"); - const runtimeRoot = await createTestTempDirectory("rea-launcher-test-"); - roots.push(runtimeRoot); + const parent = await createTestTempDirectory("rea-launcher-test-"); + roots.push(parent); + const runtime = await createGhidraTestRuntime(parent); + runtimes.push(runtime); + const runtimeRoot = runtime.path; const token = "secret-token-that-must-not-leak"; const javaHome = process.platform === "win32" ? "C:\\Java\\jdk-21" : "/opt/jdk-21"; @@ -226,16 +238,11 @@ describe("Ghidra headless launcher", () => { descriptor_has_token: true, }); expectIsolatedEnvironment(capture.environment, runtimeRoot, javaHome); - expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).toContain( - `-Duser.home=${join(runtimeRoot, "home")}`, - ); expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).toBe( - `-Duser.home=${join(runtimeRoot, "home")} -Djava.io.tmpdir=${join(runtimeRoot, "tmp")}`, + process.platform === "win32" + ? "" + : `-Duser.home=${join(runtimeRoot, "home")} -Djava.io.tmpdir=${join(runtimeRoot, "tmp")}`, ); - if (process.platform === "win32") - expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).not.toContain( - '"', - ); if (process.platform !== "win32") expect( (await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777, diff --git a/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts index afaa6e559..30e796d15 100644 --- a/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts @@ -5,18 +5,38 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js"; +import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { createGhidraTargetSnapshot } from "../../../../src/ghidra/GhidraTargetSnapshot.js"; const roots: string[] = []; +const runtimes: PrivateRuntimeRoot[] = []; afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); await Promise.all( roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); }); describe("Ghidra target snapshot", () => { + it("uses the selected installation platform instead of the ambient host", async () => { + const root = await createTestTempDirectory("rea-ghidra-platform-"); + roots.push(root); + const source = join(root, "source.exe"); + const bytes = Buffer.from("platform fixture"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(source, bytes); + const snapshot = await createGhidraTargetSnapshot(source, root, sha256, { + platform: "linux", + }); + expect(snapshot.admission).toBeUndefined(); + await expect(readFile(snapshot.path)).resolves.toEqual(bytes); + await expect( + createGhidraTargetSnapshot(source, root, sha256, { platform: "win32" }), + ).rejects.toThrow(/No native private runtime owns/u); + }); it("copies an exact digest-bound target into the private runtime", async () => { const root = await createTestTempDirectory("rea-ghidra-snapshot-"); roots.push(root); @@ -24,27 +44,39 @@ describe("Ghidra target snapshot", () => { const bytes = Buffer.from("native PE fixture"); const sha256 = createHash("sha256").update(bytes).digest("hex"); await writeFile(source, bytes); + const runtime = await createGhidraTestRuntime(root); + runtimes.push(runtime); - const snapshot = await createGhidraTargetSnapshot(source, root, sha256); + const snapshot = await createGhidraTargetSnapshot( + source, + runtime.path, + sha256, + ); - expect(snapshot).toEqual({ - path: join(root, `target-${sha256.slice(0, 12)}.exe`), + expect(snapshot).toMatchObject({ + path: join(runtime.path, `target-${sha256.slice(0, 12)}.exe`), sha256, }); await expect(readFile(snapshot.path)).resolves.toEqual(bytes); }); - it("removes a snapshot whose digest differs from admission", async () => { + it("rejects a mismatched digest and removes the failed runtime snapshot", async () => { const root = await createTestTempDirectory("rea-ghidra-snapshot-"); roots.push(root); const source = join(root, "source with unsafe extension.%PATH%"); await writeFile(source, "changed target"); + const runtime = await createGhidraTestRuntime(root); + runtimes.push(runtime); const expected = "a".repeat(64); - const snapshotPath = join(root, `target-${expected.slice(0, 12)}.bin`); + const snapshotPath = join( + runtime.path, + `target-${expected.slice(0, 12)}.bin`, + ); await expect( - createGhidraTargetSnapshot(source, root, expected), + createGhidraTargetSnapshot(source, runtime.path, expected), ).rejects.toThrow(/digest mismatch/u); + await runtime.close(); await expect(access(snapshotPath)).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/tests/boundary/providers/ghidra/ghidraTransport.test.ts b/tests/boundary/providers/ghidra/ghidraTransport.test.ts index 083a58bda..fa703d7d2 100644 --- a/tests/boundary/providers/ghidra/ghidraTransport.test.ts +++ b/tests/boundary/providers/ghidra/ghidraTransport.test.ts @@ -4,12 +4,19 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { + createGhidraTestRuntime, + publishGhidraTestEndpoint, +} from "../../../fixtures/ghidraRuntime.js"; +import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { observeGhidraEndpoint } from "../../../../src/ghidra/GhidraTransport.js"; const roots: string[] = []; +const runtimes: PrivateRuntimeRoot[] = []; afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); await Promise.all( roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); @@ -33,9 +40,10 @@ describe("Ghidra local transport", () => { it("accepts only an exact IPv4 loopback endpoint record", async () => { const root = await createTestTempDirectory("rea-ghidra-transport-"); roots.push(root); - const path = join(root, "bridge-endpoint.json"); - await writeFile( - path, + const runtime = await createGhidraTestRuntime(root); + runtimes.push(runtime); + const path = await publishGhidraTestEndpoint( + runtime, `${JSON.stringify({ host: "127.0.0.1", port: 49152 })}\n`, ); @@ -59,8 +67,12 @@ describe("Ghidra local transport", () => { async (value) => { const root = await createTestTempDirectory("rea-ghidra-transport-"); roots.push(root); - const path = join(root, "bridge-endpoint.json"); - await writeFile(path, JSON.stringify(value)); + const runtime = await createGhidraTestRuntime(root); + runtimes.push(runtime); + const path = await publishGhidraTestEndpoint( + runtime, + JSON.stringify(value), + ); await expect( observeGhidraEndpoint({ diff --git a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts index 86e444014..39eb8ff61 100644 --- a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts +++ b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts @@ -35,7 +35,7 @@ describe("real Windows Ghidra workflow trust boundary", () => { expect.objectContaining({ uses: "actions/upload-artifact@v4", with: expect.objectContaining({ - path: "windows-ghidra-proof.log", + path: expect.stringContaining("windows-ghidra-proof.log"), }), }), ]), diff --git a/tests/conformance/providers/ghidra/windowsCapabilities.test.ts b/tests/conformance/providers/ghidra/windowsCapabilities.test.ts index ae1292228..86fe3ec3b 100644 --- a/tests/conformance/providers/ghidra/windowsCapabilities.test.ts +++ b/tests/conformance/providers/ghidra/windowsCapabilities.test.ts @@ -12,6 +12,23 @@ const dependencies = ( ): WindowsCapabilityDependencies => ({ platform: "win32", architecture: "x64", + nativeCapabilities: () => ({ + job_object_process_ownership: { + available: false, + reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + proof: "not-proven", + }, + private_runtime_dacl: { + available: false, + reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + proof: "not-proven", + }, + reparse_safe_path_admission: { + available: false, + reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + proof: "not-proven", + }, + }), probeSymlinkCreation: () => Promise.resolve({ available: false, reason: "EPERM" }), probeNoFollowOpen: () => ({ @@ -39,7 +56,8 @@ describe("Windows host capability report", () => { it("never treats a numeric POSIX flag as Windows reparse authority", () => { expect(systemNoFollowOpenCapability("win32", 0x20_000)).toEqual({ available: false, - reason: "Windows reparse-safe handle admission is not implemented", + reason: + "Node pathname O_NOFOLLOW is not Windows handle authority; native admission is reported separately in security.", }); expect(systemNoFollowOpenCapability("linux", 0x20_000)).toEqual({ available: true, diff --git a/tests/fixtures/captureGhidraLaunch.mjs b/tests/fixtures/captureGhidraLaunch.mjs index 556c47316..1a8ca982d 100755 --- a/tests/fixtures/captureGhidraLaunch.mjs +++ b/tests/fixtures/captureGhidraLaunch.mjs @@ -15,6 +15,8 @@ await writeFile( environment: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, + APPDATA: process.env.APPDATA, + LOCALAPPDATA: process.env.LOCALAPPDATA, TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP, diff --git a/tests/fixtures/ghidraRuntime.ts b/tests/fixtures/ghidraRuntime.ts new file mode 100644 index 000000000..c67bf2439 --- /dev/null +++ b/tests/fixtures/ghidraRuntime.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import type { ProcessCleanupResult } from "../../src/process/ProcessOwnership.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; + +/** Allocate the same private runtime boundary used by the real Ghidra client. */ +export const createGhidraTestRuntime = ( + parent: string, +): Promise => + PrivateRuntimeRoot.create({ parent, prefix: "rea-ghidra-test-" }); + +/** Publish a closed endpoint file with the provider child's actual file owner. */ +export const publishGhidraTestEndpoint = async ( + runtime: PrivateRuntimeRoot, + content: string, +): Promise => { + const path = join(runtime.path, "bridge-endpoint.json"); + if (process.platform !== "win32") { + await writeFile(path, content, { flag: "wx" }); + return path; + } + const launched = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: [ + "-e", + "require('node:fs').writeFileSync(process.argv[1],process.argv[2],{flag:'wx'})", + path, + content, + ], + runId: randomUUID(), + }); + let cleanup: ProcessCleanupResult | undefined; + try { + await new Promise((resolve, reject) => { + launched.process.once("error", reject); + launched.process.once("close", (code: number | null) => { + if (code === 0) resolve(); + else reject(new Error(`Endpoint fixture exited with code ${code}`)); + }); + }); + } finally { + cleanup = await launched.cleanup?.(); + } + if (cleanup?.cleaned !== true) + throw new Error("Endpoint fixture process cleanup was not verified"); + return path; +}; diff --git a/tests/fixtures/providerProcess.ts b/tests/fixtures/providerProcess.ts index f13a1ca68..48bd94c3f 100644 --- a/tests/fixtures/providerProcess.ts +++ b/tests/fixtures/providerProcess.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { fileURLToPath } from "node:url"; +import type { ProviderProcessHandle } from "../../src/process/ProviderProcess.js"; const fixturePath = fileURLToPath( new URL("./providerProcess.mjs", import.meta.url), @@ -18,7 +19,7 @@ export const spawnProviderProcessFixture = ( /** Wait until a long-lived fixture has installed its signal behavior. */ export const waitForProviderProcessReady = ( - child: ChildProcess, + child: ProviderProcessHandle, timeoutMs = 2_000, ): Promise => new Promise((resolve, reject) => { @@ -54,7 +55,7 @@ export const waitForProviderProcessReady = ( /** Read the distinct process-group leader created by the detached-child mode. */ export const waitForDetachedProviderChild = ( - child: ChildProcess, + child: ProviderProcessHandle, timeoutMs = 2_000, ): Promise => new Promise((resolve, reject) => { @@ -90,7 +91,7 @@ export const waitForDetachedProviderChild = ( /** Kill a fixture if necessary and wait for its process handle to settle. */ export const stopProviderProcessFixture = async ( - child: ChildProcess, + child: ProviderProcessHandle, ): Promise => { if (child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolve) => { diff --git a/tests/fixtures/windows/nonAdminRunner.cc b/tests/fixtures/windows/nonAdminRunner.cc new file mode 100644 index 000000000..33c4134ed --- /dev/null +++ b/tests/fixtures/windows/nonAdminRunner.cc @@ -0,0 +1,112 @@ +// Test-only launcher: reuse a non-elevated shell token belonging to the current user. +// No accounts, host policies, profiles, or caller privileges are changed. +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +static std::wstring quote(const std::wstring& value) { + std::wstring result = L"\""; + size_t slashes = 0; + for (const auto character : value) { + if (character == L'\\') { ++slashes; continue; } + result.append(slashes * (character == L'\"' ? 2 : 1), L'\\'); + slashes = 0; + if (character == L'\"') result += L'\\'; + result += character; + } + result.append(slashes * 2, L'\\'); + return result + L"\""; +} + +int wmain(int count, wchar_t** args) { + if (count < 3) return 64; + HANDLE current = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ADJUST_DEFAULT | TOKEN_ASSIGN_PRIMARY, ¤t)) return 65; + std::vector callerUser(4096); DWORD callerUserSize; + if (!GetTokenInformation(current, TokenUser, callerUser.data(), callerUser.size(), &callerUserSize)) return 66; + HANDLE primary = nullptr; + const auto snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + PROCESSENTRY32W entry{}; entry.dwSize = sizeof(entry); + if (snapshot != INVALID_HANDLE_VALUE && Process32FirstW(snapshot, &entry)) do { + if (_wcsicmp(entry.szExeFile, L"explorer.exe") != 0) continue; + HANDLE candidate = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); + HANDLE token = nullptr; + if (candidate && OpenProcessToken(candidate, TOKEN_QUERY | TOKEN_DUPLICATE, &token)) { + TOKEN_ELEVATION elevation{}; DWORD size; + std::vector user(4096); + if (GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size) && !elevation.TokenIsElevated && + GetTokenInformation(token, TokenUser, user.data(), user.size(), &size) && + EqualSid(reinterpret_cast(user.data())->User.Sid, + reinterpret_cast(callerUser.data())->User.Sid)) + DuplicateTokenEx(token, MAXIMUM_ALLOWED, nullptr, SecurityImpersonation, TokenPrimary, &primary); + CloseHandle(token); + } + if (candidate) CloseHandle(candidate); + if (primary) break; + } while (Process32NextW(snapshot, &entry)); + if (snapshot != INVALID_HANDLE_VALUE) CloseHandle(snapshot); + CloseHandle(current); + if (!primary) { std::fprintf(stderr, "No non-elevated shell token belonging to the current user.\n"); return 67; } + unsigned char medium[SECURITY_MAX_SID_SIZE]; DWORD mediumSize = sizeof(medium); + if (!CreateWellKnownSid(WinMediumLabelSid, nullptr, medium, &mediumSize)) return 71; + TOKEN_MANDATORY_LABEL label{{medium, SE_GROUP_INTEGRITY}}; + if (!SetTokenInformation(primary, TokenIntegrityLevel, &label, sizeof(label) + GetLengthSid(medium))) { + std::fprintf(stderr, "Medium-integrity fixture token failed: %lu\n", GetLastError()); CloseHandle(primary); return 72; + } + std::wstring line; + for (int index = 1; index < count; ++index) { if (index > 1) line += L' '; line += quote(args[index]); } + std::vector buffer(line.begin(), line.end()); buffer.push_back(0); + std::vector user(4096); DWORD userSize; + if (!GetTokenInformation(primary, TokenUser, user.data(), user.size(), &userSize)) return 73; + wchar_t* userSid = nullptr; + if (!ConvertSidToStringSidW(reinterpret_cast(user.data())->User.Sid, &userSid)) return 74; + const std::wstring sddl = L"O:" + std::wstring(userSid) + L"D:P(A;;GA;;;" + userSid + L")(A;;GA;;;SY)S:(ML;;NW;;;ME)"; + LocalFree(userSid); + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) return 75; + PACL acl = nullptr; BOOL present, defaulted; + if (!GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) || !present) return 78; + TOKEN_DEFAULT_DACL defaultDacl{acl}; + TOKEN_OWNER owner{reinterpret_cast(user.data())->User.Sid}; + if (!SetTokenInformation(primary, TokenDefaultDacl, &defaultDacl, sizeof(defaultDacl)) || + !SetTokenInformation(primary, TokenOwner, &owner, sizeof(owner))) return 79; + SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE}; + const auto stationName = L"rea-fixture-" + std::to_wstring(GetCurrentProcessId()); + const auto originalStation = GetProcessWindowStation(); + const auto station = CreateWindowStationW(stationName.c_str(), 0, WINSTA_ALL_ACCESS, &security); + if (!station || !SetProcessWindowStation(station)) return 76; + const auto desktop = CreateDesktopW(L"fixture", nullptr, nullptr, 0, GENERIC_ALL, &security); + SetProcessWindowStation(originalStation); LocalFree(descriptor); + if (!desktop) return 77; + auto desktopName = stationName + L"\\fixture"; + STARTUPINFOW startup{}; startup.cb = sizeof(startup); + startup.lpDesktop = desktopName.data(); + PROCESS_INFORMATION process{}; + auto environment = GetEnvironmentStringsW(); + wchar_t workingDirectory[32768]; GetCurrentDirectoryW(32768, workingDirectory); + const BOOL started = CreateProcessWithTokenW(primary, 0, args[1], buffer.data(), + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS, + environment, workingDirectory, &startup, &process); + const DWORD failure = GetLastError(); + FreeEnvironmentStringsW(environment); CloseHandle(primary); + if (!started) { std::fprintf(stderr, "Non-admin fixture launch failed: %lu\n", failure); return 68; } + HANDLE job = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION policy{}; + policy.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!job || !SetInformationJobObject(job, JobObjectExtendedLimitInformation, &policy, sizeof(policy)) || + !AssignProcessToJobObject(job, process.hProcess) || !SetProcessAffinityMask(process.hProcess, 3)) { + TerminateProcess(process.hProcess, 69); CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (job) CloseHandle(job); return 69; + } + ResumeThread(process.hThread); CloseHandle(process.hThread); + const DWORD waited = WaitForSingleObject(process.hProcess, 900000); + DWORD code = 70; + if (waited == WAIT_OBJECT_0) GetExitCodeProcess(process.hProcess, &code); + CloseHandle(job); CloseHandle(process.hProcess); + CloseDesktop(desktop); CloseWindowStation(station); + return static_cast(code); +} diff --git a/tests/fixtures/windows/processBoundary.cc b/tests/fixtures/windows/processBoundary.cc new file mode 100644 index 000000000..18dcb2dbd --- /dev/null +++ b/tests/fixtures/windows/processBoundary.cc @@ -0,0 +1,98 @@ +// Independent Windows observations for the real native conformance lane. +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +int wmain(int count, wchar_t** args) { + if (count == 2 && std::wstring(args[1]) == L"environment") { + wchar_t* block = GetEnvironmentStringsW(); + if (!block) return 7; + std::wstring previous; + bool sorted = true; + for (const wchar_t* entry = block; *entry; entry += std::wcslen(entry) + 1) { + const auto value = std::wstring(entry); + const auto end = value.find(L'=', value.front() == L'=' ? 1 : 0); + const auto name = value.substr(0, end); + if (!previous.empty() && CompareStringOrdinal(previous.data(), previous.size(), + name.data(), name.size(), TRUE) != CSTR_LESS_THAN) + sorted = false; + previous = name; + } + FreeEnvironmentStringsW(block); + auto value = [](const wchar_t* name) { + wchar_t text[128]{}; GetEnvironmentVariableW(name, text, 128); return std::wstring(text); + }; + const auto sameUnicodeName = CompareStringOrdinal(L"REA_\u00df", -1, L"REA_SS", -1, TRUE) == CSTR_EQUAL; + const auto expectedSharp = sameUnicodeName ? L"double" : L"sharp"; + std::printf("{\"ordinallySorted\":%s,\"lastDuplicateSelected\":%s,\"unicodeNamesPreserved\":%s}\n", + sorted ? "true" : "false", value(L"REA_CASE") == L"last" ? "true" : "false", + value(L"REA_\u00df") == expectedSharp && value(L"REA_SS") == L"double" ? "true" : "false"); + return 0; + } + if (count == 4 && std::wstring(args[1]) == L"reparse-directory") { + // Only the conformance lane's disposable, nonempty source directory is used. + const auto substitute = L"\\??\\" + std::wstring(args[3]); + const auto substituteBytes = static_cast(substitute.size() * sizeof(wchar_t)); + struct MountPoint { + DWORD tag; WORD dataLength, reserved; + WORD substituteOffset, substituteLength, printOffset, printLength; + } header{IO_REPARSE_TAG_MOUNT_POINT, static_cast(8 + substituteBytes + 4), 0, + 0, substituteBytes, static_cast(substituteBytes + 2), 0}; + std::vector buffer(sizeof(header) + substituteBytes + 4, 0); + std::memcpy(buffer.data(), &header, sizeof(header)); + std::memcpy(buffer.data() + sizeof(header), substitute.data(), substituteBytes); + HANDLE directory = CreateFileW(args[2], GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr); + DWORD observed = directory == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + if (directory != INVALID_HANDLE_VALUE) { + DWORD bytes; + if (!DeviceIoControl(directory, FSCTL_SET_REPARSE_POINT, buffer.data(), + static_cast(buffer.size()), nullptr, 0, &bytes, nullptr)) + observed = GetLastError(); + CloseHandle(directory); + } + std::printf("{\"reparseConversionDenied\":%s,\"win32Code\":%lu}\n", + observed == ERROR_DIR_NOT_EMPTY || observed == ERROR_SHARING_VIOLATION || observed == ERROR_ACCESS_DENIED + ? "true" : "false", observed); + return 0; + } + if (count > 1 && std::wstring(args[1]) == L"breakaway") { + wchar_t executable[32768]; GetModuleFileNameW(nullptr, executable, 32768); + std::wstring line = L"\"" + std::wstring(executable) + L"\" exit"; + STARTUPINFOW startup{}; startup.cb = sizeof(startup); PROCESS_INFORMATION child{}; + if (CreateProcessW(executable, line.data(), nullptr, nullptr, FALSE, + CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW, nullptr, nullptr, &startup, &child)) { + TerminateProcess(child.hProcess, 1); CloseHandle(child.hThread); CloseHandle(child.hProcess); + std::puts("{\"breakawayDenied\":false}"); return 1; + } + std::printf("{\"breakawayDenied\":%s,\"win32Code\":%lu}\n", + GetLastError() == ERROR_ACCESS_DENIED ? "true" : "false", GetLastError()); return 0; + } + HANDLE token = nullptr; DWORD size; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &token)) return 2; + TOKEN_ELEVATION elevation{}; + if (!GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size)) return 3; + std::vector user(4096), owner(4096), integrity(4096); + if (!GetTokenInformation(token, TokenUser, user.data(), user.size(), &size) || + !GetTokenInformation(token, TokenOwner, owner.data(), owner.size(), &size) || + !GetTokenInformation(token, TokenIntegrityLevel, integrity.data(), integrity.size(), &size)) return 4; + HANDLE impersonation = nullptr; + if (!DuplicateToken(token, SecurityIdentification, &impersonation)) return 5; + unsigned char administrator[SECURITY_MAX_SID_SIZE]; DWORD administratorSize = sizeof(administrator); + CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, administrator, &administratorSize); + BOOL member = TRUE; + if (!CheckTokenMembership(impersonation, administrator, &member)) return 6; + auto sid = reinterpret_cast(integrity.data())->Label.Sid; + const auto level = *GetSidSubAuthority(sid, *GetSidSubAuthorityCount(sid) - 1); + const bool ownerIsUser = EqualSid(reinterpret_cast(user.data())->User.Sid, + reinterpret_cast(owner.data())->Owner); + std::printf("{\"elevated\":%s,\"administrator\":%s,\"integrityLevel\":%lu,\"defaultOwnerIsUser\":%s}\n", + elevation.TokenIsElevated ? "true" : "false", member ? "true" : "false", level, + ownerIsUser ? "true" : "false"); + CloseHandle(impersonation); CloseHandle(token); return 0; +} diff --git a/vitest.config.ts b/vitest.config.ts index b540ee261..afaf64168 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,7 +37,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{artifacts,browser,dotnet,ghidra,hopper,native,process,reference,server}/**/*.test.ts", + "src/{artifacts,browser,dotnet,ghidra,hopper,native,process,reference,server,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,